@ngockhoale/ukit 2.4.2 → 2.5.0

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 (59) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/README.md +20 -0
  3. package/manifests/platform.full.yaml +51 -112
  4. package/package.json +2 -1
  5. package/scripts/index/refresh-index.mjs +47 -22
  6. package/src/cli/commands/doctor.js +132 -2
  7. package/src/cli/commands/uninstall.js +18 -0
  8. package/src/core/applyPlan.js +17 -2
  9. package/src/core/compact/threshold.js +36 -6
  10. package/src/core/diffPlan.js +35 -0
  11. package/src/core/fileOps.js +26 -0
  12. package/src/core/projectImportant.js +430 -0
  13. package/src/core/sensitiveValueScanner.js +118 -0
  14. package/src/core/status.js +55 -1
  15. package/src/core/uninstall.js +183 -3
  16. package/src/diagnostics/classifyHang.js +246 -0
  17. package/src/index/buildIndex.js +1033 -62
  18. package/templates/.claude/hooks/auto-allow-bash.sh +82 -93
  19. package/templates/.claude/hooks/block-dangerous.sh +31 -5
  20. package/templates/.claude/hooks/completion-gate.sh +51 -10
  21. package/templates/.claude/hooks/compress-output.sh +38 -6
  22. package/templates/.claude/hooks/context-hardcap-gate.sh +35 -6
  23. package/templates/.claude/hooks/context-window-guard.sh +128 -18
  24. package/templates/.claude/hooks/handoff-model-guard.sh +31 -5
  25. package/templates/.claude/hooks/handoff-resume.sh +31 -5
  26. package/templates/.claude/hooks/post-edit-verify.sh +31 -5
  27. package/templates/.claude/hooks/pre-edit-backup.sh +31 -5
  28. package/templates/.claude/hooks/project-important.sh +67 -0
  29. package/templates/.claude/hooks/protect-files.sh +31 -5
  30. package/templates/.claude/hooks/record-execution.sh +31 -5
  31. package/templates/.claude/hooks/sensitive-data-guard.sh +124 -56
  32. package/templates/.claude/hooks/skill-router.sh +31 -5
  33. package/templates/.claude/hooks/stale-spec-guard.sh +31 -5
  34. package/templates/.claude/hooks/task-watchdog.sh +108 -123
  35. package/templates/.claude/hooks/verification-guard.sh +107 -112
  36. package/templates/.claude/hooks/vision-router.sh +49 -13
  37. package/templates/.claude/settings.json +5 -5
  38. package/templates/.claude/ukit/index/lib/index-core.mjs +960 -63
  39. package/templates/.claude/ukit/index/refresh-index.mjs +47 -22
  40. package/templates/.claude/ukit/index/route-task.mjs +610 -4
  41. package/templates/.claude/ukit/runtime/async-lock.mjs +340 -0
  42. package/templates/.claude/ukit/runtime/compact-threshold.mjs +73 -24
  43. package/templates/.claude/ukit/runtime/context-capacity.mjs +144 -0
  44. package/templates/.claude/ukit/runtime/execution-ledger.mjs +664 -170
  45. package/templates/.claude/ukit/runtime/hook-chain-budget.mjs +92 -0
  46. package/templates/.claude/ukit/runtime/hook-chain-runner.mjs +84 -29
  47. package/templates/.claude/ukit/runtime/hook-input.sh +85 -5
  48. package/templates/.claude/ukit/runtime/hook-payload-store.mjs +160 -0
  49. package/templates/.claude/ukit/runtime/hook-process.mjs +250 -0
  50. package/templates/.claude/ukit/runtime/hook-telemetry.mjs +255 -0
  51. package/templates/.claude/ukit/runtime/hook-telemetry.sh +60 -0
  52. package/templates/.claude/ukit/runtime/project-important.mjs +381 -0
  53. package/templates/.claude/ukit/runtime/sensitive-value-scanner.mjs +128 -0
  54. package/templates/.claude/ukit/runtime/stop-coordinator.mjs +509 -0
  55. package/templates/.claude/ukit/runtime/task-watchdog.mjs +180 -6
  56. package/templates/.claude/ukit/runtime/transcript-tail.mjs +1 -1
  57. package/templates/.omp/hooks/pre/ukit-bridge.js +178 -61
  58. package/templates/AGENTS.md +8 -0
  59. package/templates/PROJECT_IMPORTANT.md +9 -0
