@optave/codegraph 3.8.1 → 3.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/README.md +12 -7
  2. package/dist/ast-analysis/engine.d.ts.map +1 -1
  3. package/dist/ast-analysis/engine.js +121 -48
  4. package/dist/ast-analysis/engine.js.map +1 -1
  5. package/dist/ast-analysis/visitors/ast-store-visitor.d.ts.map +1 -1
  6. package/dist/ast-analysis/visitors/ast-store-visitor.js +15 -18
  7. package/dist/ast-analysis/visitors/ast-store-visitor.js.map +1 -1
  8. package/dist/ast-analysis/visitors/complexity-visitor.d.ts.map +1 -1
  9. package/dist/ast-analysis/visitors/complexity-visitor.js +50 -1
  10. package/dist/ast-analysis/visitors/complexity-visitor.js.map +1 -1
  11. package/dist/cli/commands/branch-compare.d.ts.map +1 -1
  12. package/dist/cli/commands/branch-compare.js +4 -0
  13. package/dist/cli/commands/branch-compare.js.map +1 -1
  14. package/dist/cli/commands/diff-impact.d.ts.map +1 -1
  15. package/dist/cli/commands/diff-impact.js +2 -1
  16. package/dist/cli/commands/diff-impact.js.map +1 -1
  17. package/dist/cli/commands/info.d.ts.map +1 -1
  18. package/dist/cli/commands/info.js +3 -2
  19. package/dist/cli/commands/info.js.map +1 -1
  20. package/dist/db/connection.d.ts +1 -0
  21. package/dist/db/connection.d.ts.map +1 -1
  22. package/dist/db/connection.js +22 -4
  23. package/dist/db/connection.js.map +1 -1
  24. package/dist/db/repository/base.d.ts +41 -0
  25. package/dist/db/repository/base.d.ts.map +1 -1
  26. package/dist/db/repository/base.js +22 -0
  27. package/dist/db/repository/base.js.map +1 -1
  28. package/dist/db/repository/index.d.ts +1 -0
  29. package/dist/db/repository/index.d.ts.map +1 -1
  30. package/dist/db/repository/index.js.map +1 -1
  31. package/dist/db/repository/native-repository.d.ts +8 -1
  32. package/dist/db/repository/native-repository.d.ts.map +1 -1
  33. package/dist/db/repository/native-repository.js +69 -1
  34. package/dist/db/repository/native-repository.js.map +1 -1
  35. package/dist/db/repository/sqlite-repository.d.ts +1 -0
  36. package/dist/db/repository/sqlite-repository.d.ts.map +1 -1
  37. package/dist/db/repository/sqlite-repository.js +25 -0
  38. package/dist/db/repository/sqlite-repository.js.map +1 -1
  39. package/dist/domain/analysis/dependencies.d.ts +1 -28
  40. package/dist/domain/analysis/dependencies.d.ts.map +1 -1
  41. package/dist/domain/analysis/dependencies.js +24 -8
  42. package/dist/domain/analysis/dependencies.js.map +1 -1
  43. package/dist/domain/graph/builder/incremental.d.ts.map +1 -1
  44. package/dist/domain/graph/builder/incremental.js +18 -0
  45. package/dist/domain/graph/builder/incremental.js.map +1 -1
  46. package/dist/domain/graph/builder/pipeline.d.ts.map +1 -1
  47. package/dist/domain/graph/builder/pipeline.js +298 -206
  48. package/dist/domain/graph/builder/pipeline.js.map +1 -1
  49. package/dist/domain/graph/builder/stages/build-edges.d.ts.map +1 -1
  50. package/dist/domain/graph/builder/stages/build-edges.js +56 -3
  51. package/dist/domain/graph/builder/stages/build-edges.js.map +1 -1
  52. package/dist/domain/graph/builder/stages/resolve-imports.d.ts.map +1 -1
  53. package/dist/domain/graph/builder/stages/resolve-imports.js +19 -23
  54. package/dist/domain/graph/builder/stages/resolve-imports.js.map +1 -1
  55. package/dist/domain/graph/watcher.d.ts.map +1 -1
  56. package/dist/domain/graph/watcher.js +99 -95
  57. package/dist/domain/graph/watcher.js.map +1 -1
  58. package/dist/domain/parser.d.ts +4 -0
  59. package/dist/domain/parser.d.ts.map +1 -1
  60. package/dist/domain/parser.js +130 -61
  61. package/dist/domain/parser.js.map +1 -1
  62. package/dist/domain/search/models.d.ts.map +1 -1
  63. package/dist/domain/search/models.js +7 -5
  64. package/dist/domain/search/models.js.map +1 -1
  65. package/dist/extractors/go.js +53 -35
  66. package/dist/extractors/go.js.map +1 -1
  67. package/dist/extractors/javascript.js +85 -36
  68. package/dist/extractors/javascript.js.map +1 -1
  69. package/dist/features/complexity.d.ts.map +1 -1
  70. package/dist/features/complexity.js +78 -58
  71. package/dist/features/complexity.js.map +1 -1
  72. package/dist/features/dataflow.d.ts.map +1 -1
  73. package/dist/features/dataflow.js +109 -118
  74. package/dist/features/dataflow.js.map +1 -1
  75. package/dist/features/structure.d.ts.map +1 -1
  76. package/dist/features/structure.js +147 -97
  77. package/dist/features/structure.js.map +1 -1
  78. package/dist/graph/algorithms/louvain.d.ts.map +1 -1
  79. package/dist/graph/algorithms/louvain.js +4 -2
  80. package/dist/graph/algorithms/louvain.js.map +1 -1
  81. package/dist/graph/classifiers/roles.d.ts +2 -0
  82. package/dist/graph/classifiers/roles.d.ts.map +1 -1
  83. package/dist/graph/classifiers/roles.js +13 -5
  84. package/dist/graph/classifiers/roles.js.map +1 -1
  85. package/dist/presentation/communities.d.ts.map +1 -1
  86. package/dist/presentation/communities.js +38 -34
  87. package/dist/presentation/communities.js.map +1 -1
  88. package/dist/presentation/manifesto.d.ts.map +1 -1
  89. package/dist/presentation/manifesto.js +31 -33
  90. package/dist/presentation/manifesto.js.map +1 -1
  91. package/dist/presentation/queries-cli/inspect.d.ts.map +1 -1
  92. package/dist/presentation/queries-cli/inspect.js +47 -46
  93. package/dist/presentation/queries-cli/inspect.js.map +1 -1
  94. package/dist/shared/file-utils.d.ts.map +1 -1
  95. package/dist/shared/file-utils.js +94 -72
  96. package/dist/shared/file-utils.js.map +1 -1
  97. package/dist/types.d.ts +83 -2
  98. package/dist/types.d.ts.map +1 -1
  99. package/grammars/tree-sitter-erlang.wasm +0 -0
  100. package/grammars/tree-sitter-gleam.wasm +0 -0
  101. package/package.json +9 -9
  102. package/src/ast-analysis/engine.ts +150 -55
  103. package/src/ast-analysis/visitors/ast-store-visitor.ts +19 -21
  104. package/src/ast-analysis/visitors/complexity-visitor.ts +55 -1
  105. package/src/cli/commands/branch-compare.ts +4 -0
  106. package/src/cli/commands/diff-impact.ts +2 -1
  107. package/src/cli/commands/info.ts +3 -2
  108. package/src/db/connection.ts +24 -5
  109. package/src/db/repository/base.ts +57 -0
  110. package/src/db/repository/index.ts +1 -0
  111. package/src/db/repository/native-repository.ts +92 -1
  112. package/src/db/repository/sqlite-repository.ts +26 -0
  113. package/src/domain/analysis/dependencies.ts +24 -6
  114. package/src/domain/graph/builder/incremental.ts +21 -0
  115. package/src/domain/graph/builder/pipeline.ts +396 -245
  116. package/src/domain/graph/builder/stages/build-edges.ts +53 -2
  117. package/src/domain/graph/builder/stages/resolve-imports.ts +20 -20
  118. package/src/domain/graph/watcher.ts +118 -98
  119. package/src/domain/parser.ts +131 -63
  120. package/src/domain/search/models.ts +11 -5
  121. package/src/extractors/go.ts +57 -32
  122. package/src/extractors/javascript.ts +88 -35
  123. package/src/features/complexity.ts +94 -58
  124. package/src/features/dataflow.ts +153 -132
  125. package/src/features/structure.ts +167 -95
  126. package/src/graph/algorithms/louvain.ts +5 -2
  127. package/src/graph/classifiers/roles.ts +14 -5
  128. package/src/presentation/communities.ts +44 -39
  129. package/src/presentation/manifesto.ts +35 -38
  130. package/src/presentation/queries-cli/inspect.ts +48 -46
  131. package/src/shared/file-utils.ts +116 -77
  132. package/src/types.ts +87 -1
