directory.ts 42 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901
  1. /**
  2. * Directory Management
  3. *
  4. * Manages the .codegraph/ directory structure for CodeGraph data.
  5. */
  6. import * as fs from 'fs';
  7. import * as os from 'os';
  8. import * as path from 'path';
  9. /** The default per-project data directory name. */
  10. const DEFAULT_CODEGRAPH_DIR = '.codegraph';
  11. let warnedBadDirName = false;
  12. /**
  13. * Resolve the per-project data directory name, honoring the `CODEGRAPH_DIR`
  14. * environment override (default `.codegraph`). The override is a single path
  15. * segment that lives in the project root.
  16. *
  17. * Why this exists: two environments that share one working tree must NOT share
  18. * one `.codegraph/` — most concretely Windows-native and WSL (issue #636). The
  19. * daemon lockfile (`.codegraph/daemon.pid`) records a platform-specific pid and
  20. * socket path (a Windows named pipe vs a WSL Unix socket), and SQLite file
  21. * locking across the WSL2 ↔ Windows filesystem boundary is unreliable, so two
  22. * daemons sharing one index risks corruption. Setting `CODEGRAPH_DIR=.codegraph-win`
  23. * on one side gives each environment its own index in the same tree.
  24. *
  25. * Read live (not captured at load) so it is both process-accurate and testable.
  26. * An override that isn't a plain directory name — empty, containing a path
  27. * separator, `.`, `..`/traversal, or absolute — is ignored (we keep the
  28. * default) rather than risk writing the index outside the project or into the
  29. * project root itself; we warn once to stderr so the misconfiguration is seen.
  30. */
  31. export function codeGraphDirName(): string {
  32. const raw = process.env.CODEGRAPH_DIR?.trim();
  33. if (!raw) return DEFAULT_CODEGRAPH_DIR;
  34. const invalid =
  35. raw === '.' ||
  36. raw.includes('..') ||
  37. raw.includes('/') ||
  38. raw.includes('\\') ||
  39. path.isAbsolute(raw);
  40. if (invalid) {
  41. if (!warnedBadDirName) {
  42. warnedBadDirName = true;
  43. // stderr only — stdout is the MCP protocol channel.
  44. console.warn(
  45. `[codegraph] Ignoring invalid CODEGRAPH_DIR="${raw}" — it must be a plain ` +
  46. `directory name (no path separators, no "..", not absolute). Using "${DEFAULT_CODEGRAPH_DIR}".`
  47. );
  48. }
  49. return DEFAULT_CODEGRAPH_DIR;
  50. }
  51. return raw;
  52. }
  53. /**
  54. * CodeGraph directory name — a load-time snapshot of {@link codeGraphDirName}.
  55. * A running process's environment is fixed, so this equals the live value;
  56. * it's kept as a stable string export for backward compatibility. Internal code
  57. * resolves the name through {@link codeGraphDirName} / {@link getCodeGraphDir}
  58. * so the `CODEGRAPH_DIR` override always applies.
  59. */
  60. export const CODEGRAPH_DIR = codeGraphDirName();
  61. /**
  62. * Is `name` (a single path segment) a CodeGraph data directory? Matches the
  63. * default `.codegraph`, the active `CODEGRAPH_DIR` override, and any
  64. * `.codegraph-*` sibling. File-watching and the indexer skip ALL of these, so
  65. * when two environments share one working tree (Windows + WSL, issue #636)
  66. * neither indexes or watches the other's index directory.
  67. */
  68. export function isCodeGraphDataDir(name: string): boolean {
  69. return (
  70. name === DEFAULT_CODEGRAPH_DIR ||
  71. name === codeGraphDirName() ||
  72. name.startsWith(DEFAULT_CODEGRAPH_DIR + '-')
  73. );
  74. }
  75. /**
  76. * Get the .codegraph directory path for a project
  77. */
  78. export function getCodeGraphDir(projectRoot: string): string {
  79. return path.join(projectRoot, codeGraphDirName());
  80. }
  81. /**
  82. * Check if a project has been initialized with CodeGraph
  83. * Requires both .codegraph/ directory AND codegraph.db to exist
  84. */
  85. export function isInitialized(projectRoot: string): boolean {
  86. const codegraphDir = getCodeGraphDir(projectRoot);
  87. if (!fs.existsSync(codegraphDir) || !fs.statSync(codegraphDir).isDirectory()) {
  88. return false;
  89. }
  90. // Must have codegraph.db, not just .codegraph folder
  91. const dbPath = path.join(codegraphDir, 'codegraph.db');
  92. return fs.existsSync(dbPath);
  93. }
  94. /**
  95. * Find the nearest parent directory containing .codegraph/
  96. *
  97. * Walks up from the given path to find a CodeGraph-initialized project,
  98. * similar to how git finds .git/ directories.
  99. *
  100. * @param startPath - Directory to start searching from
  101. * @returns The project root containing .codegraph/, or null if not found
  102. */
  103. /**
  104. * Reason a directory is unsafe to use as an index ROOT, or null when it's fine.
  105. *
  106. * Indexing your home directory or a filesystem root drags in caches, `Library`,
  107. * every other project, etc. — a multi-GB index, constant file-watcher churn, and
  108. * (pre-1.0 on macOS) a file-descriptor blowup that exhausted `kern.maxfiles` and
  109. * took unrelated apps / the whole machine down (#845). The classic trigger:
  110. * running the installer or `codegraph init` from `$HOME`, which auto-indexes the
  111. * current directory. These are never intended project roots, so the installer
  112. * and `init`/`index` refuse them (overridable with `--force`).
  113. *
  114. * Pure-ish (reads only `os.homedir()` + realpath) so it's easy to unit-test.
  115. * The returned string is a human phrase that slots into "… looks like {reason}".
  116. */
  117. export function unsafeIndexRootReason(projectRoot: string): string | null {
  118. const resolve = (p: string): string => {
  119. try {
  120. return fs.realpathSync(path.resolve(p));
  121. } catch {
  122. return path.resolve(p);
  123. }
  124. };
  125. const resolved = resolve(projectRoot);
  126. // Filesystem root: `/` on POSIX, a drive root like `C:\` on Windows.
  127. if (path.parse(resolved).root === resolved) {
  128. return 'the filesystem root';
  129. }
  130. const home = resolve(os.homedir());
  131. // Case-insensitive on macOS/Windows (case-preserving but case-insensitive FS).
  132. const norm = (p: string): string =>
  133. process.platform === 'darwin' || process.platform === 'win32' ? p.toLowerCase() : p;
  134. const r = norm(resolved);
  135. const h = norm(home);
  136. if (r === h) {
  137. return 'your home directory';
  138. }
  139. // An ancestor of home (e.g. `/Users`, `/home`) — even broader than home.
  140. if (h.startsWith(r + path.sep)) {
  141. return 'a parent of your home directory';
  142. }
  143. return null;
  144. }
  145. export function findNearestCodeGraphRoot(startPath: string): string | null {
  146. let current = path.resolve(startPath);
  147. const root = path.parse(current).root;
  148. while (current !== root) {
  149. if (isInitialized(current)) {
  150. return current;
  151. }
  152. const parent = path.dirname(current);
  153. if (parent === current) break; // Reached filesystem root
  154. current = parent;
  155. }
  156. // Check root as well
  157. if (isInitialized(current)) {
  158. return current;
  159. }
  160. return null;
  161. }
  162. /** Heavy/irrelevant directory names the sub-project scan never descends into. */
  163. const SUBPROJECT_SCAN_SKIP = new Set([
  164. 'node_modules', '.git', '.svn', '.hg', 'dist', 'build', 'out', 'target',
  165. 'vendor', 'bin', 'obj', '.next', '.nuxt', '.svelte-kit', '.cache', 'coverage',
  166. '.venv', 'venv', '__pycache__', '.turbo', '.idea', '.vscode', 'tmp', 'temp',
  167. ]);
  168. /** Manifests that mark a directory as a project/workspace root. The down-scan
  169. * is gated on one of these so a non-project cwd (e.g. `$HOME`) is a cheap
  170. * no-op instead of a deep filesystem crawl. */
  171. const WORKSPACE_ROOT_MANIFESTS = [
  172. 'package.json', 'pnpm-workspace.yaml', 'lerna.json', 'nx.json', 'turbo.json',
  173. 'go.work', 'go.mod', 'Cargo.toml', 'pom.xml', 'build.gradle', 'build.gradle.kts',
  174. 'settings.gradle', 'pyproject.toml', 'composer.json', 'Gemfile', 'rush.json',
  175. 'WORKSPACE', 'WORKSPACE.bazel',
  176. ];
  177. function looksLikeProjectRoot(dir: string): boolean {
  178. return WORKSPACE_ROOT_MANIFESTS.some((m) => fs.existsSync(path.join(dir, m)));
  179. }
  180. function escapeRegExp(s: string): string {
  181. return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  182. }
  183. /**
  184. * Indexed sub-project roots beneath `root` (bounded breadth-first scan). For
  185. * the monorepo case behind #964: the index lives in a CHILD
  186. * (`packages/x/.codegraph/`), not at the workspace root the agent's cwd points
  187. * at. Descent stops at the first indexed directory on a branch (a project's
  188. * own sub-dirs aren't separate projects) and is bounded by depth + count so it
  189. * never turns into a full-tree crawl on a large repo.
  190. */
  191. export function findIndexedSubprojectRoots(
  192. root: string,
  193. opts: { maxDepth?: number; max?: number } = {},
  194. ): string[] {
  195. // A stray workspace manifest must not enable scanning home or broader roots (#1454).
  196. if (unsafeIndexRootReason(root) !== null) return [];
  197. const maxDepth = opts.maxDepth ?? 4;
  198. const max = opts.max ?? 64;
  199. const out: string[] = [];
  200. const walk = (dir: string, depth: number): void => {
  201. if (out.length >= max || depth > maxDepth) return;
  202. let entries: fs.Dirent[];
  203. try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
  204. for (const e of entries) {
  205. if (out.length >= max) return;
  206. if (!e.isDirectory()) continue;
  207. if (e.name.startsWith('.') || SUBPROJECT_SCAN_SKIP.has(e.name)) continue;
  208. const child = path.join(dir, e.name);
  209. if (isInitialized(child)) { out.push(child); continue; } // don't descend into an indexed project
  210. walk(child, depth + 1);
  211. }
  212. };
  213. walk(root, 1);
  214. return out;
  215. }
  216. /** Result of {@link resolveServerRoot}. */
  217. export interface ServerRootResolution {
  218. /** The project root to serve as the default, or null when none resolved. */
  219. root: string | null;
  220. /** True when `root` was adopted from the down-scan rather than the up-walk. */
  221. viaSubScan: boolean;
  222. /**
  223. * Indexed sub-projects the down-scan saw when it ran but could NOT adopt
  224. * (zero or several candidates). Empty when the up-walk resolved or the scan
  225. * was skipped. Callers surface these so "no default project" errors can say
  226. * what IS reachable (#1607).
  227. */
  228. candidates: string[];
  229. }
  230. /**
  231. * Whether `base` is a plausible workspace root for the sub-project down-scan.
  232. * Mirrors `planFrontload`'s manifest gate, widened to accept a bare `.git`
  233. * entry — the #1606 shape is a workspace container holding only agent config
  234. * and a `.git`, with every build manifest living in the indexed children. The
  235. * user's home directory and the filesystem root are never eligible: a stray
  236. * manifest there must not turn server startup into a scan that could adopt an
  237. * unrelated project (#1454 documents that failure mode for the prompt-hook).
  238. */
  239. function eligibleForSubprojectScan(base: string): boolean {
  240. if (base === path.parse(base).root) return false;
  241. let home: string | null = null;
  242. try { home = os.homedir(); } catch { home = null; }
  243. if (home && (base === home || base === path.resolve(home))) return false;
  244. if (looksLikeProjectRoot(base)) return true;
  245. return fs.existsSync(path.join(base, '.git'));
  246. }
  247. /**
  248. * Resolve the project root an MCP server should serve as its DEFAULT project
  249. * (#1606). Up-walk first (`findNearestCodeGraphRoot` — the common case, and
  250. * cheap). When nothing is indexed at or above `searchFrom`, run the bounded
  251. * sub-project down-scan `planFrontload` already uses, behind the workspace
  252. * gate above: EXACTLY ONE indexed sub-project is unambiguous and is adopted
  253. * as the root; zero or several yield no root, with the candidates carried so
  254. * the caller can name them instead of failing silently (#1607).
  255. *
  256. * `opts.subprojectScan: false` skips the down-scan entirely (the per-tool-call
  257. * retry path throttles it; the up-walk always runs).
  258. */
  259. export function resolveServerRoot(
  260. searchFrom: string,
  261. opts: { subprojectScan?: boolean } = {},
  262. ): ServerRootResolution {
  263. const up = findNearestCodeGraphRoot(searchFrom);
  264. if (up) return { root: up, viaSubScan: false, candidates: [] };
  265. if (opts.subprojectScan === false) return { root: null, viaSubScan: false, candidates: [] };
  266. const base = path.resolve(searchFrom);
  267. if (!eligibleForSubprojectScan(base)) return { root: null, viaSubScan: false, candidates: [] };
  268. const subs = findIndexedSubprojectRoots(base);
  269. if (subs.length === 1) return { root: subs[0]!, viaSubScan: true, candidates: subs };
  270. return { root: null, viaSubScan: false, candidates: subs };
  271. }
  272. /**
  273. * Unicode-aware word-boundary emulation for the keyword lists below. JS's `\b`
  274. * is ASCII-only — it fires only at `[A-Za-z0-9_]` edges — so it can never bound
  275. * a keyword whose first or last character is accented or non-Latin: `/\boù\b/`
  276. * NEVER matches "où est …" (ù isn't an ASCII word char, so no boundary exists
  277. * next to it). That is the #994 CJK mechanism resurfaced for Latin scripts and
  278. * Cyrillic (#1126). A lookaround — "not flanked by a letter, digit, or
  279. * underscore" — is the script-independent equivalent.
  280. */
  281. const NOT_WORD_BEFORE = /(?<![\p{L}\p{N}_])/u.source;
  282. const NOT_WORD_AFTER = /(?![\p{L}\p{N}_])/u.source;
  283. /**
  284. * Structural keywords matched as EXACT words (boundary on both sides): short
  285. * or ambiguous tokens where prefix matching would false-positive ("flow" in
  286. * "flower", "path" in "pathological"). Grouped by language; a term appears once
  287. * even when several languages share it ("como" is Portuguese for how AND
  288. * unaccented-typed Spanish "cómo").
  289. */
  290. const STRUCTURAL_WORDS = [
  291. // English — the pre-#1126 list minus what moved to STRUCTURAL_STEMS: the
  292. // bare-stem entries never matched their own derived forms (`\barchitect\b`
  293. // can't match "architecture"), and "what calls" is subsumed by the "call" stem.
  294. 'how', 'where', 'tracing', 'flows?', 'paths?', 'reach(?:es|ed)?', 'wired?', 'breaks?', 'why does',
  295. // French (où=where, flux=flow, chemin=path, casse=breaks)
  296. 'comment', 'où', 'flux', 'chemins?', 'casse',
  297. // Spanish (cómo/como=how, dónde/donde=where, flujo=flow, ruta/camino=path,
  298. // rompe=breaks, llaman / quién llama = call(s) — bare "llama" is excluded:
  299. // it's also the animal/model name in English prompts)
  300. 'cómo', 'dónde', 'donde', 'flujos?', 'rutas?', 'caminos?', 'rompe', 'llaman', 'quién llama', 'quien llama',
  301. // Portuguese (como=how — also covers unaccented Spanish; onde=where,
  302. // fluxo=flow, caminho=path)
  303. 'como', 'onde', 'fluxos?', 'caminhos?',
  304. // German (wie=how, wo/woher/wohin=where, Pfad=path, Fluss/Ablauf=flow,
  305. // bricht/kaputt=breaks, ruft=calls, hängt=depends — "hängt … von X ab"
  306. // splits the separable verb "abhängen", so the "abhäng" stem can't catch it)
  307. 'wie', 'wo', 'woher', 'wohin', 'pfade?', 'fluss', 'ablauf', 'bricht', 'kaputt', 'ruft', 'hängt',
  308. // Italian (dove=where, flusso=flow, percorso/i=path)
  309. 'dove', 'flusso', 'percors[oi]',
  310. // Russian (как=how, где=where, путь/пути=path, работает=works)
  311. 'как', 'где', 'путь', 'пути', 'работает',
  312. // Ukrainian (як=how, де=where, потік=flow — обліque cases reuse the RU
  313. // "поток" stem; працює=works)
  314. 'як', 'де', 'потік', 'працює',
  315. // Dutch (hoe=how, waar=where, roept=calls, werkt=works, aangeroepen=called —
  316. // the ge- participle escapes the "aanroep" stem)
  317. 'hoe', 'waar', 'roept', 'werkt', 'aangeroepen',
  318. // Polish + Czech (jak=how — shared; gdzie/kde=where, cesta=path)
  319. 'jak', 'gdzie', 'kde', 'cesta',
  320. // Romanian (cum=how, unde=where; flux is shared with French)
  321. 'cum', 'unde',
  322. // Hungarian (hogyan=how, hol=where)
  323. 'hogyan', 'hol',
  324. // Turkish (nasıl=how, mimari=architecture, takip=trace/follow)
  325. 'nasıl', 'mimari', 'takip',
  326. // Indonesian/Malay (bagaimana=how, di mana/dimana=where, alur=flow, jalur=path)
  327. 'bagaimana', 'di mana', 'dimana', 'alur', 'jalur',
  328. // Vietnamese — spaced Latin with heavy diacritics, the exact class ASCII `\b`
  329. // breaks (làm sao/thế nào=how, ở đâu=where, gọi=call, phụ thuộc=depend,
  330. // ảnh hưởng=affect, kiến trúc=architecture, cấu trúc=structure, luồng=flow,
  331. // đường dẫn=path, hoạt động=works, giải thích=explain, theo dõi=trace)
  332. 'làm sao', 'thế nào', 'ở đâu', 'gọi', 'phụ thuộc', 'ảnh hưởng', 'kiến trúc',
  333. 'cấu trúc', 'luồng', 'đường dẫn', 'hoạt động', 'giải thích', 'theo dõi',
  334. // Swedish / Danish / Norwegian (hur/hvordan=how, hvor=where, beror=depends,
  335. // flöde=flow)
  336. 'hur', 'hvordan', 'hvor', 'beror', 'flöde',
  337. // Finnish (miten=how, missä=where, toimii=works)
  338. 'miten', 'missä', 'toimii',
  339. // Greek (πώς=how, πού=where — accented forms only: unaccented πως/που are
  340. // ubiquitous conjunctions; καλεί=calls, δομή=structure, ροή=flow)
  341. 'πώς', 'πού', 'καλεί', 'δομή', 'ροή',
  342. // Hindi (कैसे=how, कहाँ/कहां=where, कॉल=call, निर्भर=depends,
  343. // संरचना=structure, प्रवाह=flow)
  344. 'कैसे', 'कहाँ', 'कहां', 'कॉल', 'निर्भर', 'संरचना', 'प्रवाह',
  345. ];
  346. /**
  347. * Structural keyword STEMS matched as word PREFIXES (boundary on the left
  348. * only), so derived forms match without enumerating each: "architect" fires on
  349. * architecture/architectural, "depend" on depends/dependency/dependencies,
  350. * "вызыва" on вызывает/вызывается. Mid-word occurrences stay excluded —
  351. * "restructure"/"independent" don't fire — so precision stays close to the
  352. * exact-word class. Add a stem only when every plausible completion is still a
  353. * structural word; a stem with ordinary-English completions must instead
  354. * enumerate its structural suffixes and re-assert the right boundary (see the
  355. * four bounded English entries below, #1138).
  356. */
  357. const STRUCTURAL_STEMS = [
  358. // English + the Latin-script languages that share the spelling (French
  359. // architecture/structure/trace/impact, Spanish depende/implementa/impacto, …).
  360. // call/trace/affect/connect are NOT safe as open prefixes — callus,
  361. // calligraphy, Connecticut, connective, affectionate, Tracey are ordinary
  362. // words that would false-fire the full-explore tier (#1138) — so they carry
  363. // an enumerated suffix set + right boundary. "tracing" lives in
  364. // STRUCTURAL_WORDS (the e is dropped, so no trace-prefix form matches it).
  365. 'architect', 'structur', 'depend', 'implement', 'impact', 'explain',
  366. `call(?:s|ing|ed|ers?|backs?|able|sites?)?${NOT_WORD_AFTER}`,
  367. `trace(?:s|d|rs?)?${NOT_WORD_AFTER}`,
  368. `affect(?:s|ed|ing)?${NOT_WORD_AFTER}`,
  369. `connect(?:s|ed|ing|ions?|ors?|ivity)?${NOT_WORD_AFTER}`,
  370. // French (appel(le)=call, dépend=depends, implément(e)=implement,
  371. // connex(ion)=connection, expliqu(e)=explain, fonctionn(e/ement)=works)
  372. 'appel', 'dépend', 'implément', 'connex', 'expliqu', 'fonctionn',
  373. // Spanish (llamad(a)=call, afect(a)=affect, conect(a)/conexi(ón)=connect,
  374. // arquitec(tura)=architecture, estructur(a)=structure, funcion(a)=works,
  375. // traza(r)=trace, explica=explain)
  376. 'llamad', 'afect', 'conect', 'conexi', 'arquitec', 'estructur', 'funcion', 'traza', 'explica',
  377. // Portuguese (chama(da)=call, afeta=affect, arquitet(ura)=architecture,
  378. // estrutur(a)=structure, quebra(do)=breaks)
  379. 'chama', 'afeta', 'arquitet', 'estrutur', 'quebra',
  380. // German (abhäng(t)=depend, Auswirkung=impact, beeinfluss(t)=affect,
  381. // verbind(et)=connect, Architektur, Struktur, funktionier(t)=works,
  382. // Aufruf/aufgerufen=call, erklär(t)=explain, verfolg(en)=trace)
  383. 'abhäng', 'auswirkung', 'beeinfluss', 'verbind', 'architekt', 'struktur', 'funktionier', 'aufruf', 'aufgerufen', 'erklär', 'verfolg',
  384. // Italian (chiam(a/ata)=call, dipend(e/enza)=depend, impatt(o)=impact,
  385. // connett(e)/conness(ione)=connect, architett(ura), struttur(a),
  386. // funzion(a/amento)=works, tracci(a)=trace, spiega(mi)=explain)
  387. 'chiam', 'dipend', 'impatt', 'connett', 'conness', 'architett', 'struttur', 'funzion', 'tracci', 'spiega',
  388. // Russian (вызыва(ет)=calls, завис(ит)=depends, влия(ет)=affects,
  389. // реализ(ация)=implementation, структур(а), архитектур(а),
  390. // трассир(овка)=trace, лома(ет)=breaks, объясн(и)=explain, поток=flow)
  391. 'вызыва', 'завис', 'влия', 'реализ', 'структур', 'архитектур', 'трассир', 'лома', 'объясн', 'поток',
  392. // Ukrainian — і/и spellings diverge from Russian (виклика(є)=calls,
  393. // залеж(ить)=depends, вплива(є)=affects, архітектур(а), реаліз(ація),
  394. // поясн(и)=explain, шлях(у)=path; структур(а) is shared with Russian)
  395. 'виклика', 'залеж', 'вплива', 'архітектур', 'реаліз', 'поясн', 'шлях',
  396. // Dutch (aanroep(en)=call, afhankelijk(heid)=depends, beïnvloed(t)=affects,
  397. // structuur — "structur" can't reach the uu; uitleg(gen)=explain)
  398. 'aanroep', 'afhankelijk', 'beïnvloed', 'structuur', 'uitleg',
  399. // Polish (wywoł(uje)=calls, zależ(y)=depends, wpływ(a)=affects/impact,
  400. // przepływ=flow, ścieżk(a)=path, działa(nie)=works, wyjaśni(j)=explain,
  401. // śledz(enie)=trace; architektura/struktura fire via the German stems)
  402. 'wywoł', 'zależ', 'wpływ', 'przepływ', 'ścieżk', 'działa', 'wyjaśni', 'śledz',
  403. // Czech (volá(ní)=calls, závis(í)=depends, ovlivň(uje)=affects,
  404. // funguj(e)=works, vysvětl(i)=explain)
  405. 'volá', 'závis', 'ovlivň', 'funguj', 'vysvětl',
  406. // Romanian (apel(ează)=calls, depind(e)=depends — i not e, so "depend" misses
  407. // it; arhitectur(a) — no c; funcțion(ează)=works, explică=explain)
  408. 'apel', 'depind', 'arhitectur', 'funcțion', 'explică',
  409. // Hungarian (hív(ja)=calls, függ(őség)=depends, működ(ik)=works,
  410. // struktúr(a) — ú escapes "struktur"; magyaráz(d)=explain;
  411. // architektúra fires via the German stem)
  412. 'hív', 'függ', 'működ', 'struktúr', 'magyaráz',
  413. // Turkish — agglutinative, so stems beat exact words (nere(de/ye/den)=where,
  414. // çağır/çağrı=call, bağıml(ı)=depends, bağlant(ı)=connection, akış(ı)=flow,
  415. // etkile(r)/etkisi=affects/impact)
  416. 'nere', 'çağır', 'çağrı', 'bağıml', 'bağlant', 'akış', 'etkile', 'etkisi',
  417. // Indonesian/Malay — me-/di-/ber- prefixes block a bare stem, so affixed
  418. // forms are listed too (panggil(an)/memanggil/dipanggil=call,
  419. // bergantung/tergantung=depends, pengaruh/mempengaruhi/memengaruhi=affect,
  420. // arsitektur=architecture, fungsi/berfungsi=works,
  421. // jelaskan/menjelaskan=explain)
  422. 'panggil', 'memanggil', 'dipanggil', 'bergantung', 'tergantung', 'pengaruh',
  423. 'mempengaruhi', 'memengaruhi', 'arsitektur', 'fungsi', 'berfungsi', 'jelaskan', 'menjelaskan',
  424. // Swedish / Danish / Norwegian (anrop(ar)=calls, påverk(ar)/påvirk(er)=affects,
  425. // afhæng(er)/avheng(er)=depends, förklar(a)/forklar=explain,
  426. // arkitektur — k not ch; funger(ar/er)=works)
  427. 'anrop', 'påverk', 'påvirk', 'afhæng', 'avheng', 'förklar', 'forklar', 'arkitektur', 'funger',
  428. // Finnish (kutsu(u)=calls, riippu(u)=depends, arkkitehtuur(i),
  429. // rakente(en)=structure, selit(ä)=explain)
  430. 'kutsu', 'riippu', 'arkkitehtuur', 'rakente', 'selit',
  431. // Greek — accented and unaccented stem spellings both occur
  432. // (εξαρτ(άται)=depends, επηρε(άζει)=affects, αρχιτεκτονικ(ή),
  433. // διαδρομ(ή)=path, εξηγ/εξήγ(ησε)=explain)
  434. 'εξαρτ', 'επηρε', 'αρχιτεκτονικ', 'διαδρομ', 'εξηγ', 'εξήγ',
  435. // Hindi (समझा(ओ/इए)=explain, आर्किटेक्चर=architecture)
  436. 'समझा', 'आर्किटेक्चर',
  437. ];
  438. const STRUCTURAL_WORDS_RE = new RegExp(`${NOT_WORD_BEFORE}(?:${STRUCTURAL_WORDS.join('|')})${NOT_WORD_AFTER}`, 'iu');
  439. const STRUCTURAL_STEMS_RE = new RegExp(`${NOT_WORD_BEFORE}(?:${STRUCTURAL_STEMS.join('|')})`, 'iu');
  440. /**
  441. * Structural keywords matched as bare SUBSTRINGS, for languages where a
  442. * boundary can't be relied on: scripts with no word separators (Chinese —
  443. * simplified AND traditional; the original #994 set was simplified-only —
  444. * Japanese, Thai), Korean (spaced, but particles attach directly to the noun:
  445. * 구조가/구조를), and Arabic / Farsi / Hebrew (spaced, but proclitics attach to
  446. * the word: وكيف "and-how", והמבנה "and-the-structure"). JS's `\b` can never
  447. * fire between Han characters, which was issue #994: the English-only gate
  448. * silently no-op'd every Chinese prompt, so non-English users got no front-load
  449. * nudge and no error to explain why. The sets mirror the English intent
  450. * (如何/怎么/怎麼/どうやって/どのように/어떻게/كيف/چگونه/چطور/איך/อย่างไร/ยังไง=how,
  451. * 在哪/哪里/哪裡/어디/أين/كجا/איפה/ที่ไหน=where, 流程/流向/流れ/흐름/تدفق/זרימה=flow,
  452. * 路径/路徑/経路/경로/مسار/مسیر/נתיב/เส้นทาง=path,
  453. * 调用/調用/呼び出/호출/يستدعي/استدعاء/فراخوان/קורא/เรียกใช้=call,
  454. * 依赖/依賴/依存/의존/يعتمد/تعتمد/وابسته/תלוי/ขึ้นอยู่กับ=depend,
  455. * 影响/影響/영향/يؤثر/تأثير/تأثیر/משפיע/ผลกระทบ=impact/affect,
  456. * 实现/實現/実装/구현=implement,
  457. * 架构/架構/アーキテクチャ/아키텍처/معماري/معماری/ארכיטקטור/สถาปัตยกรรม=architecture,
  458. * 结构/結構/構造/구조/بنية/هيكل/ساختار/מבנה/โครงสร้าง=structure,
  459. * 追踪/跟踪/追蹤/追跡/トレース/추적/تتبع/ติดตาม=trace,
  460. * يعمل/تعمل/ทำงาน=works) plus structural-overview words with no single clean
  461. * English equivalent (介绍/介紹/解析/分析/原理/机制/機制/仕組み/説明/설명/動作/동작/작동/
  462. * اشرح/شرح/توضیح/הסבר/อธิบาย=explain).
  463. *
  464. * KNOWN, ACCEPTED false-positive class (#1140): substring matching cannot see
  465. * homograph compounds — Korean 구조 (structure) also fires inside 구조대
  466. * (rescue squad). Verified unfixable at this layer: ICU word segmentation
  467. * (Intl.Segmenter) returns 구조대 and the particle form 구조가 (which the gate
  468. * MUST keep matching) as equally opaque single segments, and a 구조대 denylist
  469. * would break 구조대로 ("according to the structure" — 구조 + the 대로
  470. * particle), a legitimate structural prompt. The miss rate this design avoids
  471. * (silently no-op'ing every prompt in these languages, #994) outweighs the
  472. * occasional off-domain fire.
  473. */
  474. const STRUCTURAL_UNSEGMENTED = /如何|怎么|怎麼|在哪|哪里|哪裡|追踪|跟踪|追蹤|追跡|トレース|流程|流向|流れ|路径|路徑|経路|调用|調用|呼び出|依赖|依賴|依存|影响|影響|实现|實現|実装|架构|架構|アーキテクチャ|结构|結構|構造|介绍|介紹|解析|分析|原理|机制|機制|仕組み|説明|動作|どうやって|どのように|어떻게|어디|호출|흐름|경로|의존|영향|구현|구조|아키텍처|추적|동작|작동|설명|كيف|أين|اين|يستدعي|استدعاء|يعتمد|تعتمد|يؤثر|تأثير|معماري|بنية|هيكل|تدفق|مسار|تتبع|يعمل|تعمل|اشرح|شرح|چگونه|چطور|کجا|فراخوان|وابسته|تأثیر|معماری|ساختار|مسیر|توضیح|איך|איפה|קורא|תלוי|משפיע|ארכיטקטור|מבנה|זרימה|נתיב|הסבר|อย่างไร|ยังไง|ที่ไหน|เรียกใช้|ขึ้นอยู่กับ|ผลกระทบ|สถาปัตยกรรม|โครงสร้าง|เส้นทาง|ติดตาม|ทำงาน|อธิบาย/;
  475. /** Doc/data/asset file extensions — a `name.ext` of this kind is a file
  476. * reference, not a code symbol, so it must not trip the member-access signal. */
  477. const DOC_DATA_EXT = /\.(md|markdown|txt|rst|json|ya?ml|toml|lock|csv|tsv|log|ini|cfg|conf|env|xml|html?|png|jpe?g|gif|svg|pdf)$/i;
  478. /**
  479. * Does `prompt` contain an explicit structural keyword? A keyword is a strong,
  480. * self-contained signal, so the front-load hook fires on it directly — no graph
  481. * check needed. (A *code-token* match, by contrast, is only a candidate the
  482. * hook verifies against the graph first; see {@link extractCodeTokens}.)
  483. * Coverage is multilingual (#994, #1126): the ~29 languages with the largest
  484. * developer populations, across Latin, Cyrillic, Greek, CJK, Hangul, Arabic,
  485. * Hebrew, Thai, and Devanagari scripts. Languages beyond the keyword table
  486. * still fire through the language-agnostic code-token path.
  487. */
  488. export function hasStructuralKeyword(prompt: string): boolean {
  489. return (
  490. !!prompt &&
  491. (STRUCTURAL_WORDS_RE.test(prompt) || STRUCTURAL_STEMS_RE.test(prompt) || STRUCTURAL_UNSEGMENTED.test(prompt))
  492. );
  493. }
  494. /**
  495. * Identifier-shaped tokens in `prompt` — camelCase / PascalCase-with-inner-cap,
  496. * snake_case, a `name(` call, or the two sides of an `a.b` member access. Naming
  497. * a symbol is a code question whatever the surrounding human language, and these
  498. * shapes almost never occur in ordinary prose, so they catch the common
  499. * "<symbol> 的调用链?" / "where is <symbol> 定義" prompts no keyword list would.
  500. *
  501. * These are *candidates*, not a verdict: a tech brand like `JavaScript` or
  502. * `GitHub` is identifier-shaped too, so the front-load hook checks each token
  503. * against the actual index ({@link getNodesByName}) and only fires when one is a
  504. * real symbol here — otherwise a brand-name prompt would inject ~16KB of
  505. * low-relevance context (issue #994 follow-up). A doc/data filename ("README.md")
  506. * is excluded from the member-access form since it's a file reference, not a symbol.
  507. */
  508. export function extractCodeTokens(prompt: string): string[] {
  509. if (!prompt) return [];
  510. const out = new Set<string>();
  511. // camelCase / PascalCase-with-inner-cap (getUserId, parseToken, UserService) or
  512. // snake_case (article_publish, get_user) — a whole identifier run that has an
  513. // inner lower→upper transition or an underscore flanked by alphanumerics.
  514. for (const m of prompt.matchAll(/[A-Za-z_$][\w$]*/g)) {
  515. const w = m[0];
  516. if (/[a-z][A-Z]/.test(w) || /[A-Za-z0-9]_[A-Za-z0-9]/.test(w)) out.add(w);
  517. }
  518. // call form: an identifier directly before '(' — parseToken(, render(). No
  519. // whitespace before '(' so prose like "the function (entry point)" doesn't trip it.
  520. for (const m of prompt.matchAll(/([A-Za-z_$][\w$]*)\(/g)) out.add(m[1]!);
  521. // member access on identifiers (user.login) — but not a doc/data filename.
  522. for (const m of prompt.matchAll(/([A-Za-z_$][\w$]*)\.([A-Za-z_$][\w$]*)/g)) {
  523. if (!DOC_DATA_EXT.test(m[0])) { out.add(m[1]!); out.add(m[2]!); }
  524. }
  525. return [...out];
  526. }
  527. /**
  528. * Cheap, graph-free candidate gate for the front-load hook: could `prompt` be a
  529. * structural / flow / impact / "where-how" question worth front-loading context
  530. * for? True on an explicit keyword in any covered language (#994, #1126) OR an
  531. * identifier-shaped token. A keyword is sufficient to fire on its own; a
  532. * token-only match is only a candidate the hook then verifies against the graph
  533. * (a brand name like `JavaScript` is token-shaped but isn't a symbol). Every
  534. * non-candidate prompt ("fix this typo", in any language) stays a zero-cost no-op.
  535. */
  536. export function isStructuralPrompt(prompt: string): boolean {
  537. return hasStructuralKeyword(prompt) || extractCodeTokens(prompt).length > 0;
  538. }
  539. /**
  540. * Claude Code persists `UserPromptSubmit` hook stdout above this many
  541. * characters to a file and shows the model a ~2 KB preview instead (#1694).
  542. * Measured on Claude Code 2.1.261; documented in the hooks reference as a
  543. * 10,000-character cap on hook output strings.
  544. */
  545. export const CLAUDE_CODE_INLINE_HOOK_OUTPUT_LIMIT = 10_000;
  546. /**
  547. * Max characters of explore text injected by `codegraph prompt-hook` before
  548. * truncation. Must stay under {@link CLAUDE_CODE_INLINE_HOOK_OUTPUT_LIMIT} so
  549. * the host delivers the payload inline. 9,000 leaves ~1k for the
  550. * `<codegraph_context>` wrapper and the `projectPath` nudge lines appended
  551. * after the cap is applied.
  552. */
  553. export const PROMPT_HOOK_INJECTION_MAX = 9_000;
  554. /**
  555. * Cap explore text for the prompt-hook injection, preserving the existing
  556. * "call codegraph_explore for the rest" notice when truncated.
  557. */
  558. export function capPromptHookInjection(text: string, max = PROMPT_HOOK_INJECTION_MAX): string {
  559. return text.length > max
  560. ? `${text.slice(0, max)}\n…(truncated; call codegraph_explore for the rest)`
  561. : text;
  562. }
  563. /**
  564. * What the front-load hook should do for a prompt issued from a directory.
  565. */
  566. export interface FrontloadPlan {
  567. /** Open + explore this project and inject its source as context. `null` when
  568. * there's no single project to front-load (none indexed, or several indexed
  569. * sub-projects with no clear match — see {@link nudgeProjects}). */
  570. exploreRoot: string | null;
  571. /** Indexed sub-projects to surface in a "pass `projectPath`" nudge: the rest
  572. * of a monorepo's indexed projects alongside `exploreRoot`, or — when no one
  573. * project clearly matches — the full list (with `exploreRoot` null). */
  574. nudgeProjects: string[];
  575. /** True when the plan came from scanning DOWN into sub-projects (cwd itself
  576. * is not under any index) — the monorepo case, where a follow-up
  577. * `codegraph_explore` needs an explicit `projectPath`. */
  578. viaSubScan: boolean;
  579. }
  580. /**
  581. * Decide what the front-load hook injects for a `prompt` issued from `cwd`,
  582. * shaped by where the `.codegraph/` index(es) actually are:
  583. * 1. **cwd (or an ancestor) is indexed** → front-load that project. The
  584. * normal single-project / nested-file case.
  585. * 2. **cwd isn't indexed but looks like a workspace root** → the indexes live
  586. * in sub-projects (the monorepo case behind #964). One indexed
  587. * sub-project → front-load it; several → front-load the one the prompt
  588. * names (by relative path like `packages/api`, or package directory name)
  589. * and nudge about the rest; several with no match → nudge the full list so
  590. * the agent passes `projectPath`, rather than guessing wrong.
  591. * 3. **nothing indexed reachable** → do nothing (the agent's own tools apply).
  592. */
  593. export function planFrontload(cwd: string, prompt: string): FrontloadPlan {
  594. const none: FrontloadPlan = { exploreRoot: null, nudgeProjects: [], viaSubScan: false };
  595. // 1. up-walk — nearest indexed ancestor (incl. cwd). Cheap; covers the common
  596. // single-project case without a down-scan.
  597. let dir = path.resolve(cwd);
  598. for (let i = 0; i < 6; i++) {
  599. if (isInitialized(dir)) return { exploreRoot: dir, nudgeProjects: [], viaSubScan: false };
  600. const parent = path.dirname(dir);
  601. if (parent === dir) break;
  602. dir = parent;
  603. }
  604. // 2. down-scan — only from something that looks like a workspace root, so a
  605. // non-project cwd (e.g. $HOME) is a cheap no-op, not a deep crawl.
  606. const base = path.resolve(cwd);
  607. if (!looksLikeProjectRoot(base)) return none;
  608. const subs = findIndexedSubprojectRoots(base);
  609. if (subs.length === 0) return none;
  610. if (subs.length === 1) return { exploreRoot: subs[0]!, nudgeProjects: [], viaSubScan: true };
  611. // Several indexed sub-projects — pick the one the prompt points at, if any.
  612. const p = prompt.toLowerCase();
  613. let best: { root: string; score: number; relLen: number } | null = null;
  614. for (const s of subs) {
  615. const rel = path.relative(base, s);
  616. const relLc = rel.split(path.sep).join('/').toLowerCase();
  617. const name = path.basename(s).toLowerCase();
  618. let score = 0;
  619. if (relLc && p.includes(relLc)) score = 10; // "packages/api"
  620. else if (name.length >= 3 && new RegExp(`\\b${escapeRegExp(name)}\\b`).test(p)) score = 5; // "api"
  621. if (score > 0 && (!best || score > best.score || (score === best.score && rel.length < best.relLen))) {
  622. best = { root: s, score, relLen: rel.length };
  623. }
  624. }
  625. if (best) {
  626. return { exploreRoot: best.root, nudgeProjects: subs.filter((s) => s !== best!.root), viaSubScan: true };
  627. }
  628. // No clear match — nudge the full list rather than front-load a guess.
  629. return { exploreRoot: null, nudgeProjects: subs, viaSubScan: true };
  630. }
  631. /**
  632. * Contents of `.codegraph/.gitignore`. A single wildcard ignore keeps every
  633. * transient file in the index dir — the database, `daemon.pid`, the socket,
  634. * logs, cache, and anything future versions add — out of git, without having
  635. * to enumerate each name (issues #788, #492, #484). Older versions wrote an
  636. * explicit allowlist that never listed `daemon.pid` or the socket, so those
  637. * runtime files were silently committed.
  638. */
  639. const GITIGNORE_CONTENT = `# CodeGraph data files — local to each machine, not for committing.
  640. # Ignore everything in .codegraph/ except this file itself, so transient
  641. # files (the database, daemon.pid, sockets, logs) never show up in git.
  642. *
  643. !.gitignore
  644. `;
  645. /** Header line that prefixes every .gitignore CodeGraph has auto-generated. */
  646. const GITIGNORE_MARKER = '# CodeGraph data files';
  647. /**
  648. * Is `content` a stale CodeGraph-generated `.gitignore` that should be
  649. * regenerated in place? True when it carries our header but predates the
  650. * wildcard ignore (it has no bare `*` line) — i.e. one of the old explicit
  651. * allowlists (`*.db`, `cache/`, `.dirty`, …) that never ignored `daemon.pid`
  652. * or the socket (issue #788). A file WITHOUT our header is user-authored and
  653. * is left untouched; one that already has the wildcard is current. Matching
  654. * on the header (not a byte-exact list of past defaults) heals every old
  655. * variant — v0.7.x through 0.9.9 — and is idempotent once upgraded.
  656. */
  657. function isStaleDefaultGitignore(content: string): boolean {
  658. if (!content.trimStart().startsWith(GITIGNORE_MARKER)) return false;
  659. return !content.split('\n').some((line) => line.trim() === '*');
  660. }
  661. /**
  662. * Write `.codegraph/.gitignore` if it's absent, or upgrade a stale
  663. * CodeGraph-generated default in place; a user-customized file is left alone.
  664. * Best-effort — returns `false` only if a needed write failed.
  665. */
  666. function ensureGitignore(gitignorePath: string): boolean {
  667. let existing: string | null;
  668. try {
  669. existing = fs.readFileSync(gitignorePath, 'utf-8');
  670. } catch {
  671. existing = null; // absent (ENOENT) or unreadable — (re)create below
  672. }
  673. // Current default or a user-authored file: nothing to do.
  674. if (existing !== null && !isStaleDefaultGitignore(existing)) return true;
  675. try {
  676. fs.writeFileSync(gitignorePath, GITIGNORE_CONTENT, 'utf-8');
  677. return true;
  678. } catch {
  679. return false;
  680. }
  681. }
  682. /**
  683. * Create the .codegraph directory structure
  684. * Note: Only throws if codegraph.db already exists, not just if .codegraph/ exists.
  685. */
  686. export function createDirectory(projectRoot: string): void {
  687. const codegraphDir = getCodeGraphDir(projectRoot);
  688. const dbPath = path.join(codegraphDir, 'codegraph.db');
  689. // Only throw if CodeGraph is actually initialized (db exists)
  690. // .codegraph/ folder alone is fine
  691. if (fs.existsSync(dbPath)) {
  692. throw new Error(`CodeGraph already initialized in ${projectRoot}`);
  693. }
  694. // Create main directory (if it doesn't exist)
  695. fs.mkdirSync(codegraphDir, { recursive: true });
  696. // Write .gitignore inside .codegraph (create if absent, upgrade a stale
  697. // pre-wildcard default left by an older version — issue #788).
  698. ensureGitignore(path.join(codegraphDir, '.gitignore'));
  699. }
  700. /**
  701. * Remove the .codegraph directory
  702. */
  703. export function removeDirectory(projectRoot: string): void {
  704. const codegraphDir = getCodeGraphDir(projectRoot);
  705. if (!fs.existsSync(codegraphDir)) {
  706. return;
  707. }
  708. // Verify .codegraph is a real directory, not a symlink pointing elsewhere
  709. const lstat = fs.lstatSync(codegraphDir);
  710. if (lstat.isSymbolicLink()) {
  711. // Only remove the symlink itself, never follow it for recursive delete
  712. fs.unlinkSync(codegraphDir);
  713. return;
  714. }
  715. if (!lstat.isDirectory()) {
  716. // Not a directory - remove the single file
  717. fs.unlinkSync(codegraphDir);
  718. return;
  719. }
  720. // Recursively remove directory
  721. fs.rmSync(codegraphDir, { recursive: true, force: true });
  722. }
  723. /**
  724. * Get all files in the .codegraph directory
  725. */
  726. export function listDirectoryContents(projectRoot: string): string[] {
  727. const codegraphDir = getCodeGraphDir(projectRoot);
  728. if (!fs.existsSync(codegraphDir)) {
  729. return [];
  730. }
  731. const files: string[] = [];
  732. function walkDir(dir: string, prefix: string = ''): void {
  733. const entries = fs.readdirSync(dir, { withFileTypes: true });
  734. for (const entry of entries) {
  735. const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
  736. // Skip symlinks to prevent following links outside .codegraph
  737. if (entry.isSymbolicLink()) {
  738. continue;
  739. }
  740. if (entry.isDirectory()) {
  741. walkDir(path.join(dir, entry.name), relativePath);
  742. } else {
  743. files.push(relativePath);
  744. }
  745. }
  746. }
  747. walkDir(codegraphDir);
  748. return files;
  749. }
  750. /**
  751. * Get the total size of the .codegraph directory in bytes
  752. */
  753. export function getDirectorySize(projectRoot: string): number {
  754. const codegraphDir = getCodeGraphDir(projectRoot);
  755. if (!fs.existsSync(codegraphDir)) {
  756. return 0;
  757. }
  758. let totalSize = 0;
  759. function walkDir(dir: string): void {
  760. const entries = fs.readdirSync(dir, { withFileTypes: true });
  761. for (const entry of entries) {
  762. // Skip symlinks to prevent following links outside .codegraph
  763. if (entry.isSymbolicLink()) {
  764. continue;
  765. }
  766. const fullPath = path.join(dir, entry.name);
  767. if (entry.isDirectory()) {
  768. walkDir(fullPath);
  769. } else {
  770. const stats = fs.statSync(fullPath);
  771. totalSize += stats.size;
  772. }
  773. }
  774. }
  775. walkDir(codegraphDir);
  776. return totalSize;
  777. }
  778. /**
  779. * Ensure a subdirectory exists within .codegraph
  780. */
  781. export function ensureSubdirectory(projectRoot: string, subdirName: string): string {
  782. if (subdirName.includes('..') || subdirName.includes(path.sep) || subdirName.includes('/')) {
  783. throw new Error(`Invalid subdirectory name: ${subdirName}`);
  784. }
  785. const subdirPath = path.join(getCodeGraphDir(projectRoot), subdirName);
  786. if (!fs.existsSync(subdirPath)) {
  787. fs.mkdirSync(subdirPath, { recursive: true });
  788. }
  789. return subdirPath;
  790. }
  791. /**
  792. * Check if the .codegraph directory has valid structure
  793. */
  794. export function validateDirectory(projectRoot: string): {
  795. valid: boolean;
  796. errors: string[];
  797. } {
  798. const errors: string[] = [];
  799. const codegraphDir = getCodeGraphDir(projectRoot);
  800. if (!fs.existsSync(codegraphDir)) {
  801. errors.push('CodeGraph directory does not exist');
  802. return { valid: false, errors };
  803. }
  804. if (!fs.statSync(codegraphDir).isDirectory()) {
  805. errors.push('.codegraph exists but is not a directory');
  806. return { valid: false, errors };
  807. }
  808. // Auto-repair / upgrade .gitignore (non-critical file). A missing one is
  809. // recreated; a stale pre-wildcard default that never ignored daemon.pid is
  810. // regenerated in place (issue #788); a user-authored file is left alone.
  811. const gitignorePath = path.join(codegraphDir, '.gitignore');
  812. const existedBefore = fs.existsSync(gitignorePath);
  813. if (!ensureGitignore(gitignorePath) && !existedBefore) {
  814. // Only a missing-and-uncreatable file is surfaced; a failed in-place
  815. // upgrade of an existing file is non-fatal — the index still works.
  816. errors.push('.gitignore missing in .codegraph directory and could not be created');
  817. }
  818. return {
  819. valid: errors.length === 0,
  820. errors,
  821. };
  822. }