@@ -8,7 +8,103 @@ import { importsNeedAliasContext, loadImportAliasContextState, resolveImportSpec
8
8
  import { clearIndexArtifactCache } from './queryIndex.js';
9
9
  import { clearRelatedTestArtifactCache } from './relatedTests.js';
10
10
 
11
- const GIT_ENUMERATION_TIMEOUT_MS = 15_000;
11
+ // One wall-clock ceiling for the WHOLE discovery phase: both `git ls-files`
12
+ // enumerations AND the non-git fallback walk share this single budget. The
13
+ // previous per-git-call 15s timeout let two hung enumerations cost 2 × 15s
14
+ // before discovery ever fell back to the directory walk; now every stage
15
+ // receives only the remaining budget and expiry throws a typed
16
+ // IndexDiscoveryTimeoutError instead of degrading silently.
17
+ export const DEFAULT_DISCOVERY_DEADLINE_MS = 30_000;
18
+
19
+ // Upper bound on in-flight filesystem stat calls during discovery: git
20
+ // candidate stats and the non-git fallback walk share this fixed pool.
21
+ // The previous `Promise.all(candidates.map(stat))` produced one unbounded
22
+ // promise per candidate, so a 10k-file listing held 10k concurrent stats
23
+ // (descriptors + scheduler pressure) at once. With the pool, peak in-flight
24
+ // I/O stays flat no matter the repository size.
25
+ export const INDEX_IO_CONCURRENCY = 64;
26
+
27
+ /**
28
+ * Typed cancellation failure for build phases beyond discovery (stat pools,
29
+ * file reads/parse, artifact publish) when the caller's AbortSignal fires.
30
+ * Discovery keeps its own deadline/abort classification in
31
+ * IndexDiscoveryTimeoutError; this error reports explicit caller cancellation
32
+ * of the rest of the pipeline.
33
+ */
34
+ export class IndexCancelledError extends Error {
35
+ constructor({ phase } = {}) {
36
+ super(`Index work was cancelled by its AbortSignal (during ${phase}).`);
37
+ this.name = 'IndexCancelledError';
38
+ this.code = 'INDEX_CANCELLED';
39
+ this.phase = phase;
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Fixed-concurrency worker pool over `items`.
45
+ *
46
+ * At most `limit` workers run at once, so in-flight I/O never scales with the
47
+ * item count. Each worker re-checks `signal` BEFORE claiming its next item, so
48
+ * an abort leaves queued work unstarted and the promise rejects with
49
+ * IndexCancelledError. A worker rejection maps that one item to `null` while
50
+ * the remaining items still process — a single failing stat/read can never
51
+ * hang or kill the pool.
52
+ *
53
+ * @returns {Promise<Array>} results in `items` order (null for failed items).
54
+ */
55
+ export async function mapLimit(items, limit, worker, signal = null, phase = 'map') {
56
+ const concurrency = Math.max(1, Math.floor(Number(limit)) || 1);
57
+ const results = new Array(items.length);
58
+ let nextIndex = 0;
59
+
60
+ const runWorker = async () => {
61
+ while (nextIndex < items.length) {
62
+ if (signal?.aborted) {
63
+ throw new IndexCancelledError({ phase });
64
+ }
65
+ const index = nextIndex;
66
+ nextIndex += 1;
67
+ try {
68
+ results[index] = await worker(items[index], index);
69
+ } catch (error) {
70
+ if (error instanceof IndexDiscoveryTimeoutError || error instanceof IndexCancelledError) {
71
+ throw error;
72
+ }
73
+ results[index] = null;
74
+ }
75
+ }
76
+ };
77
+
78
+ await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, () => runWorker()));
79
+ return results;
80
+ }
81
+
82
+ /** Gate for phases between discovery and publish: abort means nothing is written. */
83
+ function assertNotAborted(signal, phase) {
84
+ if (signal?.aborted) {
85
+ throw new IndexCancelledError({ phase });
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Typed liveness failure for project discovery. Thrown when the discovery
91
+ * wall-clock deadline expires or the caller's AbortSignal fires — never a
92
+ * silent partial result.
93
+ */
94
+ export class IndexDiscoveryTimeoutError extends Error {
95
+ constructor({ deadlineMs, phase, elapsedMs, aborted = false }) {
96
+ super(aborted
97
+ ? `Index discovery was aborted ${elapsedMs}ms into its ${deadlineMs}ms deadline (during ${phase}).`
98
+ : `Index discovery exceeded its ${deadlineMs}ms wall-clock deadline (elapsed ${elapsedMs}ms, during ${phase}).`);
99
+ this.name = 'IndexDiscoveryTimeoutError';
100
+ this.code = 'INDEX_DISCOVERY_TIMEOUT';
101
+ this.deadlineMs = deadlineMs;
102
+ this.phase = phase;
103
+ this.elapsedMs = elapsedMs;
104
+ this.aborted = aborted;
105
+ }
106
+ }
107
+
12
108
  const EXCLUDED_DIR_NAMES = new Set([
13
109
  'node_modules',
14
110
  '.git',
@@ -39,15 +135,33 @@ export const DEFAULT_INDEX_CACHE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
39
135
  // Bumped only when the snapshot shape itself changes; consumers must reject
40
136
  // snapshots from a different version instead of trusting unknown fields.
41
137
  export const DISCOVERY_SNAPSHOT_VERSION = 1;
42
- const INDEX_PARSE_BATCH_SIZE = 8;
138
+ // Parse-phase read/extract concurrency — deliberately tighter than the
139
+ // discovery stat pool because parsing holds file content in memory.
140
+ export const INDEX_PARSE_BATCH_SIZE = 8;
43
141
  const CALL_IGNORE_WORDS = new Set([
44
142
  'if', 'for', 'while', 'switch', 'catch', 'function', 'return', 'typeof',
45
143
  ]);
46
144
 
47
- export async function buildCodeIndex({ rootDir = process.cwd(), discoverySnapshot = null } = {}) {
145
+ export async function buildCodeIndex({ rootDir = process.cwd(), changedFiles = null, discoverySnapshot = null, signal = null, deadlineMs = DEFAULT_DISCOVERY_DEADLINE_MS } = {}) {
48
146
  const absoluteRoot = path.resolve(rootDir);
49
147
  const indexDir = getIndexDir(absoluteRoot);
50
148
 
149
+ // ── Incremental fast path (TASK-023) ──
150
+ // Validated changed-file hints let an existing, schema-current index absorb
151
+ // the change set without a full repository discovery. Every unsafe hint, and
152
+ // every previous artifact that cannot be merged, falls back to the bounded
153
+ // full path below — correctness wins over the optimization, and incremental
154
+ // output must stay equivalent to a clean full rebuild.
155
+ const changedHintPlan = normalizeChangedFileHints(changedFiles);
156
+ if (changedHintPlan) {
157
+ const incremental = changedHintPlan.ok
158
+ ? await tryIncrementalIndexUpdate({ absoluteRoot, indexDir, changedPaths: changedHintPlan.paths, signal })
159
+ : null;
160
+ if (incremental) {
161
+ return incremental;
162
+ }
163
+ }
164
+
51
165
  const previousFilesArtifact = await readArtifactIfExists(absoluteRoot, INDEX_ARTIFACTS.files);
52
166
  const canReusePrevious = previousFilesArtifact?.schemaVersion === INDEX_SCHEMA_VERSION;
53
167
  const previousCodeFileRecords = canReusePrevious
@@ -65,7 +179,9 @@ export async function buildCodeIndex({ rootDir = process.cwd(), discoverySnapsho
65
179
  // enumeration instead of two.
66
180
  const discoveredFiles = isDiscoverySnapshotUsable(discoverySnapshot, absoluteRoot)
67
181
  ? discoverySnapshot.files
68
- : await discoverProjectFiles(absoluteRoot);
182
+ // Discovery precedes every artifact write, so a deadline expiry here aborts
183
+ // the build before any prior index artifact can be replaced.
184
+ : await discoverProjectFiles(absoluteRoot, { signal, deadlineMs });
69
185
  const sourceFingerprint = createSourceFingerprint(absoluteRoot, discoveredFiles);
70
186
  const styleFilePaths = discoveredFiles
71
187
  .map((entry) => {
@@ -152,32 +268,36 @@ export async function buildCodeIndex({ rootDir = process.cwd(), discoverySnapsho
152
268
  }
153
269
  const canReuseAllParsedArtifacts = canReuseParsedArtifacts && filesToParse.length === 0 && reusableCodeFiles.length === codeFiles.length;
154
270
 
155
- for (let start = 0; start < filesToParse.length; start += INDEX_PARSE_BATCH_SIZE) {
156
- const batch = filesToParse.slice(start, start + INDEX_PARSE_BATCH_SIZE);
157
- const parsedBatch = (await Promise.all(batch.map(async (filePath) => {
158
- try {
159
- const absolutePath = path.join(absoluteRoot, filePath);
160
- const content = await fs.readFile(absolutePath, 'utf8');
161
- const scriptContent = extractScriptContent(filePath, content);
162
- return {
163
- filePath,
164
- symbols: extractSymbols(filePath, scriptContent),
165
- imports: [
166
- ...extractImports(filePath, scriptContent),
167
- ...extractSupplementalImports(filePath, content),
168
- ],
169
- calls: extractFunctionCalls(filePath, scriptContent),
170
- };
171
- } catch {
172
- return null;
173
- }
174
- }))).filter(Boolean);
271
+ // Parse through the same fixed pool as discovery stats: reads stay bounded
272
+ // by INDEX_PARSE_BATCH_SIZE and the caller's signal is re-checked before
273
+ // every new file, so an abort stops the phase with queued files unread.
274
+ // Results come back in filesToParse order, matching the previous batch loop.
275
+ const parsedFiles = await mapLimit(filesToParse, INDEX_PARSE_BATCH_SIZE, async (filePath) => {
276
+ try {
277
+ const absolutePath = path.join(absoluteRoot, filePath);
278
+ const content = await fs.readFile(absolutePath, 'utf8');
279
+ const scriptContent = extractScriptContent(filePath, content);
280
+ return {
281
+ filePath,
282
+ symbols: extractSymbols(filePath, scriptContent),
283
+ imports: [
284
+ ...extractImports(filePath, scriptContent),
285
+ ...extractSupplementalImports(filePath, content),
286
+ ],
287
+ calls: extractFunctionCalls(filePath, scriptContent),
288
+ };
289
+ } catch {
290
+ return null;
291
+ }
292
+ }, signal, 'parse');
175
293
 
176
- for (const parsedFile of parsedBatch) {
177
- symbols.push(...parsedFile.symbols);
178
- imports.push(...parsedFile.imports);
179
- calls.push(...parsedFile.calls);
294
+ for (const parsedFile of parsedFiles) {
295
+ if (!parsedFile) {
296
+ continue;
180
297
  }
298
+ symbols.push(...parsedFile.symbols);
299
+ imports.push(...parsedFile.imports);
300
+ calls.push(...parsedFile.calls);
181
301
  }
182
302
 
183
303
  const canReuseTestsMapCandidate = canReusePrevious
@@ -280,6 +400,10 @@ export async function buildCodeIndex({ rootDir = process.cwd(), discoverySnapsho
280
400
 
281
401
  const generatedAt = new Date().toISOString();
282
402
 
403
+ // No partial artifact publish: an abort landing after parsing (during the
404
+ // artifact-graph phases above) must not replace any artifact either, so the
405
+ // whole write block sits behind this gate.
406
+ assertNotAborted(signal, 'publish');
283
407
  await fs.mkdir(indexDir, { recursive: true });
284
408
  if (!canReuseFilesArtifact) {
285
409
  await writeArtifact(absoluteRoot, INDEX_ARTIFACTS.files, {
@@ -357,6 +481,15 @@ export async function buildCodeIndex({ rootDir = process.cwd(), discoverySnapsho
357
481
  generatedAt,
358
482
  rootDir: absoluteRoot,
359
483
  sourceFingerprint,
484
+ // Item counts let a later incremental update prove the artifacts it reuses
485
+ // were not truncated between builds (TASK-023 review, important #2).
486
+ artifactItemCounts: {
487
+ [INDEX_ARTIFACTS.files]: fileRecords.length,
488
+ [INDEX_ARTIFACTS.symbols]: symbols.length,
489
+ [INDEX_ARTIFACTS.imports]: imports.length,
490
+ [INDEX_ARTIFACTS.calls]: calls.length,
491
+ [INDEX_ARTIFACTS.relations]: relations.length,
492
+ },
360
493
  });
361
494
  const preservedRuntimeCaches = canReuseFilesArtifact
362
495
  && canReuseAllParsedArtifacts
@@ -389,20 +522,671 @@ export async function buildCodeIndex({ rootDir = process.cwd(), discoverySnapsho
389
522
  reusedRelations: canReuseRelations,
390
523
  reusedAnalogs: canReuseAnalogs,
391
524
  preservedRuntimeCaches,
525
+ mode: 'full',
526
+ parsedFiles: filesToParse.slice(),
527
+ removedFiles: [],
392
528
  };
393
529
  }
394
530
 
395
- async function collectFiles(scanRoots) {
531
+ // ── Incremental refresh (TASK-023) ──────────────────────────────────────────
532
+ //
533
+ // `changedFiles` hints let refresh skip repository discovery entirely: the
534
+ // caller (a watcher or git-status driven hook) asserts which paths changed and
535
+ // the update merges exactly those paths into the existing artifacts. The path
536
+ // is taken only when EVERY gate holds:
537
+ // - every hint is a safe project-relative path (no absolute form, no `..`
538
+ // segment, no NUL byte), deduplicated and normalized;
539
+ // - the previous meta/files/symbols/imports/calls/relations artifacts all
540
+ // carry the current schema version and a source fingerprint to refresh;
541
+ // - every stat and read resolves unambiguously (ENOENT means deletion,
542
+ // anything else means "unknown" and falls back to full discovery);
543
+ // - every existing hint still resolves (through `realpath`) to a target
544
+ // inside the resolved root, so a symlink cannot pull external content into
545
+ // the index that a clean discovery would have excluded;
546
+ // - every reused artifact is a well-formed item array whose entries stay
547
+ // contained in the files artifact, so a truncated artifact cannot make the
548
+ // merge publish a partial artifact.
549
+ // Style files need one extra gate: discovery lists them but files.json never
550
+ // tracks them, so the previous style list only survives inside the relations
551
+ // artifact's source snapshot — without it the refreshed fingerprint could not
552
+ // match a clean rebuild.
553
+
554
+ function normalizeChangedFileHints(changedFiles) {
555
+ if (!Array.isArray(changedFiles) || changedFiles.length === 0) {
556
+ return null;
557
+ }
558
+
559
+ const paths = [];
560
+ const seen = new Set();
561
+ for (const raw of changedFiles) {
562
+ if (typeof raw !== 'string' || raw.includes('\0')) {
563
+ return { ok: false, paths: [] };
564
+ }
565
+ const trimmed = raw.trim();
566
+ if (!trimmed) {
567
+ continue;
568
+ }
569
+ // An absolute hint (POSIX or Windows drive form) can never describe a
570
+ // project-relative path, and a `..` segment cannot be trusted to stay
571
+ // contained — either rejects the whole hint set so the safe full
572
+ // discovery path runs instead.
573
+ if (path.isAbsolute(trimmed) || /^[A-Za-z]:[\\/]/.test(trimmed)) {
574
+ return { ok: false, paths: [] };
575
+ }
576
+ const segments = trimmed.split(/[\\/]+/);
577
+ if (segments.some((segment) => segment === '..')) {
578
+ return { ok: false, paths: [] };
579
+ }
580
+ const normalized = segments.filter((segment) => segment && segment !== '.').join('/');
581
+ if (!normalized || seen.has(normalized)) {
582
+ continue;
583
+ }
584
+ seen.add(normalized);
585
+ paths.push(normalized);
586
+ }
587
+
588
+ if (paths.length === 0) {
589
+ // Hints were requested but none survived validation: treat the request as
590
+ // ambiguous instead of silently doing nothing.
591
+ return { ok: false, paths: [] };
592
+ }
593
+ return { ok: true, paths };
594
+ }
595
+
596
+ function isSafeContainedRelative(rootDir, relativePath) {
597
+ if (!relativePath || path.isAbsolute(relativePath)) {
598
+ return false;
599
+ }
600
+ // Round-trip containment check: joining and re-relativizing must return the
601
+ // exact hint, which rules out separator or prefix surprises.
602
+ return path.relative(rootDir, path.join(rootDir, relativePath)) === relativePath;
603
+ }
604
+
605
+ function isSafeArtifactItem(item) {
606
+ return Boolean(item)
607
+ && typeof item === 'object'
608
+ && !Array.isArray(item)
609
+ && isSafeArtifactItemPath(item.filePath);
610
+ }
611
+
612
+ function isSafeArtifactItemPath(relativePath) {
613
+ return typeof relativePath === 'string'
614
+ && relativePath !== ''
615
+ && !path.isAbsolute(relativePath)
616
+ && !relativePath.split(/[\\/]+/).some((segment) => segment === '..');
617
+ }
618
+
619
+ function isValidFileRecord(item) {
620
+ return isSafeArtifactItem(item)
621
+ && typeof item.domain === 'string'
622
+ && typeof item.ext === 'string'
623
+ && Number.isFinite(Number(item.mtimeMs))
624
+ && Number.isFinite(Number(item.size));
625
+ }
626
+
627
+ /**
628
+ * The merge reuses every item of these artifacts verbatim and groups them by
629
+ * the covered file path. They are only safe to reuse when the array is present
630
+ * and every entry is a well-formed item whose covered path the files artifact
631
+ * still tracks — a malformed or foreign entry falls back to the bounded full
632
+ * path instead of publishing a partial artifact.
633
+ */
634
+ function isValidCoveredItems(items, fileRecords, keyFn) {
635
+ if (!Array.isArray(items)) {
636
+ return false;
637
+ }
638
+ const trackedPaths = new Set(fileRecords.map((record) => record.filePath));
639
+ return items.every((item) => {
640
+ if (!item || typeof item !== 'object' || Array.isArray(item)) {
641
+ return false;
642
+ }
643
+ const key = keyFn(item);
644
+ return isSafeArtifactItemPath(key) && trackedPaths.has(key);
645
+ });
646
+ }
647
+
648
+ /**
649
+ * Item shapes alone cannot detect a schema-current artifact whose `items` array
650
+ * lost entries: every surviving entry still looks valid, yet the merge would
651
+ * reuse it verbatim and publish an artifact missing the unchanged files. Each
652
+ * required artifact therefore records its item count in meta, and the count must
653
+ * match the array actually loaded — a mismatch (or a pre-count meta) falls back
654
+ * to full discovery.
655
+ */
656
+ function artifactItemCountsMatch(metaArtifact, counts) {
657
+ const recorded = metaArtifact?.artifactItemCounts;
658
+ if (!recorded || typeof recorded !== 'object' || Array.isArray(recorded)) {
659
+ return false;
660
+ }
661
+ return Object.entries(counts).every(([name, count]) => Number(recorded[name]) === count);
662
+ }
663
+
664
+ /**
665
+ * Stat every hinted path and classify it as an upsert, a removal, or — for
666
+ * extensions discovery never indexes — a no-op. Returns null when a hint
667
+ * cannot be resolved unambiguously so the caller falls back to full discovery.
668
+ *
669
+ * Containment is re-checked against the RESOLVED root for every hint that still
670
+ * exists: `fs.stat` and `fs.readFile` both follow symlinks, so the lexical check
671
+ * alone would let `src/escape.js -> /outside/secret.js` pull external content
672
+ * into an incremental update that a clean discovery excludes.
673
+ */
674
+ async function resolveChangedHintUpdates(rootDir, changedPaths, previousRecordsByPath) {
675
+ const upserts = new Map();
676
+ const removals = new Set();
677
+ const styleUpserts = new Set();
678
+ const styleRemovals = new Set();
679
+
680
+ let rootRealPath = null;
681
+ try {
682
+ rootRealPath = await fs.realpath(rootDir);
683
+ } catch {
684
+ // The root itself cannot be resolved: no hint is trustworthy.
685
+ return null;
686
+ }
687
+
688
+ for (const relativePath of changedPaths) {
689
+ if (!isSafeContainedRelative(rootDir, relativePath)) {
690
+ return null;
691
+ }
692
+
693
+ const absolutePath = path.join(rootDir, relativePath);
694
+ let stat = null;
695
+ try {
696
+ stat = await fs.stat(absolutePath);
697
+ } catch (error) {
698
+ if (error?.code !== 'ENOENT') {
699
+ return null;
700
+ }
701
+ }
702
+
703
+ if (stat && !stat.isFile()) {
704
+ return null;
705
+ }
706
+
707
+ if (stat) {
708
+ let realPath = null;
709
+ try {
710
+ realPath = await fs.realpath(absolutePath);
711
+ } catch {
712
+ // Vanished or unresolvable between stat and realpath: unknown state.
713
+ return null;
714
+ }
715
+ if (!isDiscoveryPathContained(rootRealPath, realPath)) {
716
+ return null;
717
+ }
718
+ }
719
+
720
+ const ext = path.extname(relativePath).toLowerCase();
721
+ if (!stat) {
722
+ // Missing file: a removal when the index knows it, a no-op otherwise.
723
+ if (previousRecordsByPath.has(relativePath)) {
724
+ removals.add(relativePath);
725
+ } else if (STYLE_EXTENSIONS.has(ext)) {
726
+ styleRemovals.add(relativePath);
727
+ }
728
+ continue;
729
+ }
730
+
731
+ if (TRACKED_EXTENSIONS.has(ext)) {
732
+ upserts.set(relativePath, {
733
+ filePath: relativePath,
734
+ domain: relativePath.split('/')[0] ?? 'other',
735
+ ext,
736
+ mtimeMs: Math.floor(stat.mtimeMs),
737
+ size: stat.size,
738
+ });
739
+ } else if (STYLE_EXTENSIONS.has(ext)) {
740
+ styleUpserts.add(relativePath);
741
+ }
742
+ // Any other extension is never discovered, so a full rebuild would ignore
743
+ // it too — the hint is a safe no-op.
744
+ }
745
+
746
+ return { upserts, removals, styleUpserts, styleRemovals };
747
+ }
748
+
749
+ async function tryIncrementalIndexUpdate({ absoluteRoot, indexDir, changedPaths, signal }) {
750
+ assertNotAborted(signal, 'incremental-stat');
751
+
752
+ const [
753
+ metaArtifact,
754
+ filesArtifact,
755
+ symbolsArtifact,
756
+ importsArtifact,
757
+ callsArtifact,
758
+ testsMapArtifact,
759
+ hotspotsArtifact,
760
+ archetypesArtifact,
761
+ relationsArtifact,
762
+ analogsArtifact,
763
+ ] = await Promise.all([
764
+ readArtifactIfExists(absoluteRoot, INDEX_ARTIFACTS.meta),
765
+ readArtifactIfExists(absoluteRoot, INDEX_ARTIFACTS.files),
766
+ readArtifactIfExists(absoluteRoot, INDEX_ARTIFACTS.symbols),
767
+ readArtifactIfExists(absoluteRoot, INDEX_ARTIFACTS.imports),
768
+ readArtifactIfExists(absoluteRoot, INDEX_ARTIFACTS.calls),
769
+ readArtifactIfExists(absoluteRoot, INDEX_ARTIFACTS.testsMap),
770
+ readArtifactIfExists(absoluteRoot, INDEX_ARTIFACTS.hotspots),
771
+ readArtifactIfExists(absoluteRoot, INDEX_ARTIFACTS.archetypes),
772
+ readArtifactIfExists(absoluteRoot, INDEX_ARTIFACTS.relations),
773
+ readArtifactIfExists(absoluteRoot, INDEX_ARTIFACTS.analogs),
774
+ ]);
775
+
776
+ // A schema version matching is not enough: the merge reuses these items
777
+ // verbatim, so each artifact must still be a well-formed item array whose
778
+ // entries belong to the files artifact, at the item count the meta artifact
779
+ // recorded when it was published. A schema-current but truncated
780
+ // symbols.json would otherwise cause the update to publish only the changed
781
+ // file's symbols and silently drop every unchanged file's.
782
+ const artifactItemCounts = {
783
+ [INDEX_ARTIFACTS.files]: filesArtifact?.items?.length ?? -1,
784
+ [INDEX_ARTIFACTS.symbols]: symbolsArtifact?.items?.length ?? -1,
785
+ [INDEX_ARTIFACTS.imports]: importsArtifact?.items?.length ?? -1,
786
+ [INDEX_ARTIFACTS.calls]: callsArtifact?.items?.length ?? -1,
787
+ [INDEX_ARTIFACTS.relations]: relationsArtifact?.items?.length ?? -1,
788
+ };
789
+ const previousArtifactsUsable = [
790
+ metaArtifact,
791
+ filesArtifact,
792
+ symbolsArtifact,
793
+ importsArtifact,
794
+ callsArtifact,
795
+ relationsArtifact,
796
+ ].every((artifact) => artifact?.schemaVersion === INDEX_SCHEMA_VERSION)
797
+ && Boolean(metaArtifact?.sourceFingerprint)
798
+ && Array.isArray(filesArtifact.items)
799
+ && filesArtifact.items.every((item) => isValidFileRecord(item))
800
+ && isValidCoveredItems(symbolsArtifact.items, filesArtifact.items, (item) => item.filePath)
801
+ && isValidCoveredItems(importsArtifact.items, filesArtifact.items, (item) => item.from)
802
+ && isValidCoveredItems(callsArtifact.items, filesArtifact.items, (item) => item.filePath)
803
+ && isValidCoveredItems(relationsArtifact.items, filesArtifact.items, (item) => item.filePath)
804
+ && Array.isArray(relationsArtifact?.sourceSnapshot?.styleFiles)
805
+ && artifactItemCountsMatch(metaArtifact, artifactItemCounts);
806
+ if (!previousArtifactsUsable) {
807
+ return null;
808
+ }
809
+
810
+ const previousRecordsByPath = new Map(filesArtifact.items.map((item) => [item.filePath, item]));
811
+ const hintUpdates = await resolveChangedHintUpdates(absoluteRoot, changedPaths, previousRecordsByPath);
812
+ if (!hintUpdates) {
813
+ return null;
814
+ }
815
+
816
+ // Merge the hint outcome into one sorted record set — the same shape and
817
+ // ordering the full discovery path produces.
818
+ const mergedRecordsByPath = new Map(previousRecordsByPath);
819
+ for (const removedPath of hintUpdates.removals) {
820
+ mergedRecordsByPath.delete(removedPath);
821
+ }
822
+ for (const [upsertedPath, record] of hintUpdates.upserts) {
823
+ mergedRecordsByPath.set(upsertedPath, record);
824
+ }
825
+ const mergedFileRecords = [...mergedRecordsByPath.values()]
826
+ .sort((a, b) => a.filePath.localeCompare(b.filePath));
827
+
828
+ const recordsChanged = !areFileRecordSnapshotsEqual(filesArtifact.items, mergedFileRecords);
829
+
830
+ // Only hinted code files whose on-disk identity actually moved are
831
+ // re-parsed; an unchanged hint must not rewrite anything.
832
+ const parsedFiles = [];
833
+ for (const [filePath, record] of hintUpdates.upserts) {
834
+ if (!CODE_EXTENSIONS.has(record.ext)) {
835
+ continue;
836
+ }
837
+ const previous = previousRecordsByPath.get(filePath);
838
+ const unchanged = previous
839
+ && Number(previous.mtimeMs ?? -1) === Number(record.mtimeMs)
840
+ && Number(previous.size ?? -1) === Number(record.size);
841
+ if (!unchanged) {
842
+ parsedFiles.push(filePath);
843
+ }
844
+ }
845
+ parsedFiles.sort((a, b) => a.localeCompare(b));
846
+
847
+ // Re-apply style hints to the persisted style list; a listed style file that
848
+ // vanished without a hint is real drift the update heals the same way a full
849
+ // rebuild would (by dropping it).
850
+ const styleSet = new Set(relationsArtifact.sourceSnapshot.styleFiles);
851
+ let styleListChanged = false;
852
+ for (const stylePath of hintUpdates.styleUpserts) {
853
+ if (!styleSet.has(stylePath)) {
854
+ styleSet.add(stylePath);
855
+ styleListChanged = true;
856
+ }
857
+ }
858
+ for (const stylePath of hintUpdates.styleRemovals) {
859
+ if (styleSet.has(stylePath)) {
860
+ styleSet.delete(stylePath);
861
+ styleListChanged = true;
862
+ }
863
+ }
864
+ const styleEntries = (await mapLimit([...styleSet].sort((a, b) => a.localeCompare(b)), INDEX_IO_CONCURRENCY, async (stylePath) => {
865
+ try {
866
+ const stat = await fs.stat(path.join(absoluteRoot, stylePath));
867
+ return {
868
+ absolutePath: path.join(absoluteRoot, stylePath),
869
+ mtimeMs: Math.floor(stat.mtimeMs),
870
+ size: stat.size,
871
+ };
872
+ } catch (error) {
873
+ if (error?.code === 'ENOENT') {
874
+ styleListChanged = true;
875
+ return null;
876
+ }
877
+ // Unknown stat failure: cannot reconstruct the fingerprint input.
878
+ return { statError: true };
879
+ }
880
+ }, signal, 'stat-style'));
881
+ if (styleEntries.some((entry) => entry?.statError)) {
882
+ return null;
883
+ }
884
+ const styleFilePaths = styleEntries.filter(Boolean).map((entry) => normalizeRelative(absoluteRoot, entry.absolutePath));
885
+
886
+ const previousSymbolsByPath = groupBy(symbolsArtifact.items ?? [], (item) => item.filePath);
887
+ const previousImportsByPath = groupBy(importsArtifact.items ?? [], (item) => item.from);
888
+ const previousCallsByPath = groupBy(callsArtifact.items ?? [], (item) => item.filePath);
889
+
890
+ const parsedSet = new Set(parsedFiles);
891
+ const codeFiles = mergedFileRecords.filter((record) => CODE_EXTENSIONS.has(record.ext));
892
+ // A removal (or an add) changes code-file membership even when nothing is
893
+ // re-parsed — the merged artifacts must drop (or carry) that file's items.
894
+ const codeIdentitiesChanged = !haveStableTrackedFileIdentities(
895
+ filesArtifact.items.filter((item) => CODE_EXTENSIONS.has(item?.ext)),
896
+ codeFiles,
897
+ );
898
+ const parsedArtifactsChanged = parsedFiles.length > 0 || codeIdentitiesChanged;
899
+ const symbols = [];
900
+ const imports = [];
901
+ const calls = [];
902
+ let reusedCodeFileCount = 0;
903
+
904
+ // Full-path ordering: reused files first (sorted), then newly parsed files
905
+ // (sorted), so a merged artifact carries the same item order a rebuild
906
+ // produces for the same state.
907
+ for (const file of codeFiles) {
908
+ if (parsedSet.has(file.filePath)) {
909
+ continue;
910
+ }
911
+ symbols.push(...(previousSymbolsByPath.get(file.filePath) ?? []));
912
+ imports.push(...(previousImportsByPath.get(file.filePath) ?? []));
913
+ calls.push(...(previousCallsByPath.get(file.filePath) ?? []));
914
+ reusedCodeFileCount += 1;
915
+ }
916
+
917
+ if (parsedFiles.length > 0) {
918
+ const parsed = await mapLimit(parsedFiles, INDEX_PARSE_BATCH_SIZE, async (filePath) => {
919
+ try {
920
+ const absolutePath = path.join(absoluteRoot, filePath);
921
+ const content = await fs.readFile(absolutePath, 'utf8');
922
+ const scriptContent = extractScriptContent(filePath, content);
923
+ return {
924
+ filePath,
925
+ symbols: extractSymbols(filePath, scriptContent),
926
+ imports: [
927
+ ...extractImports(filePath, scriptContent),
928
+ ...extractSupplementalImports(filePath, content),
929
+ ],
930
+ calls: extractFunctionCalls(filePath, scriptContent),
931
+ };
932
+ } catch {
933
+ return null;
934
+ }
935
+ }, signal, 'parse');
936
+
937
+ for (const parsedFile of parsed) {
938
+ if (!parsedFile) {
939
+ continue;
940
+ }
941
+ symbols.push(...parsedFile.symbols);
942
+ imports.push(...parsedFile.imports);
943
+ calls.push(...parsedFile.calls);
944
+ }
945
+ }
946
+
947
+ // Derived artifacts are recomputed in memory from the merged inputs — the
948
+ // expensive work being avoided is discovery and re-parsing, not these pure
949
+ // transformations, so their output stays identical to a clean rebuild.
950
+ const canReuseTestsMap = haveStableTrackedFileIdentities(filesArtifact.items, mergedFileRecords)
951
+ && testsMapArtifact?.schemaVersion === INDEX_SCHEMA_VERSION;
952
+ const testsMap = canReuseTestsMap ? testsMapArtifact.items : buildTestsMap(mergedFileRecords);
953
+
954
+ const bugIndexSnapshot = await readBugIndexSnapshot(absoluteRoot);
955
+ const canReuseHotspots = hotspotsArtifact?.schemaVersion === INDEX_SCHEMA_VERSION
956
+ && areBugIndexSnapshotsEqual(hotspotsArtifact?.sourceSnapshot, bugIndexSnapshot);
957
+ const hotspots = canReuseHotspots ? hotspotsArtifact.items : await buildHotspots(absoluteRoot, bugIndexSnapshot);
958
+
959
+ const archetypes = classifyArchetypes(mergedFileRecords, symbols);
960
+ const previousCodePaths = new Set(
961
+ filesArtifact.items.filter((item) => CODE_EXTENSIONS.has(item?.ext)).map((item) => item.filePath),
962
+ );
963
+ const currentCodePaths = new Set(codeFiles.map((file) => file.filePath));
964
+ const canReuseArchetypes = parsedFiles.length === 0
965
+ && !recordsChanged
966
+ && archetypesArtifact?.schemaVersion === INDEX_SCHEMA_VERSION
967
+ && codeFiles.every((file) => previousCodePaths.has(file.filePath))
968
+ && archetypesArtifact?.items?.length === archetypes.length
969
+ && (archetypesArtifact.items ?? []).every((item) => currentCodePaths.has(item?.filePath));
970
+ const archetypeItems = canReuseArchetypes ? archetypesArtifact.items : archetypes;
971
+
972
+ const needsAliasContext = importsNeedAliasContext(imports);
973
+ const importAliasState = needsAliasContext
974
+ ? await loadImportAliasContextState({ rootDir: absoluteRoot })
975
+ : null;
976
+ const importAliasContext = importAliasState?.context ?? null;
977
+ const styleSnapshot = {
978
+ styleFiles: styleFilePaths,
979
+ importAliasSnapshot: importAliasState?.snapshot ?? [],
980
+ };
981
+
982
+ const graphInputsChanged = parsedArtifactsChanged || styleListChanged;
983
+ const canReuseRelations = !graphInputsChanged
984
+ && areStringArraysEqual(relationsArtifact?.sourceSnapshot?.styleFiles, styleFilePaths)
985
+ && arePathSnapshotsEqual(relationsArtifact?.sourceSnapshot?.importAliasSnapshot, styleSnapshot.importAliasSnapshot);
986
+ const canReuseAnalogs = canReuseRelations
987
+ && analogsArtifact?.schemaVersion === INDEX_SCHEMA_VERSION;
988
+
989
+ let relations = relationsArtifact.items;
990
+ let analogs = analogsArtifact?.items ?? null;
991
+ if (!canReuseRelations || !canReuseAnalogs) {
992
+ const indexedFileSet = new Set(codeFiles.map((file) => file.filePath));
993
+ const resolvedImportsBySource = buildResolvedImportMap(absoluteRoot, imports, indexedFileSet, importAliasContext);
994
+ const resolvedStyleImportsBySource = buildResolvedImportMap(absoluteRoot, imports, new Set(styleFilePaths), importAliasContext);
995
+ if (!canReuseRelations) {
996
+ relations = buildRelations(mergedFileRecords, archetypeItems, testsMap, resolvedImportsBySource, resolvedStyleImportsBySource);
997
+ }
998
+ if (!canReuseAnalogs) {
999
+ analogs = buildAnalogs(mergedFileRecords, archetypeItems, relations, resolvedImportsBySource);
1000
+ }
1001
+ }
1002
+
1003
+ // The fingerprint covers the merged discovery view (tracked records +
1004
+ // persisted style list), so recomputing it detects a style file whose
1005
+ // content/mtime moved without changing list membership — something
1006
+ // `styleListChanged` alone cannot see, and which a clean full rebuild would
1007
+ // fold into `meta.sourceFingerprint`.
1008
+ const mergedDiscoveredEntries = [
1009
+ ...mergedFileRecords.map((record) => ({
1010
+ absolutePath: path.join(absoluteRoot, record.filePath),
1011
+ mtimeMs: record.mtimeMs,
1012
+ size: record.size,
1013
+ })),
1014
+ ...styleEntries.filter(Boolean),
1015
+ ];
1016
+ const refreshedFingerprint = createSourceFingerprint(absoluteRoot, mergedDiscoveredEntries);
1017
+ const fingerprintChanged = !areSourceFingerprintsEqual(metaArtifact.sourceFingerprint, refreshedFingerprint);
1018
+
1019
+ const stateChanged = recordsChanged
1020
+ || parsedFiles.length > 0
1021
+ || styleListChanged
1022
+ || fingerprintChanged;
1023
+ const generatedAt = new Date().toISOString();
1024
+
1025
+ // Same publish gate as the full path: an abort landing after parsing must
1026
+ // not replace any artifact.
1027
+ assertNotAborted(signal, 'publish');
1028
+ await fs.mkdir(indexDir, { recursive: true });
1029
+ if (recordsChanged) {
1030
+ await writeArtifact(absoluteRoot, INDEX_ARTIFACTS.files, {
1031
+ schemaVersion: INDEX_SCHEMA_VERSION,
1032
+ generatedAt,
1033
+ rootDir: absoluteRoot,
1034
+ items: mergedFileRecords,
1035
+ });
1036
+ }
1037
+ if (parsedArtifactsChanged) {
1038
+ await writeArtifact(absoluteRoot, INDEX_ARTIFACTS.symbols, {
1039
+ schemaVersion: INDEX_SCHEMA_VERSION,
1040
+ generatedAt,
1041
+ rootDir: absoluteRoot,
1042
+ items: symbols,
1043
+ });
1044
+ await writeArtifact(absoluteRoot, INDEX_ARTIFACTS.imports, {
1045
+ schemaVersion: INDEX_SCHEMA_VERSION,
1046
+ generatedAt,
1047
+ rootDir: absoluteRoot,
1048
+ items: imports,
1049
+ });
1050
+ await writeArtifact(absoluteRoot, INDEX_ARTIFACTS.calls, {
1051
+ schemaVersion: INDEX_SCHEMA_VERSION,
1052
+ generatedAt,
1053
+ rootDir: absoluteRoot,
1054
+ items: calls,
1055
+ });
1056
+ }
1057
+ if (!canReuseTestsMap) {
1058
+ await writeArtifact(absoluteRoot, INDEX_ARTIFACTS.testsMap, {
1059
+ schemaVersion: INDEX_SCHEMA_VERSION,
1060
+ generatedAt,
1061
+ rootDir: absoluteRoot,
1062
+ items: testsMap,
1063
+ });
1064
+ }
1065
+ if (!canReuseHotspots) {
1066
+ await writeArtifact(absoluteRoot, INDEX_ARTIFACTS.hotspots, {
1067
+ schemaVersion: INDEX_SCHEMA_VERSION,
1068
+ generatedAt,
1069
+ rootDir: absoluteRoot,
1070
+ sourceSnapshot: bugIndexSnapshot,
1071
+ items: hotspots,
1072
+ });
1073
+ }
1074
+ if (!canReuseArchetypes) {
1075
+ await writeArtifact(absoluteRoot, INDEX_ARTIFACTS.archetypes, {
1076
+ schemaVersion: INDEX_SCHEMA_VERSION,
1077
+ generatedAt,
1078
+ rootDir: absoluteRoot,
1079
+ items: archetypeItems,
1080
+ });
1081
+ }
1082
+ if (!canReuseRelations) {
1083
+ await writeArtifact(absoluteRoot, INDEX_ARTIFACTS.relations, {
1084
+ schemaVersion: INDEX_SCHEMA_VERSION,
1085
+ generatedAt,
1086
+ rootDir: absoluteRoot,
1087
+ sourceSnapshot: styleSnapshot,
1088
+ items: relations,
1089
+ });
1090
+ }
1091
+ if (!canReuseAnalogs) {
1092
+ await writeArtifact(absoluteRoot, INDEX_ARTIFACTS.analogs, {
1093
+ schemaVersion: INDEX_SCHEMA_VERSION,
1094
+ generatedAt,
1095
+ rootDir: absoluteRoot,
1096
+ sourceSnapshot: styleSnapshot,
1097
+ items: analogs,
1098
+ });
1099
+ }
1100
+ if (stateChanged) {
1101
+ // Refresh the fingerprint over the merged discovery view (tracked records
1102
+ // + persisted style list), so staleness detection agrees with a clean
1103
+ // rebuild of the same disk state.
1104
+ await writeArtifact(absoluteRoot, INDEX_ARTIFACTS.meta, {
1105
+ schemaVersion: INDEX_SCHEMA_VERSION,
1106
+ generatedAt,
1107
+ rootDir: absoluteRoot,
1108
+ sourceFingerprint: refreshedFingerprint,
1109
+ // Keep the truncation guard armed for the next incremental update: these
1110
+ // are the counts of the artifacts that now hold those items.
1111
+ artifactItemCounts: {
1112
+ [INDEX_ARTIFACTS.files]: mergedFileRecords.length,
1113
+ [INDEX_ARTIFACTS.symbols]: symbols.length,
1114
+ [INDEX_ARTIFACTS.imports]: imports.length,
1115
+ [INDEX_ARTIFACTS.calls]: calls.length,
1116
+ [INDEX_ARTIFACTS.relations]: relations.length,
1117
+ },
1118
+ });
1119
+ }
1120
+
1121
+ const preservedRuntimeCaches = !recordsChanged
1122
+ && !parsedArtifactsChanged
1123
+ && !styleListChanged
1124
+ && canReuseTestsMap
1125
+ && canReuseHotspots
1126
+ && canReuseArchetypes
1127
+ && canReuseRelations
1128
+ && canReuseAnalogs;
1129
+ if (!preservedRuntimeCaches) {
1130
+ clearIndexArtifactCache(absoluteRoot);
1131
+ clearRelatedTestArtifactCache(absoluteRoot);
1132
+ }
1133
+
1134
+ return {
1135
+ indexDir,
1136
+ generatedAt,
1137
+ generatedAtMs: Date.parse(generatedAt),
1138
+ fileCount: mergedFileRecords.length,
1139
+ symbolCount: symbols.length,
1140
+ importCount: imports.length,
1141
+ testsMapCount: testsMap.length,
1142
+ hotspotCount: hotspots.length,
1143
+ archetypeCount: archetypeItems.length,
1144
+ relationCount: relations.length,
1145
+ analogCount: analogs?.length ?? 0,
1146
+ parsedCodeFileCount: parsedFiles.length,
1147
+ reusedCodeFileCount,
1148
+ reusedTestsMap: canReuseTestsMap,
1149
+ reusedHotspots: canReuseHotspots,
1150
+ reusedRelations: canReuseRelations,
1151
+ reusedAnalogs: canReuseAnalogs,
1152
+ preservedRuntimeCaches,
1153
+ mode: 'incremental',
1154
+ parsedFiles,
1155
+ removedFiles: [...hintUpdates.removals].sort((a, b) => a.localeCompare(b)),
1156
+ };
1157
+ }
1158
+
1159
+ async function collectFiles(scanRoots, discovery = null) {
1160
+ const budget = discovery ?? createDiscoveryBudget({});
396
1161
  const result = [];
397
1162
  const stack = [...scanRoots];
1163
+ const rootPath = path.resolve(scanRoots[0]);
1164
+ let rootRealPath = null;
1165
+ try {
1166
+ rootRealPath = await resolveDiscoveryPath(rootPath, budget, 'realpath-root');
1167
+ } catch (error) {
1168
+ if (error instanceof IndexDiscoveryTimeoutError) {
1169
+ throw error;
1170
+ }
1171
+ }
1172
+
1173
+ const isInsideRoot = (realPath) => isDiscoveryPathContained(rootRealPath, realPath);
398
1174
  const visitedDirectoryRealPaths = new Set();
399
1175
 
400
1176
  while (stack.length > 0) {
1177
+ assertDiscoveryAlive(budget, 'walk');
401
1178
  const current = stack.pop();
402
1179
  let currentRealPath;
403
1180
  try {
404
- currentRealPath = await fs.realpath(current);
405
- } catch {
1181
+ currentRealPath = await resolveDiscoveryPath(current, budget, 'realpath');
1182
+ } catch (error) {
1183
+ if (error instanceof IndexDiscoveryTimeoutError) {
1184
+ throw error;
1185
+ }
1186
+ continue;
1187
+ }
1188
+
1189
+ if (!isInsideRoot(currentRealPath)) {
406
1190
  continue;
407
1191
  }
408
1192
 
@@ -413,8 +1197,15 @@ async function collectFiles(scanRoots) {
413
1197
 
414
1198
  let entries;
415
1199
  try {
416
- entries = await fs.readdir(current, { withFileTypes: true });
417
- } catch {
1200
+ entries = await resolveDiscoveryOperation(
1201
+ () => fs.readdir(current, { withFileTypes: true }),
1202
+ budget,
1203
+ 'readdir',
1204
+ );
1205
+ } catch (error) {
1206
+ if (error instanceof IndexDiscoveryTimeoutError) {
1207
+ throw error;
1208
+ }
418
1209
  continue;
419
1210
  }
420
1211
 
@@ -430,15 +1221,35 @@ async function collectFiles(scanRoots) {
430
1221
  directDirectories.push(fullPath);
431
1222
  } else if (entry.isSymbolicLink()) {
432
1223
  try {
433
- const stat = await fs.stat(fullPath);
1224
+ const stat = await resolveDiscoveryOperation(
1225
+ () => fs.stat(fullPath),
1226
+ budget,
1227
+ 'stat-symlink',
1228
+ );
434
1229
  if (stat.isDirectory() && !shouldSkipDirectory(entry.name)) {
1230
+ // Containment is re-checked from the link's realpath when the
1231
+ // directory is popped, so an escaping symlinked subtree is pruned.
435
1232
  symlinkDirectories.push(fullPath);
436
1233
  continue;
437
1234
  }
438
1235
 
439
1236
  if (stat.isFile()) {
440
1237
  const ext = path.extname(entry.name).toLowerCase();
441
- if (DISCOVERED_EXTENSIONS.has(ext)) {
1238
+ if (!DISCOVERED_EXTENSIONS.has(ext)) {
1239
+ continue;
1240
+ }
1241
+ // A symlinked FILE is indexed only when its real path stays inside
1242
+ // the root; the link itself may point anywhere on disk.
1243
+ let realFilePath = null;
1244
+ try {
1245
+ realFilePath = await resolveDiscoveryPath(fullPath, budget, 'realpath-symlink');
1246
+ } catch (error) {
1247
+ if (error instanceof IndexDiscoveryTimeoutError) {
1248
+ throw error;
1249
+ }
1250
+ realFilePath = null;
1251
+ }
1252
+ if (isInsideRoot(realFilePath)) {
442
1253
  symlinkFiles.push({
443
1254
  absolutePath: fullPath,
444
1255
  mtimeMs: Math.floor(stat.mtimeMs),
@@ -446,7 +1257,13 @@ async function collectFiles(scanRoots) {
446
1257
  });
447
1258
  }
448
1259
  }
449
- } catch {
1260
+ } catch (error) {
1261
+ // A stalled stat/realpath past the shared deadline must surface as the
1262
+ // typed timeout, not be misclassified as a broken symlink and resolve
1263
+ // to silently truncated discovery.
1264
+ if (error instanceof IndexDiscoveryTimeoutError) {
1265
+ throw error;
1266
+ }
450
1267
  // broken symlink, skip
451
1268
  }
452
1269
  }
@@ -460,19 +1277,26 @@ async function collectFiles(scanRoots) {
460
1277
  const ext = path.extname(entry.name).toLowerCase();
461
1278
  return DISCOVERED_EXTENSIONS.has(ext);
462
1279
  });
463
- const fileStats = (await Promise.all(fileEntries.map(async (entry) => {
1280
+ const fileStats = (await mapLimit(fileEntries, INDEX_IO_CONCURRENCY, async (entry) => {
464
1281
  try {
465
1282
  const fullPath = path.join(current, entry.name);
466
- const stat = await fs.stat(fullPath);
1283
+ const stat = await resolveDiscoveryOperation(
1284
+ () => fs.stat(fullPath),
1285
+ budget,
1286
+ 'stat-walk',
1287
+ );
467
1288
  return {
468
1289
  absolutePath: fullPath,
469
1290
  mtimeMs: Math.floor(stat.mtimeMs),
470
1291
  size: stat.size,
471
1292
  };
472
- } catch {
1293
+ } catch (error) {
1294
+ if (error instanceof IndexDiscoveryTimeoutError) {
1295
+ throw error;
1296
+ }
473
1297
  return null;
474
1298
  }
475
- }))).filter(Boolean);
1299
+ }, budget.signal, 'stat-walk')).filter(Boolean);
476
1300
  result.push(...fileStats, ...symlinkFiles);
477
1301
  }
478
1302
 
@@ -1072,42 +1896,152 @@ function areBugIndexSnapshotsEqual(previousSnapshot, nextSnapshot) {
1072
1896
  *
1073
1897
  * Non-git projects fall back to walking the root with the same exclusion rules.
1074
1898
  */
1075
- async function discoverProjectFiles(rootDir) {
1076
- const gitFiles = await collectGitTrackedFiles(rootDir);
1899
+ /**
1900
+ * Discover project files under ONE top-level wall-clock deadline.
1901
+ *
1902
+ * @param {string} rootDir - project root.
1903
+ * @param {{ signal?: AbortSignal|null, deadlineMs?: number }} [options]
1904
+ * `deadlineMs` is a single ceiling divided across both git enumerations and
1905
+ * the non-git fallback walk — not a per-call timeout. `signal` cancels early
1906
+ * and surfaces through the same typed error. A non-positive or non-finite
1907
+ * `deadlineMs` means "no budget": discovery throws immediately.
1908
+ * @returns {Promise<Array<{absolutePath: string, mtimeMs: number, size: number}>>}
1909
+ * @throws {IndexDiscoveryTimeoutError} when the deadline expires or `signal` aborts.
1910
+ */
1911
+ export async function discoverProjectFiles(rootDir, { signal = null, deadlineMs = DEFAULT_DISCOVERY_DEADLINE_MS } = {}) {
1912
+ const budget = createDiscoveryBudget({ signal, deadlineMs });
1913
+ assertDiscoveryAlive(budget, 'start');
1914
+ const gitFiles = await collectGitTrackedFiles(rootDir, budget);
1077
1915
  if (gitFiles) {
1078
1916
  return gitFiles;
1079
1917
  }
1080
1918
 
1081
- return collectFiles([rootDir]);
1919
+ return collectFiles([rootDir], budget);
1920
+ }
1921
+
1922
+ function createDiscoveryBudget({ signal = null, deadlineMs = DEFAULT_DISCOVERY_DEADLINE_MS } = {}) {
1923
+ const effectiveDeadlineMs = Number.isFinite(deadlineMs) && deadlineMs > 0 ? deadlineMs : 0;
1924
+ const startedAt = Date.now();
1925
+ return {
1926
+ signal,
1927
+ deadlineMs: effectiveDeadlineMs,
1928
+ startedAt,
1929
+ deadline: startedAt + effectiveDeadlineMs,
1930
+ };
1931
+ }
1932
+
1933
+ function assertDiscoveryAlive({ signal, deadlineMs, deadline, startedAt }, phase) {
1934
+ const elapsedMs = Date.now() - startedAt;
1935
+ if (signal?.aborted) {
1936
+ throw new IndexDiscoveryTimeoutError({ deadlineMs, phase, elapsedMs, aborted: true });
1937
+ }
1938
+ if (Date.now() >= deadline) {
1939
+ throw new IndexDiscoveryTimeoutError({ deadlineMs, phase, elapsedMs });
1940
+ }
1941
+ }
1942
+
1943
+ function discoveryOperationTimeout(budget, phase) {
1944
+ assertDiscoveryAlive(budget, phase);
1945
+ return Math.max(1, Math.ceil(remainingDiscoveryMs(budget)));
1946
+ }
1947
+
1948
+ async function resolveDiscoveryOperation(operation, budget, phase) {
1949
+ const timeoutMs = discoveryOperationTimeout(budget, phase);
1950
+ let timer;
1951
+ const timeout = new Promise((_, reject) => {
1952
+ timer = setTimeout(() => {
1953
+ reject(new IndexDiscoveryTimeoutError({
1954
+ deadlineMs: budget.deadlineMs,
1955
+ phase,
1956
+ elapsedMs: Date.now() - budget.startedAt,
1957
+ }));
1958
+ }, timeoutMs);
1959
+ });
1960
+ try {
1961
+ return await Promise.race([Promise.resolve().then(operation), timeout]);
1962
+ } finally {
1963
+ clearTimeout(timer);
1964
+ }
1965
+ }
1966
+
1967
+ async function resolveDiscoveryPath(filePath, budget, phase) {
1968
+ return resolveDiscoveryOperation(() => fs.realpath(filePath), budget, phase);
1969
+ }
1970
+
1971
+ function remainingDiscoveryMs({ deadline }) {
1972
+ return deadline - Date.now();
1973
+ }
1974
+
1975
+ function isDiscoveryPathContained(rootRealPath, realPath) {
1976
+ if (!rootRealPath || !realPath) {
1977
+ return false;
1978
+ }
1979
+ if (realPath === rootRealPath) {
1980
+ return true;
1981
+ }
1982
+ const relative = path.relative(rootRealPath, realPath);
1983
+ return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative);
1082
1984
  }
1083
1985
 
1084
1986
  function createDiscoverySnapshot(rootDir, files, fingerprint) {
1085
- // Frozen: the snapshot must stay byte-stable between the staleness inspection
1086
- // and the build that consumes it, and callers must not mutate shared state.
1987
+ // Deep-frozen, and each file entry is cloned into a fresh frozen record: the
1988
+ // snapshot must stay byte-stable between the staleness inspection and the
1989
+ // build that consumes it, and a caller that keeps a reference must not be able
1990
+ // to redirect the build by mutating `entry.absolutePath` after inspection.
1087
1991
  return Object.freeze({
1088
1992
  snapshotVersion: DISCOVERY_SNAPSHOT_VERSION,
1089
1993
  schemaVersion: INDEX_SCHEMA_VERSION,
1090
1994
  rootDir,
1091
- fingerprint,
1092
- files: Object.freeze(files.slice()),
1995
+ fingerprint: Object.freeze({ ...fingerprint }),
1996
+ files: Object.freeze(files.map((entry) => Object.freeze({
1997
+ absolutePath: entry?.absolutePath,
1998
+ mtimeMs: entry?.mtimeMs,
1999
+ size: entry?.size,
2000
+ }))),
1093
2001
  });
1094
2002
  }
1095
2003
 
2004
+ function isSnapshotPathContained(rootDir, absolutePath) {
2005
+ if (typeof absolutePath !== 'string' || absolutePath === '' || !path.isAbsolute(absolutePath)) {
2006
+ return false;
2007
+ }
2008
+ const resolved = path.resolve(absolutePath);
2009
+ // A file entry can never be the root directory itself.
2010
+ if (resolved === rootDir) {
2011
+ return false;
2012
+ }
2013
+ const relative = path.relative(rootDir, resolved);
2014
+ return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative);
2015
+ }
2016
+
1096
2017
  function isDiscoverySnapshotUsable(discoverySnapshot, absoluteRoot) {
1097
2018
  return Boolean(discoverySnapshot)
1098
2019
  && discoverySnapshot.snapshotVersion === DISCOVERY_SNAPSHOT_VERSION
1099
2020
  && discoverySnapshot.schemaVersion === INDEX_SCHEMA_VERSION
1100
2021
  && typeof discoverySnapshot.rootDir === 'string'
1101
2022
  && path.resolve(discoverySnapshot.rootDir) === absoluteRoot
1102
- && Array.isArray(discoverySnapshot.files);
2023
+ && Array.isArray(discoverySnapshot.files)
2024
+ // Fail closed: a snapshot is reusable only when EVERY entry is an absolute
2025
+ // path contained by the snapshotted root. A mutated, relative, or foreign
2026
+ // entry makes the whole snapshot untrusted — the repository is rediscovered
2027
+ // once instead of letting caller-supplied paths steer the build.
2028
+ && discoverySnapshot.files.every((entry) => Boolean(entry)
2029
+ && typeof entry === 'object'
2030
+ && isSnapshotPathContained(absoluteRoot, entry.absolutePath));
1103
2031
  }
1104
2032
 
1105
- function runGit(rootDir, args) {
2033
+ function runGit(rootDir, args, budgetMs) {
2034
+ // spawnSync treats `timeout: 0` as "no timeout", so an exhausted budget must
2035
+ // short-circuit here instead of ever reaching an unbounded git call.
2036
+ if (!(budgetMs > 0)) {
2037
+ return null;
2038
+ }
2039
+
1106
2040
  const result = spawnSync('git', args, {
1107
2041
  cwd: rootDir,
1108
2042
  encoding: 'utf8',
1109
2043
  maxBuffer: 256 * 1024 * 1024,
1110
- timeout: GIT_ENUMERATION_TIMEOUT_MS,
2044
+ timeout: Math.ceil(budgetMs),
1111
2045
  windowsHide: true,
1112
2046
  });
1113
2047
 
@@ -1122,14 +2056,20 @@ function runGit(rootDir, args) {
1122
2056
  * @returns {Promise<Array<{absolutePath: string, mtimeMs: number, size: number}>|null>}
1123
2057
  * `null` when this is not a usable git worktree, so the caller can fall back.
1124
2058
  */
1125
- async function collectGitTrackedFiles(rootDir) {
2059
+ async function collectGitTrackedFiles(rootDir, discovery = null) {
2060
+ const budget = discovery ?? createDiscoveryBudget({});
2061
+ assertDiscoveryAlive(budget, 'git');
1126
2062
  // `-z` matters beyond separators: it also disables git's path quoting, which
1127
2063
  // would otherwise mangle non-ASCII filenames.
1128
- const tracked = runGit(rootDir, ['ls-files', '-z']);
2064
+ const tracked = runGit(rootDir, ['ls-files', '-z'], remainingDiscoveryMs(budget));
1129
2065
  if (tracked === null) {
1130
2066
  return null;
1131
2067
  }
1132
- const untracked = runGit(rootDir, ['ls-files', '-z', '--others', '--exclude-standard']) ?? '';
2068
+ // Both enumerations draw from the same budget: a first call that hung pays
2069
+ // the whole ceiling, and the second call (and the fallback walk after it)
2070
+ // only ever gets the remaining time.
2071
+ assertDiscoveryAlive(budget, 'git');
2072
+ const untracked = runGit(rootDir, ['ls-files', '-z', '--others', '--exclude-standard'], remainingDiscoveryMs(budget)) ?? '';
1133
2073
 
1134
2074
  const relativePaths = new Set();
1135
2075
  for (const entry of `${tracked}${untracked}`.split('\0')) {
@@ -1151,23 +2091,50 @@ async function collectGitTrackedFiles(rootDir) {
1151
2091
  return !relativePath.split('/').some((segment) => EXCLUDED_DIR_NAMES.has(segment));
1152
2092
  });
1153
2093
 
1154
- const entries = await Promise.all(candidates.map(async (relativePath) => {
2094
+ // Fixed pool instead of one unbounded promise per candidate: peak in-flight
2095
+ // stats stay at INDEX_IO_CONCURRENCY for a listing of any size, and an
2096
+ // aborted signal stops the fan-out with queued candidates unstat'ed.
2097
+ let rootRealPath;
2098
+ try {
2099
+ rootRealPath = await resolveDiscoveryPath(path.resolve(rootDir), budget, 'realpath-root');
2100
+ } catch (error) {
2101
+ if (error instanceof IndexDiscoveryTimeoutError) {
2102
+ throw error;
2103
+ }
2104
+ return null;
2105
+ }
2106
+
2107
+ const entries = await mapLimit(candidates, INDEX_IO_CONCURRENCY, async (relativePath) => {
1155
2108
  const absolutePath = path.join(rootDir, relativePath);
1156
2109
  try {
1157
- const stat = await fs.stat(absolutePath);
2110
+ const stat = await resolveDiscoveryOperation(
2111
+ () => fs.stat(absolutePath),
2112
+ budget,
2113
+ 'stat-git',
2114
+ );
1158
2115
  if (!stat.isFile()) {
1159
2116
  return null;
1160
2117
  }
2118
+ // fs.stat follows symlinks. Resolve the final target before accepting a
2119
+ // tracked path so Git cannot make an external file enter the index merely
2120
+ // by tracking an escaping symlink.
2121
+ const realPath = await resolveDiscoveryPath(absolutePath, budget, 'realpath-git');
2122
+ if (!isDiscoveryPathContained(rootRealPath, realPath)) {
2123
+ return null;
2124
+ }
1161
2125
  return {
1162
2126
  absolutePath,
1163
2127
  mtimeMs: Math.floor(stat.mtimeMs),
1164
2128
  size: stat.size,
1165
2129
  };
1166
- } catch {
1167
- // staged deletion, or a symlink pointing outside the worktree
2130
+ } catch (error) {
2131
+ if (error instanceof IndexDiscoveryTimeoutError) {
2132
+ throw error;
2133
+ }
2134
+ // staged deletion, broken symlink, or an unreadable path
1168
2135
  return null;
1169
2136
  }
1170
- }));
2137
+ }, budget.signal, 'stat-git');
1171
2138
 
1172
2139
  return entries.filter(Boolean);
1173
2140
  }
@@ -1841,7 +2808,7 @@ export async function getIndexArtifactGeneratedAt({
1841
2808
  return parseArtifactGeneratedAt(artifact);
1842
2809
  }
1843
2810
 
1844
- async function evaluateIndexStaleness({ rootDir, maxAgeMs, now, generatedAtMs, enumerateForSnapshot }) {
2811
+ async function evaluateIndexStaleness({ rootDir, maxAgeMs, now, generatedAtMs, enumerateForSnapshot, signal = null, deadlineMs = DEFAULT_DISCOVERY_DEADLINE_MS }) {
1845
2812
  const absoluteRoot = path.resolve(rootDir);
1846
2813
  const metaArtifact = await readArtifactIfExists(absoluteRoot, INDEX_ARTIFACTS.meta);
1847
2814
  const effectiveGeneratedAtMs = Number.isFinite(generatedAtMs)
@@ -1864,7 +2831,7 @@ async function evaluateIndexStaleness({ rootDir, maxAgeMs, now, generatedAtMs, e
1864
2831
  // Exactly one enumeration per inspection. In the refresh flow this snapshot is
1865
2832
  // handed to buildCodeIndex, so stale-check + rebuild enumerates the repository
1866
2833
  // once instead of twice.
1867
- const discoveredFiles = await discoverProjectFiles(absoluteRoot);
2834
+ const discoveredFiles = await discoverProjectFiles(absoluteRoot, { signal, deadlineMs });
1868
2835
  const fingerprint = createSourceFingerprint(absoluteRoot, discoveredFiles);
1869
2836
 
1870
2837
  return {
@@ -1880,8 +2847,10 @@ export async function inspectIndexStaleness({
1880
2847
  maxAgeMs = DEFAULT_INDEX_CACHE_MAX_AGE_MS,
1881
2848
  now = Date.now(),
1882
2849
  generatedAtMs = null,
2850
+ signal = null,
2851
+ deadlineMs = DEFAULT_DISCOVERY_DEADLINE_MS,
1883
2852
  } = {}) {
1884
- return evaluateIndexStaleness({ rootDir, maxAgeMs, now, generatedAtMs, enumerateForSnapshot: true });
2853
+ return evaluateIndexStaleness({ rootDir, maxAgeMs, now, generatedAtMs, enumerateForSnapshot: true, signal, deadlineMs });
1885
2854
  }
1886
2855
 
1887
2856
  export async function isIndexStale({
@@ -1889,7 +2858,9 @@ export async function isIndexStale({
1889
2858
  maxAgeMs = DEFAULT_INDEX_CACHE_MAX_AGE_MS,
1890
2859
  now = Date.now(),
1891
2860
  generatedAtMs = null,
2861
+ signal = null,
2862
+ deadlineMs = DEFAULT_DISCOVERY_DEADLINE_MS,
1892
2863
  } = {}) {
1893
- const { stale } = await evaluateIndexStaleness({ rootDir, maxAgeMs, now, generatedAtMs, enumerateForSnapshot: false });
2864
+ const { stale } = await evaluateIndexStaleness({ rootDir, maxAgeMs, now, generatedAtMs, enumerateForSnapshot: false, signal, deadlineMs });
1894
2865
  return stale;
1895
2866
  }