@@ -119,6 +119,23 @@ function buildImportEdges(
119
119
  : 'imports';
120
120
  allEdgeRows.push([fileNodeId, targetRow.id, edgeKind, 1.0, 0]);
121
121
 
122
+ // Type-only imports: create symbol-level edges so the target symbols
123
+ // get fan-in credit and aren't falsely classified as dead code.
124
+ if (imp.typeOnly && ctx.nodesByNameAndFile) {
125
+ for (const name of imp.names) {
126
+ const cleanName = name.replace(/^\*\s+as\s+/, '');
127
+ let targetFile = resolvedPath;
128
+ if (isBarrelFile(ctx, resolvedPath)) {
129
+ const actual = resolveBarrelExport(ctx, resolvedPath, cleanName);
130
+ if (actual) targetFile = actual;
131
+ }
132
+ const candidates = ctx.nodesByNameAndFile.get(`${cleanName}|${targetFile}`);
133
+ if (candidates && candidates.length > 0) {
134
+ allEdgeRows.push([fileNodeId, candidates[0]!.id, 'imports-type', 1.0, 0]);
135
+ }
136
+ }
137
+ }
138
+
122
139
  if (!imp.reexport && isBarrelFile(ctx, resolvedPath)) {
123
140
  buildBarrelEdges(ctx, imp, resolvedPath, fileNodeId, edgeKind, getNodeIdStmt, allEdgeRows);
124
141
  }
@@ -280,7 +297,18 @@ function buildImportEdgesNative(
280
297
  }
281
298
  }
282
299
 
283
- // 6. Call native
300
+ // 6. Build symbol node entries for type-only import resolution
301
+ const symbolNodes: Array<{ name: string; file: string; nodeId: number }> = [];
302
+ if (ctx.nodesByNameAndFile) {
303
+ for (const [key, nodes] of ctx.nodesByNameAndFile) {
304
+ if (nodes.length > 0) {
305
+ const [name, file] = key.split('|');
306
+ symbolNodes.push({ name: name!, file: file!, nodeId: nodes[0]!.id });
307
+ }
308
+ }
309
+ }
310
+
311
+ // 7. Call native
284
312
  const nativeEdges = native.buildImportEdges!(
285
313
  files,
286
314
  resolvedImports,
@@ -288,6 +316,7 @@ function buildImportEdgesNative(
288
316
  fileNodeIds,
289
317
  barrelFiles,
290
318
  rootDir,
319
+ symbolNodes,
291
320
  ) as NativeEdge[];
292
321
 
293
322
  for (const e of nativeEdges) {
@@ -354,7 +383,10 @@ function buildImportedNamesForNative(
354
383
  rootDir: string,
355
384
  ): Array<{ name: string; file: string }> {
356
385
  const importedNames: Array<{ name: string; file: string }> = [];
357
- for (const imp of symbols.imports) {
386
+ // Process dynamic imports first (lower priority), then static imports
387
+ // (higher priority). Rust HashMap::collect keeps the last entry per key,
388
+ // so static imports win when both contribute the same name.
389
+ const addImports = (imp: (typeof symbols.imports)[number]) => {
358
390
  const resolvedPath = getResolved(ctx, path.join(rootDir, relPath), imp.source);
359
391
  for (const name of imp.names) {
360
392
  const cleanName = name.replace(/^\*\s+as\s+/, '');
@@ -365,6 +397,12 @@ function buildImportedNamesForNative(
365
397
  }
366
398
  importedNames.push({ name: cleanName, file: targetFile });
367
399
  }
400
+ };
401
+ for (const imp of symbols.imports) {
402
+ if (imp.dynamicImport) addImports(imp);
403
+ }
404
+ for (const imp of symbols.imports) {
405
+ if (!imp.dynamicImport) addImports(imp);
368
406
  }
369
407
  return importedNames;
370
408
  }
@@ -409,12 +447,25 @@ function buildImportedNamesMap(
409
447
  rootDir: string,
410
448
  ): Map<string, string> {
411
449
  const importedNames = new Map<string, string>();
450
+ // Process dynamic imports first (lower priority), then static imports
451
+ // (higher priority). Static imports represent direct bindings while dynamic
452
+ // imports often use aliased destructuring (`{ foo: bar } = await import(…)`).
453
+ // When both contribute the same name, the static binding is authoritative.
412
454
  for (const imp of symbols.imports) {
455
+ if (!imp.dynamicImport) continue;
413
456
  const resolvedPath = getResolved(ctx, path.join(rootDir, relPath), imp.source);
414
457
  for (const name of imp.names) {
415
458
  importedNames.set(name.replace(/^\*\s+as\s+/, ''), resolvedPath);
416
459
  }
417
460
  }
461
+ for (const imp of symbols.imports) {
462
+ if (imp.dynamicImport) continue;
463
+ const resolvedPath = getResolved(ctx, path.join(rootDir, relPath), imp.source);
464
+ for (const name of imp.names) {
465
+ const cleanName = name.replace(/^\*\s+as\s+/, '');
466
+ importedNames.set(cleanName, resolvedPath);
467
+ }
468
+ }
418
469
  return importedNames;
419
470
  }
420
471
 
@@ -180,6 +180,13 @@ export function isBarrelFile(ctx: PipelineContext, relPath: string): boolean {
180
180
  return reexports.length >= ownDefs;
181
181
  }
182
182
 
183
+ /** Check if a re-export source directly defines the symbol. */
184
+ function sourceDefinesSymbol(ctx: PipelineContext, source: string, symbolName: string): boolean {
185
+ const targetSymbols = ctx.fileSymbols.get(source);
186
+ if (!targetSymbols) return false;
187
+ return targetSymbols.definitions.some((d) => d.name === symbolName);
188
+ }
189
+
183
190
  export function resolveBarrelExport(
184
191
  ctx: PipelineContext,
185
192
  barrelPath: string,
@@ -188,31 +195,24 @@ export function resolveBarrelExport(
188
195
  ): string | null {
189
196
  if (visited.has(barrelPath)) return null;
190
197
  visited.add(barrelPath);
198
+
191
199
  const reexports = ctx.reexportMap.get(barrelPath) as ReexportEntry[] | undefined;
192
200
  if (!reexports) return null;
201
+
193
202
  for (const re of reexports) {
203
+ // Named re-export: only follow if the symbol is in the export list
194
204
  if (re.names.length > 0 && !re.wildcardReexport) {
195
- if (re.names.includes(symbolName)) {
196
- const targetSymbols = ctx.fileSymbols.get(re.source);
197
- if (targetSymbols) {
198
- const hasDef = targetSymbols.definitions.some((d) => d.name === symbolName);
199
- if (hasDef) return re.source;
200
- const deeper = resolveBarrelExport(ctx, re.source, symbolName, visited);
201
- if (deeper) return deeper;
202
- }
203
- return re.source;
204
- }
205
- continue;
206
- }
207
- if (re.wildcardReexport || re.names.length === 0) {
208
- const targetSymbols = ctx.fileSymbols.get(re.source);
209
- if (targetSymbols) {
210
- const hasDef = targetSymbols.definitions.some((d) => d.name === symbolName);
211
- if (hasDef) return re.source;
212
- const deeper = resolveBarrelExport(ctx, re.source, symbolName, visited);
213
- if (deeper) return deeper;
214
- }
205
+ if (!re.names.includes(symbolName)) continue;
206
+ if (sourceDefinesSymbol(ctx, re.source, symbolName)) return re.source;
207
+ const deeper = resolveBarrelExport(ctx, re.source, symbolName, visited);
208
+ return deeper ?? re.source;
215
209
  }
210
+
211
+ // Wildcard or namespace re-export: check if target defines the symbol
212
+ if (sourceDefinesSymbol(ctx, re.source, symbolName)) return re.source;
213
+ const deeper = resolveBarrelExport(ctx, re.source, symbolName, visited);
214
+ if (deeper) return deeper;
216
215
  }
216
+
217
217
  return null;
218
218
  }
@@ -2,20 +2,16 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { closeDb, getNodeId as getNodeIdQuery, initSchema, openDb } from '../../db/index.js';
4
4
  import { debug, info } from '../../infrastructure/logger.js';
5
- import { EXTENSIONS, IGNORE_DIRS, normalizePath } from '../../shared/constants.js';
5
+ import { isSupportedFile, normalizePath, shouldIgnore } from '../../shared/constants.js';
6
6
  import { DbError } from '../../shared/errors.js';
7
7
  import { createParseTreeCache, getActiveEngine } from '../parser.js';
8
8
  import { type IncrementalStmts, rebuildFile } from './builder/incremental.js';
9
9
  import { appendChangeEvents, buildChangeEvent, diffSymbols } from './change-journal.js';
10
10
  import { appendJournalEntries } from './journal.js';
11
11
 
12
- function shouldIgnore(filePath: string): boolean {
12
+ function shouldIgnorePath(filePath: string): boolean {
13
13
  const parts = filePath.split(path.sep);
14
- return parts.some((p) => IGNORE_DIRS.has(p));
15
- }
16
-
17
- function isTrackedExt(filePath: string): boolean {
18
- return EXTENSIONS.has(path.extname(filePath));
14
+ return parts.some((p) => shouldIgnore(p));
19
15
  }
20
16
 
21
17
  /** Prepare all SQL statements needed by the watcher's incremental rebuild. */
@@ -141,24 +137,35 @@ function collectTrackedFiles(dir: string, result: string[]): void {
141
137
  let entries: fs.Dirent[];
142
138
  try {
143
139
  entries = fs.readdirSync(dir, { withFileTypes: true });
144
- } catch {
140
+ } catch (e: unknown) {
141
+ debug(`collectTrackedFiles: cannot read ${dir}: ${(e as Error).message}`);
145
142
  return;
146
143
  }
147
144
  for (const entry of entries) {
148
- if (IGNORE_DIRS.has(entry.name) || entry.name.startsWith('.')) continue;
145
+ if (shouldIgnore(entry.name)) continue;
149
146
  const full = path.join(dir, entry.name);
150
147
  if (entry.isDirectory()) {
151
148
  collectTrackedFiles(full, result);
152
- } else if (EXTENSIONS.has(path.extname(entry.name))) {
149
+ } else if (isSupportedFile(entry.name)) {
153
150
  result.push(full);
154
151
  }
155
152
  }
156
153
  }
157
154
 
158
- export async function watchProject(
159
- rootDir: string,
160
- opts: { engine?: string; poll?: boolean; pollInterval?: number } = {},
161
- ): Promise<void> {
155
+ /** Shared watcher state passed between setup and watcher sub-functions. */
156
+ interface WatcherContext {
157
+ rootDir: string;
158
+ db: ReturnType<typeof openDb>;
159
+ stmts: IncrementalStmts;
160
+ engineOpts: import('../../types.js').EngineOpts;
161
+ cache: ReturnType<typeof createParseTreeCache>;
162
+ pending: Set<string>;
163
+ timer: ReturnType<typeof setTimeout> | null;
164
+ debounceMs: number;
165
+ }
166
+
167
+ /** Initialize DB, engine, cache, and statements for watch mode. */
168
+ function setupWatcher(rootDir: string, opts: { engine?: string }): WatcherContext {
162
169
  const dbPath = path.join(rootDir, '.codegraph', 'graph.db');
163
170
  if (!fs.existsSync(dbPath)) {
164
171
  throw new DbError('No graph.db found. Run `codegraph build` first.', { file: dbPath });
@@ -183,111 +190,124 @@ export async function watchProject(
183
190
 
184
191
  const stmts = prepareWatcherStatements(db);
185
192
 
186
- const pending = new Set<string>();
187
- let timer: ReturnType<typeof setTimeout> | null = null;
188
- const DEBOUNCE_MS = 300;
189
-
190
- const usePoll = opts.poll ?? process.platform === 'win32';
191
- const POLL_INTERVAL_MS = opts.pollInterval ?? 2000;
193
+ return {
194
+ rootDir,
195
+ db,
196
+ stmts,
197
+ engineOpts,
198
+ cache,
199
+ pending: new Set<string>(),
200
+ timer: null,
201
+ debounceMs: 300,
202
+ };
203
+ }
192
204
 
193
- info(`Watching ${rootDir} for changes${usePoll ? ' (polling mode)' : ''}...`);
194
- info('Press Ctrl+C to stop.');
205
+ /** Schedule debounced processing of pending files. */
206
+ function scheduleDebouncedProcess(ctx: WatcherContext): void {
207
+ if (ctx.timer) clearTimeout(ctx.timer);
208
+ ctx.timer = setTimeout(async () => {
209
+ const files = [...ctx.pending];
210
+ ctx.pending.clear();
211
+ await processPendingFiles(files, ctx.db, ctx.rootDir, ctx.stmts, ctx.engineOpts, ctx.cache);
212
+ }, ctx.debounceMs);
213
+ }
195
214
 
196
- let cleanup: () => void;
215
+ /** Start polling-based file watcher. Returns cleanup function. */
216
+ function startPollingWatcher(ctx: WatcherContext, pollIntervalMs: number): () => void {
217
+ const mtimeMap = new Map<string, number>();
218
+
219
+ const initial: string[] = [];
220
+ collectTrackedFiles(ctx.rootDir, initial);
221
+ for (const f of initial) {
222
+ try {
223
+ mtimeMap.set(f, fs.statSync(f).mtimeMs);
224
+ } catch {
225
+ /* deleted between collect and stat */
226
+ }
227
+ }
228
+ info(`Polling ${initial.length} tracked files every ${pollIntervalMs}ms`);
197
229
 
198
- if (usePoll) {
199
- // Polling mode: avoids native OS file watchers (NtNotifyChangeDirectoryFileEx)
200
- // which can crash ReFS drivers on Windows Dev Drives.
201
- const mtimeMap = new Map<string, number>();
230
+ const pollTimer = setInterval(() => {
231
+ const current: string[] = [];
232
+ collectTrackedFiles(ctx.rootDir, current);
233
+ const currentSet = new Set(current);
202
234
 
203
- // Seed initial mtimes
204
- const initial: string[] = [];
205
- collectTrackedFiles(rootDir, initial);
206
- for (const f of initial) {
235
+ for (const f of current) {
207
236
  try {
208
- mtimeMap.set(f, fs.statSync(f).mtimeMs);
237
+ const mtime = fs.statSync(f).mtimeMs;
238
+ const prev = mtimeMap.get(f);
239
+ if (prev === undefined || mtime !== prev) {
240
+ mtimeMap.set(f, mtime);
241
+ ctx.pending.add(f);
242
+ }
209
243
  } catch {
210
244
  /* deleted between collect and stat */
211
245
  }
212
246
  }
213
- info(`Polling ${initial.length} tracked files every ${POLL_INTERVAL_MS}ms`);
214
-
215
- const pollTimer = setInterval(() => {
216
- const current: string[] = [];
217
- collectTrackedFiles(rootDir, current);
218
- const currentSet = new Set(current);
219
-
220
- // Detect modified or new files
221
- for (const f of current) {
222
- try {
223
- const mtime = fs.statSync(f).mtimeMs;
224
- const prev = mtimeMap.get(f);
225
- if (prev === undefined || mtime !== prev) {
226
- mtimeMap.set(f, mtime);
227
- pending.add(f);
228
- }
229
- } catch {
230
- /* deleted between collect and stat */
231
- }
232
- }
233
247
 
234
- // Detect deleted files
235
- for (const f of mtimeMap.keys()) {
236
- if (!currentSet.has(f)) {
237
- mtimeMap.delete(f);
238
- pending.add(f);
239
- }
248
+ for (const f of mtimeMap.keys()) {
249
+ if (!currentSet.has(f)) {
250
+ mtimeMap.delete(f);
251
+ ctx.pending.add(f);
240
252
  }
253
+ }
241
254
 
242
- if (pending.size > 0) {
243
- if (timer) clearTimeout(timer);
244
- timer = setTimeout(async () => {
245
- const files = [...pending];
246
- pending.clear();
247
- await processPendingFiles(files, db, rootDir, stmts, engineOpts, cache);
248
- }, DEBOUNCE_MS);
249
- }
250
- }, POLL_INTERVAL_MS);
251
-
252
- cleanup = () => clearInterval(pollTimer);
253
- } else {
254
- // Native OS watcher — efficient but can trigger ReFS crashes on Windows Dev Drives.
255
- // Use --poll if you experience BSOD/HYPERVISOR_ERROR on ReFS volumes.
256
- const watcher = fs.watch(rootDir, { recursive: true }, (_eventType, filename) => {
257
- if (!filename) return;
258
- if (shouldIgnore(filename)) return;
259
- if (!isTrackedExt(filename)) return;
260
-
261
- const fullPath = path.join(rootDir, filename);
262
- pending.add(fullPath);
263
-
264
- if (timer) clearTimeout(timer);
265
- timer = setTimeout(async () => {
266
- const files = [...pending];
267
- pending.clear();
268
- await processPendingFiles(files, db, rootDir, stmts, engineOpts, cache);
269
- }, DEBOUNCE_MS);
270
- });
271
-
272
- cleanup = () => watcher.close();
273
- }
255
+ if (ctx.pending.size > 0) {
256
+ scheduleDebouncedProcess(ctx);
257
+ }
258
+ }, pollIntervalMs);
259
+
260
+ return () => clearInterval(pollTimer);
261
+ }
262
+
263
+ /** Start native OS file watcher. Returns cleanup function. */
264
+ function startNativeWatcher(ctx: WatcherContext): () => void {
265
+ const watcher = fs.watch(ctx.rootDir, { recursive: true }, (_eventType, filename) => {
266
+ if (!filename) return;
267
+ if (shouldIgnorePath(filename)) return;
268
+ if (!isSupportedFile(filename)) return;
274
269
 
275
- process.on('SIGINT', () => {
270
+ ctx.pending.add(path.join(ctx.rootDir, filename));
271
+ scheduleDebouncedProcess(ctx);
272
+ });
273
+
274
+ return () => watcher.close();
275
+ }
276
+
277
+ /** Register SIGINT handler to flush journal and clean up. */
278
+ function setupShutdownHandler(ctx: WatcherContext, cleanup: () => void): void {
279
+ process.once('SIGINT', () => {
276
280
  info('Stopping watcher...');
277
281
  cleanup();
278
- // Flush any pending file paths to journal before exit
279
- if (pending.size > 0) {
280
- const entries = [...pending].map((filePath) => ({
281
- file: normalizePath(path.relative(rootDir, filePath)),
282
+ if (ctx.pending.size > 0) {
283
+ const entries = [...ctx.pending].map((filePath) => ({
284
+ file: normalizePath(path.relative(ctx.rootDir, filePath)),
282
285
  }));
283
286
  try {
284
- appendJournalEntries(rootDir, entries);
287
+ appendJournalEntries(ctx.rootDir, entries);
285
288
  } catch (e: unknown) {
286
289
  debug(`Journal flush on exit failed (non-fatal): ${(e as Error).message}`);
287
290
  }
288
291
  }
289
- if (cache) cache.clear();
290
- closeDb(db);
292
+ if (ctx.cache) ctx.cache.clear();
293
+ closeDb(ctx.db);
291
294
  process.exit(0);
292
295
  });
293
296
  }
297
+
298
+ export async function watchProject(
299
+ rootDir: string,
300
+ opts: { engine?: string; poll?: boolean; pollInterval?: number } = {},
301
+ ): Promise<void> {
302
+ const ctx = setupWatcher(rootDir, opts);
303
+
304
+ const usePoll = opts.poll ?? process.platform === 'win32';
305
+ const pollIntervalMs = opts.pollInterval ?? 2000;
306
+
307
+ info(`Watching ${rootDir} for changes${usePoll ? ' (polling mode)' : ''}...`);
308
+ info('Press Ctrl+C to stop.');
309
+
310
+ const cleanup = usePoll ? startPollingWatcher(ctx, pollIntervalMs) : startNativeWatcher(ctx);
311
+
312
+ setupShutdownHandler(ctx, cleanup);
313
+ }