@ngockhoale/ukit 2.4.1 → 2.4.3

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 (56) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/manifests/platform.full.yaml +19 -111
  3. package/package.json +2 -1
  4. package/scripts/index/refresh-index.mjs +48 -18
  5. package/src/cli/commands/doctor.js +59 -2
  6. package/src/core/compact/threshold.js +36 -6
  7. package/src/core/gatewayProbe.js +143 -15
  8. package/src/core/gatewayResilienceEnv.js +136 -7
  9. package/src/diagnostics/classifyHang.js +246 -0
  10. package/src/index/buildIndex.js +1096 -75
  11. package/templates/.claude/hooks/auto-allow-bash.sh +99 -87
  12. package/templates/.claude/hooks/auto-prune-bash.sh +4 -0
  13. package/templates/.claude/hooks/block-dangerous.sh +46 -1
  14. package/templates/.claude/hooks/completion-gate.sh +65 -7
  15. package/templates/.claude/hooks/compress-output.sh +49 -2
  16. package/templates/.claude/hooks/context-hardcap-gate.sh +52 -4
  17. package/templates/.claude/hooks/context-window-guard.sh +204 -71
  18. package/templates/.claude/hooks/handoff-model-guard.sh +50 -3
  19. package/templates/.claude/hooks/handoff-resume.sh +47 -3
  20. package/templates/.claude/hooks/post-edit-verify.sh +45 -2
  21. package/templates/.claude/hooks/pre-edit-backup.sh +45 -2
  22. package/templates/.claude/hooks/protect-files.sh +46 -1
  23. package/templates/.claude/hooks/record-execution.sh +46 -2
  24. package/templates/.claude/hooks/reinject-context.sh +1 -1
  25. package/templates/.claude/hooks/reset-compact-pressure.sh +4 -0
  26. package/templates/.claude/hooks/sensitive-data-guard.sh +101 -18
  27. package/templates/.claude/hooks/skill-router.sh +59 -5
  28. package/templates/.claude/hooks/stale-spec-guard.sh +47 -2
  29. package/templates/.claude/hooks/task-watchdog.sh +129 -126
  30. package/templates/.claude/hooks/verification-guard.sh +136 -106
  31. package/templates/.claude/hooks/vision-router.sh +138 -18
  32. package/templates/.claude/settings.json +0 -5
  33. package/templates/.claude/ukit/index/lib/index-core.mjs +1027 -68
  34. package/templates/.claude/ukit/index/post-edit-verify.mjs +8 -0
  35. package/templates/.claude/ukit/index/pre-edit-backup.mjs +8 -0
  36. package/templates/.claude/ukit/index/refresh-index.mjs +48 -18
  37. package/templates/.claude/ukit/index/route-task.mjs +610 -4
  38. package/templates/.claude/ukit/index/stale-spec-check.mjs +8 -0
  39. package/templates/.claude/ukit/runtime/async-lock.mjs +340 -0
  40. package/templates/.claude/ukit/runtime/compact-threshold.mjs +73 -24
  41. package/templates/.claude/ukit/runtime/context-capacity.mjs +144 -0
  42. package/templates/.claude/ukit/runtime/execution-ledger.mjs +672 -170
  43. package/templates/.claude/ukit/runtime/hook-chain-budget.mjs +92 -0
  44. package/templates/.claude/ukit/runtime/hook-chain-runner.mjs +84 -29
  45. package/templates/.claude/ukit/runtime/hook-input.mjs +120 -0
  46. package/templates/.claude/ukit/runtime/hook-input.sh +140 -0
  47. package/templates/.claude/ukit/runtime/hook-payload-store.mjs +160 -0
  48. package/templates/.claude/ukit/runtime/hook-process.mjs +250 -0
  49. package/templates/.claude/ukit/runtime/hook-telemetry.mjs +255 -0
  50. package/templates/.claude/ukit/runtime/hook-telemetry.sh +60 -0
  51. package/templates/.claude/ukit/runtime/output-compression.mjs +8 -0
  52. package/templates/.claude/ukit/runtime/reinject-context.mjs +8 -0
  53. package/templates/.claude/ukit/runtime/stop-coordinator.mjs +509 -0
  54. package/templates/.claude/ukit/runtime/task-watchdog.mjs +180 -6
  55. package/templates/.claude/ukit/runtime/transcript-tail.mjs +107 -0
  56. package/templates/.omp/hooks/pre/ukit-bridge.js +171 -57
@@ -3,7 +3,93 @@ import path from 'node:path';
3
3
  import crypto from 'node:crypto';
4
4
  import { spawnSync } from 'node:child_process';
5
5
 
6
- const GIT_ENUMERATION_TIMEOUT_MS = 15_000;
6
+ // One wall-clock ceiling for the WHOLE discovery phase: both `git ls-files`
7
+ // enumerations AND the non-git fallback walk share this single budget. Every
8
+ // stage receives only the remaining budget and expiry throws a typed
9
+ // IndexDiscoveryTimeoutError instead of degrading silently. Callers may tighten
10
+ // it with `deadlineMs` or cancel early with `signal` on
11
+ // `discoverProjectFiles(root, { signal, deadlineMs })`.
12
+ export const DEFAULT_DISCOVERY_DEADLINE_MS = 30_000;
13
+
14
+ // Typed liveness failure for project discovery: deadline expired or the
15
+ // caller's AbortSignal fired — never a silent partial result.
16
+ export class IndexDiscoveryTimeoutError extends Error {
17
+ constructor({ deadlineMs, phase, elapsedMs, aborted = false }) {
18
+ super(aborted
19
+ ? `Index discovery was aborted ${elapsedMs}ms into its ${deadlineMs}ms deadline (during ${phase}).`
20
+ : `Index discovery exceeded its ${deadlineMs}ms wall-clock deadline (elapsed ${elapsedMs}ms, during ${phase}).`);
21
+ this.name = 'IndexDiscoveryTimeoutError';
22
+ this.code = 'INDEX_DISCOVERY_TIMEOUT';
23
+ this.deadlineMs = deadlineMs;
24
+ this.phase = phase;
25
+ this.elapsedMs = elapsedMs;
26
+ this.aborted = aborted;
27
+ }
28
+ }
29
+
30
+ // Upper bound on in-flight filesystem stat calls during discovery: git
31
+ // candidate stats and the non-git fallback walk share this fixed pool.
32
+ // The previous `Promise.all(candidates.map(stat))` produced one unbounded
33
+ // promise per candidate, so a 10k-file listing held 10k concurrent stats
34
+ // (descriptors + scheduler pressure) at once. With the pool, peak in-flight
35
+ // I/O stays flat no matter the repository size.
36
+ export const INDEX_IO_CONCURRENCY = 64;
37
+
38
+ // Typed cancellation failure for build phases beyond discovery (stat pools,
39
+ // file reads/parse, artifact publish) when the caller's AbortSignal fires.
40
+ // Discovery keeps its own deadline/abort classification in
41
+ // IndexDiscoveryTimeoutError; this error reports explicit caller cancellation
42
+ // of the rest of the pipeline.
43
+ export class IndexCancelledError extends Error {
44
+ constructor({ phase } = {}) {
45
+ super(`Index work was cancelled by its AbortSignal (during ${phase}).`);
46
+ this.name = 'IndexCancelledError';
47
+ this.code = 'INDEX_CANCELLED';
48
+ this.phase = phase;
49
+ }
50
+ }
51
+
52
+ // Fixed-concurrency worker pool over `items`. At most `limit` workers run at
53
+ // once, so in-flight I/O never scales with the item count. Each worker
54
+ // re-checks `signal` BEFORE claiming its next item, so an abort leaves queued
55
+ // work unstarted and the promise rejects with IndexCancelledError. A worker
56
+ // rejection maps that one item to `null` while the remaining items still
57
+ // process — a single failing stat/read can never hang or kill the pool.
58
+ // Returns results in `items` order (null for failed items).
59
+ export async function mapLimit(items, limit, worker, signal = null, phase = 'map') {
60
+ const concurrency = Math.max(1, Math.floor(Number(limit)) || 1);
61
+ const results = new Array(items.length);
62
+ let nextIndex = 0;
63
+
64
+ const runWorker = async () => {
65
+ while (nextIndex < items.length) {
66
+ if (signal?.aborted) {
67
+ throw new IndexCancelledError({ phase });
68
+ }
69
+ const index = nextIndex;
70
+ nextIndex += 1;
71
+ try {
72
+ results[index] = await worker(items[index], index);
73
+ } catch (error) {
74
+ if (error instanceof IndexDiscoveryTimeoutError || error instanceof IndexCancelledError) {
75
+ throw error;
76
+ }
77
+ results[index] = null;
78
+ }
79
+ }
80
+ };
81
+
82
+ await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, () => runWorker()));
83
+ return results;
84
+ }
85
+
86
+ // Gate for phases between discovery and publish: abort means nothing is written.
87
+ function assertNotAborted(signal, phase) {
88
+ if (signal?.aborted) {
89
+ throw new IndexCancelledError({ phase });
90
+ }
91
+ }
92
+
7
93
  const EXCLUDED_DIR_NAMES = new Set([
8
94
  'node_modules',
9
95
  '.git',
@@ -30,7 +116,9 @@ const TRACKED_EXTENSIONS = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx'
30
116
  const DISCOVERED_EXTENSIONS = new Set([...TRACKED_EXTENSIONS, ...STYLE_EXTENSIONS]);
31
117
  export const INDEX_SCHEMA_VERSION = 8;
32
118
  export const DEFAULT_INDEX_CACHE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
33
- const INDEX_PARSE_BATCH_SIZE = 8;
119
+ // Parse-phase read/extract concurrency — deliberately tighter than the
120
+ // discovery stat pool because parsing holds file content in memory.
121
+ export const INDEX_PARSE_BATCH_SIZE = 8;
34
122
  const MAX_IMPORTER_HOPS = 2;
35
123
  const CALL_IGNORE_WORDS = new Set([
36
124
  'if', 'for', 'while', 'switch', 'catch', 'function', 'return', 'typeof',
@@ -118,10 +206,30 @@ const VIETNAMESE_TOKEN_ALIASES = new Map([
118
206
 
119
207
  // ── Build Index ──
120
208
 
121
- export async function buildCodeIndex({ rootDir = process.cwd() } = {}) {
209
+ // Bumped only when the snapshot shape itself changes; consumers must reject
210
+ // snapshots from a different version instead of trusting unknown fields.
211
+ export const DISCOVERY_SNAPSHOT_VERSION = 1;
212
+
213
+ export async function buildCodeIndex({ rootDir = process.cwd(), changedFiles = null, discoverySnapshot = null, signal = null, deadlineMs = DEFAULT_DISCOVERY_DEADLINE_MS } = {}) {
122
214
  const absoluteRoot = path.resolve(rootDir);
123
215
  const indexDir = getIndexDir(absoluteRoot);
124
216
 
217
+ // Incremental fast path (TASK-023): validated changed-file hints let an
218
+ // existing, schema-current index absorb the change set without a full
219
+ // repository discovery. Every unsafe hint, and every previous artifact that
220
+ // cannot be merged, falls back to the bounded full path below — correctness
221
+ // wins over the optimization, and incremental output must stay equivalent to
222
+ // a clean full rebuild.
223
+ const changedHintPlan = normalizeChangedFileHints(changedFiles);
224
+ if (changedHintPlan) {
225
+ const incremental = changedHintPlan.ok
226
+ ? await tryIncrementalIndexUpdate({ absoluteRoot, indexDir, changedPaths: changedHintPlan.paths, signal })
227
+ : null;
228
+ if (incremental) {
229
+ return incremental;
230
+ }
231
+ }
232
+
125
233
  const previousFilesArtifact = await readArtifactIfExists(absoluteRoot, INDEX_ARTIFACTS.files);
126
234
  const canReusePrevious = previousFilesArtifact?.schemaVersion === INDEX_SCHEMA_VERSION;
127
235
  const previousCodeFileRecords = canReusePrevious
@@ -132,7 +240,15 @@ export async function buildCodeIndex({ rootDir = process.cwd() } = {}) {
132
240
  previousCodeFileRecords.map((item) => [item.filePath, { mtimeMs: Number(item.mtimeMs ?? -1), size: Number(item.size ?? -1) }]),
133
241
  );
134
242
 
135
- const discoveredFiles = await discoverProjectFiles(absoluteRoot);
243
+ // Reuse the caller's discovery snapshot when it describes exactly this root
244
+ // under the current schema and snapshot version; anything else (foreign root,
245
+ // stale schema, wrong shape) is rejected and the repository is rediscovered
246
+ // once here. Keeps a stale-check + rebuild refresh at a single enumeration.
247
+ const discoveredFiles = isDiscoverySnapshotUsable(discoverySnapshot, absoluteRoot)
248
+ ? discoverySnapshot.files
249
+ // Discovery precedes every artifact write, so a deadline expiry here aborts
250
+ // the build before any prior index artifact can be replaced.
251
+ : await discoverProjectFiles(absoluteRoot, { signal, deadlineMs });
136
252
  const sourceFingerprint = createSourceFingerprint(absoluteRoot, discoveredFiles);
137
253
  const styleFilePaths = discoveredFiles
138
254
  .map((entry) => {
@@ -211,32 +327,34 @@ export async function buildCodeIndex({ rootDir = process.cwd() } = {}) {
211
327
  }
212
328
  const canReuseAllParsedArtifacts = canReuseParsedArtifacts && filesToParse.length === 0 && reusableCodeFiles.length === codeFiles.length;
213
329
 
214
- for (let start = 0; start < filesToParse.length; start += INDEX_PARSE_BATCH_SIZE) {
215
- const batch = filesToParse.slice(start, start + INDEX_PARSE_BATCH_SIZE);
216
- const parsedBatch = (await Promise.all(batch.map(async (filePath) => {
217
- try {
218
- const absolutePath = path.join(absoluteRoot, filePath);
219
- const content = await fs.readFile(absolutePath, 'utf8');
220
- const scriptContent = extractScriptContent(filePath, content);
221
- return {
222
- filePath,
223
- symbols: extractSymbols(filePath, scriptContent),
224
- imports: [
225
- ...extractImports(filePath, scriptContent),
226
- ...extractSupplementalImports(filePath, content),
227
- ],
228
- calls: extractFunctionCalls(filePath, scriptContent),
229
- };
230
- } catch {
231
- return null;
232
- }
233
- }))).filter(Boolean);
234
-
235
- for (const parsedFile of parsedBatch) {
236
- symbols.push(...parsedFile.symbols);
237
- imports.push(...parsedFile.imports);
238
- calls.push(...parsedFile.calls);
330
+ // Parse through the same fixed pool as discovery stats: reads stay bounded
331
+ // by INDEX_PARSE_BATCH_SIZE and the caller's signal is re-checked before
332
+ // every new file, so an abort stops the phase with queued files unread.
333
+ // Results come back in filesToParse order, matching the previous batch loop.
334
+ const parsedFiles = await mapLimit(filesToParse, INDEX_PARSE_BATCH_SIZE, async (filePath) => {
335
+ try {
336
+ const absolutePath = path.join(absoluteRoot, filePath);
337
+ const content = await fs.readFile(absolutePath, 'utf8');
338
+ const scriptContent = extractScriptContent(filePath, content);
339
+ return {
340
+ filePath,
341
+ symbols: extractSymbols(filePath, scriptContent),
342
+ imports: [
343
+ ...extractImports(filePath, scriptContent),
344
+ ...extractSupplementalImports(filePath, content),
345
+ ],
346
+ calls: extractFunctionCalls(filePath, scriptContent),
347
+ };
348
+ } catch {
349
+ return null;
239
350
  }
351
+ }, signal, 'parse');
352
+
353
+ for (const parsedFile of parsedFiles) {
354
+ if (!parsedFile) continue;
355
+ symbols.push(...parsedFile.symbols);
356
+ imports.push(...parsedFile.imports);
357
+ calls.push(...parsedFile.calls);
240
358
  }
241
359
 
242
360
  const canReuseTestsMapCandidate = canReusePrevious
@@ -334,6 +452,10 @@ export async function buildCodeIndex({ rootDir = process.cwd() } = {}) {
334
452
 
335
453
  const generatedAt = new Date().toISOString();
336
454
 
455
+ // No partial artifact publish: an abort landing after parsing (during the
456
+ // artifact-graph phases above) must not replace any artifact either, so the
457
+ // whole write block sits behind this gate.
458
+ assertNotAborted(signal, 'publish');
337
459
  await fs.mkdir(indexDir, { recursive: true });
338
460
  if (!canReuseFilesArtifact) {
339
461
  await writeArtifact(absoluteRoot, INDEX_ARTIFACTS.files, { schemaVersion: INDEX_SCHEMA_VERSION, generatedAt, items: fileRecords });
@@ -358,7 +480,20 @@ export async function buildCodeIndex({ rootDir = process.cwd() } = {}) {
358
480
  if (!canReuseAnalogs) {
359
481
  await writeArtifact(absoluteRoot, INDEX_ARTIFACTS.analogs, { schemaVersion: INDEX_SCHEMA_VERSION, generatedAt, sourceSnapshot: styleSnapshot, items: analogs });
360
482
  }
361
- await writeArtifact(absoluteRoot, INDEX_ARTIFACTS.meta, { schemaVersion: INDEX_SCHEMA_VERSION, generatedAt, sourceFingerprint });
483
+ await writeArtifact(absoluteRoot, INDEX_ARTIFACTS.meta, {
484
+ schemaVersion: INDEX_SCHEMA_VERSION,
485
+ generatedAt,
486
+ sourceFingerprint,
487
+ // Item counts let a later incremental update prove the artifacts it reuses
488
+ // were not truncated between builds (TASK-023 review, important #2).
489
+ artifactItemCounts: {
490
+ [INDEX_ARTIFACTS.files]: fileRecords.length,
491
+ [INDEX_ARTIFACTS.symbols]: symbols.length,
492
+ [INDEX_ARTIFACTS.imports]: imports.length,
493
+ [INDEX_ARTIFACTS.calls]: calls.length,
494
+ [INDEX_ARTIFACTS.relations]: relations.length,
495
+ },
496
+ });
362
497
  const preservedRuntimeCaches = canReuseFilesArtifact
363
498
  && canReuseAllParsedArtifacts
364
499
  && canReuseTestsMap
@@ -390,6 +525,582 @@ export async function buildCodeIndex({ rootDir = process.cwd() } = {}) {
390
525
  reusedRelations: canReuseRelations,
391
526
  reusedAnalogs: canReuseAnalogs,
392
527
  preservedRuntimeCaches,
528
+ mode: 'full',
529
+ parsedFiles: filesToParse.slice(),
530
+ removedFiles: [],
531
+ };
532
+ }
533
+
534
+ // ── Incremental refresh (TASK-023) ──
535
+ //
536
+ // `changedFiles` hints let refresh skip repository discovery entirely: the
537
+ // caller (a watcher or git-status driven hook) asserts which paths changed and
538
+ // the update merges exactly those paths into the existing artifacts. The path
539
+ // is taken only when EVERY gate holds:
540
+ // - every hint is a safe project-relative path (no absolute form, no `..`
541
+ // segment, no NUL byte), deduplicated and normalized;
542
+ // - the previous meta/files/symbols/imports/calls/relations artifacts all
543
+ // carry the current schema version and a source fingerprint to refresh;
544
+ // - every stat and read resolves unambiguously (ENOENT means deletion,
545
+ // anything else means "unknown" and falls back to full discovery);
546
+ // - every existing hint still resolves (through `realpath`) to a target
547
+ // inside the resolved root, so a symlink cannot pull external content into
548
+ // the index that a clean discovery would have excluded;
549
+ // - every reused artifact is a well-formed item array whose entries stay
550
+ // contained in the files artifact, so a truncated artifact cannot make the
551
+ // merge publish a partial artifact.
552
+ // Style files need one extra gate: discovery lists them but files.json never
553
+ // tracks them, so the previous style list only survives inside the relations
554
+ // artifact's source snapshot — without it the refreshed fingerprint could not
555
+ // match a clean rebuild.
556
+
557
+ function normalizeChangedFileHints(changedFiles) {
558
+ if (!Array.isArray(changedFiles) || changedFiles.length === 0) {
559
+ return null;
560
+ }
561
+
562
+ const paths = [];
563
+ const seen = new Set();
564
+ for (const raw of changedFiles) {
565
+ if (typeof raw !== 'string' || raw.includes('\0')) {
566
+ return { ok: false, paths: [] };
567
+ }
568
+ const trimmed = raw.trim();
569
+ if (!trimmed) {
570
+ continue;
571
+ }
572
+ // An absolute hint (POSIX or Windows drive form) can never describe a
573
+ // project-relative path, and a `..` segment cannot be trusted to stay
574
+ // contained — either rejects the whole hint set so the safe full
575
+ // discovery path runs instead.
576
+ if (path.isAbsolute(trimmed) || /^[A-Za-z]:[\\/]/.test(trimmed)) {
577
+ return { ok: false, paths: [] };
578
+ }
579
+ const segments = trimmed.split(/[\\/]+/);
580
+ if (segments.some((segment) => segment === '..')) {
581
+ return { ok: false, paths: [] };
582
+ }
583
+ const normalized = segments.filter((segment) => segment && segment !== '.').join('/');
584
+ if (!normalized || seen.has(normalized)) {
585
+ continue;
586
+ }
587
+ seen.add(normalized);
588
+ paths.push(normalized);
589
+ }
590
+
591
+ if (paths.length === 0) {
592
+ // Hints were requested but none survived validation: treat the request as
593
+ // ambiguous instead of silently doing nothing.
594
+ return { ok: false, paths: [] };
595
+ }
596
+ return { ok: true, paths };
597
+ }
598
+
599
+ function isSafeContainedRelative(rootDir, relativePath) {
600
+ if (!relativePath || path.isAbsolute(relativePath)) {
601
+ return false;
602
+ }
603
+ // Round-trip containment check: joining and re-relativizing must return the
604
+ // exact hint, which rules out separator or prefix surprises.
605
+ return path.relative(rootDir, path.join(rootDir, relativePath)) === relativePath;
606
+ }
607
+
608
+ function isSafeArtifactItem(item) {
609
+ return Boolean(item)
610
+ && typeof item === 'object'
611
+ && !Array.isArray(item)
612
+ && isSafeArtifactItemPath(item.filePath);
613
+ }
614
+
615
+ function isSafeArtifactItemPath(relativePath) {
616
+ return typeof relativePath === 'string'
617
+ && relativePath !== ''
618
+ && !path.isAbsolute(relativePath)
619
+ && !relativePath.split(/[\\/]+/).some((segment) => segment === '..');
620
+ }
621
+
622
+ function isValidFileRecord(item) {
623
+ return isSafeArtifactItem(item)
624
+ && typeof item.domain === 'string'
625
+ && typeof item.ext === 'string'
626
+ && Number.isFinite(Number(item.mtimeMs))
627
+ && Number.isFinite(Number(item.size));
628
+ }
629
+
630
+ // The merge reuses every item of these artifacts verbatim and groups them by
631
+ // the covered file path. They are only safe to reuse when the array is present
632
+ // and every entry is a well-formed item whose covered path the files artifact
633
+ // still tracks — a malformed or foreign entry falls back to the bounded full
634
+ // path instead of publishing a partial artifact.
635
+ function isValidCoveredItems(items, fileRecords, keyFn) {
636
+ if (!Array.isArray(items)) {
637
+ return false;
638
+ }
639
+ const trackedPaths = new Set(fileRecords.map((record) => record.filePath));
640
+ return items.every((item) => {
641
+ if (!item || typeof item !== 'object' || Array.isArray(item)) {
642
+ return false;
643
+ }
644
+ const key = keyFn(item);
645
+ return isSafeArtifactItemPath(key) && trackedPaths.has(key);
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
+ function artifactItemCountsMatch(metaArtifact, counts) {
656
+ const recorded = metaArtifact?.artifactItemCounts;
657
+ if (!recorded || typeof recorded !== 'object' || Array.isArray(recorded)) {
658
+ return false;
659
+ }
660
+ return Object.entries(counts).every(([name, count]) => Number(recorded[name]) === count);
661
+ }
662
+
663
+ // Stat every hinted path and classify it as an upsert, a removal, or — for
664
+ // extensions discovery never indexes — a no-op. Returns null when a hint
665
+ // cannot be resolved unambiguously so the caller falls back to full discovery.
666
+ //
667
+ // Containment is re-checked against the RESOLVED root for every hint that still
668
+ // exists: `fs.stat` and `fs.readFile` both follow symlinks, so the lexical check
669
+ // alone would let `src/escape.js -> /outside/secret.js` pull external content
670
+ // into an incremental update that a clean discovery excludes.
671
+ async function resolveChangedHintUpdates(rootDir, changedPaths, previousRecordsByPath) {
672
+ const upserts = new Map();
673
+ const removals = new Set();
674
+ const styleUpserts = new Set();
675
+ const styleRemovals = new Set();
676
+
677
+ let rootRealPath = null;
678
+ try {
679
+ rootRealPath = await fs.realpath(rootDir);
680
+ } catch {
681
+ // The root itself cannot be resolved: no hint is trustworthy.
682
+ return null;
683
+ }
684
+
685
+ for (const relativePath of changedPaths) {
686
+ if (!isSafeContainedRelative(rootDir, relativePath)) {
687
+ return null;
688
+ }
689
+
690
+ const absolutePath = path.join(rootDir, relativePath);
691
+ let stat = null;
692
+ try {
693
+ stat = await fs.stat(absolutePath);
694
+ } catch (error) {
695
+ if (error?.code !== 'ENOENT') {
696
+ return null;
697
+ }
698
+ }
699
+
700
+ if (stat && !stat.isFile()) {
701
+ return null;
702
+ }
703
+
704
+ if (stat) {
705
+ let realPath = null;
706
+ try {
707
+ realPath = await fs.realpath(absolutePath);
708
+ } catch {
709
+ // Vanished or unresolvable between stat and realpath: unknown state.
710
+ return null;
711
+ }
712
+ if (!isDiscoveryPathContained(rootRealPath, realPath)) {
713
+ return null;
714
+ }
715
+ }
716
+
717
+ const ext = path.extname(relativePath).toLowerCase();
718
+ if (!stat) {
719
+ // Missing file: a removal when the index knows it, a no-op otherwise.
720
+ if (previousRecordsByPath.has(relativePath)) {
721
+ removals.add(relativePath);
722
+ } else if (STYLE_EXTENSIONS.has(ext)) {
723
+ styleRemovals.add(relativePath);
724
+ }
725
+ continue;
726
+ }
727
+
728
+ if (TRACKED_EXTENSIONS.has(ext)) {
729
+ upserts.set(relativePath, {
730
+ filePath: relativePath,
731
+ domain: relativePath.split('/')[0] ?? 'other',
732
+ ext,
733
+ mtimeMs: Math.floor(stat.mtimeMs),
734
+ size: stat.size,
735
+ });
736
+ } else if (STYLE_EXTENSIONS.has(ext)) {
737
+ styleUpserts.add(relativePath);
738
+ }
739
+ // Any other extension is never discovered, so a full rebuild would ignore
740
+ // it too — the hint is a safe no-op.
741
+ }
742
+
743
+ return { upserts, removals, styleUpserts, styleRemovals };
744
+ }
745
+
746
+ async function tryIncrementalIndexUpdate({ absoluteRoot, indexDir, changedPaths, signal }) {
747
+ assertNotAborted(signal, 'incremental-stat');
748
+
749
+ const [
750
+ metaArtifact,
751
+ filesArtifact,
752
+ symbolsArtifact,
753
+ importsArtifact,
754
+ callsArtifact,
755
+ testsMapArtifact,
756
+ hotspotsArtifact,
757
+ archetypesArtifact,
758
+ relationsArtifact,
759
+ analogsArtifact,
760
+ ] = await Promise.all([
761
+ readArtifactIfExists(absoluteRoot, INDEX_ARTIFACTS.meta),
762
+ readArtifactIfExists(absoluteRoot, INDEX_ARTIFACTS.files),
763
+ readArtifactIfExists(absoluteRoot, INDEX_ARTIFACTS.symbols),
764
+ readArtifactIfExists(absoluteRoot, INDEX_ARTIFACTS.imports),
765
+ readArtifactIfExists(absoluteRoot, INDEX_ARTIFACTS.calls),
766
+ readArtifactIfExists(absoluteRoot, INDEX_ARTIFACTS.testsMap),
767
+ readArtifactIfExists(absoluteRoot, INDEX_ARTIFACTS.hotspots),
768
+ readArtifactIfExists(absoluteRoot, INDEX_ARTIFACTS.archetypes),
769
+ readArtifactIfExists(absoluteRoot, INDEX_ARTIFACTS.relations),
770
+ readArtifactIfExists(absoluteRoot, INDEX_ARTIFACTS.analogs),
771
+ ]);
772
+
773
+ // A schema version matching is not enough: the merge reuses these items
774
+ // verbatim, so each artifact must still be a well-formed item array whose
775
+ // entries belong to the files artifact, at the item count the meta artifact
776
+ // recorded when it was published. A schema-current but truncated
777
+ // symbols.json would otherwise cause the update to publish only the changed
778
+ // file's symbols and silently drop every unchanged file's.
779
+ const artifactItemCounts = {
780
+ [INDEX_ARTIFACTS.files]: filesArtifact?.items?.length ?? -1,
781
+ [INDEX_ARTIFACTS.symbols]: symbolsArtifact?.items?.length ?? -1,
782
+ [INDEX_ARTIFACTS.imports]: importsArtifact?.items?.length ?? -1,
783
+ [INDEX_ARTIFACTS.calls]: callsArtifact?.items?.length ?? -1,
784
+ [INDEX_ARTIFACTS.relations]: relationsArtifact?.items?.length ?? -1,
785
+ };
786
+ const previousArtifactsUsable = [
787
+ metaArtifact,
788
+ filesArtifact,
789
+ symbolsArtifact,
790
+ importsArtifact,
791
+ callsArtifact,
792
+ relationsArtifact,
793
+ ].every((artifact) => artifact?.schemaVersion === INDEX_SCHEMA_VERSION)
794
+ && Boolean(metaArtifact?.sourceFingerprint)
795
+ && Array.isArray(filesArtifact.items)
796
+ && filesArtifact.items.every((item) => isValidFileRecord(item))
797
+ && isValidCoveredItems(symbolsArtifact.items, filesArtifact.items, (item) => item.filePath)
798
+ && isValidCoveredItems(importsArtifact.items, filesArtifact.items, (item) => item.from)
799
+ && isValidCoveredItems(callsArtifact.items, filesArtifact.items, (item) => item.filePath)
800
+ && isValidCoveredItems(relationsArtifact.items, filesArtifact.items, (item) => item.filePath)
801
+ && Array.isArray(relationsArtifact?.sourceSnapshot?.styleFiles)
802
+ && artifactItemCountsMatch(metaArtifact, artifactItemCounts);
803
+ if (!previousArtifactsUsable) {
804
+ return null;
805
+ }
806
+
807
+ const previousRecordsByPath = new Map(filesArtifact.items.map((item) => [item.filePath, item]));
808
+ const hintUpdates = await resolveChangedHintUpdates(absoluteRoot, changedPaths, previousRecordsByPath);
809
+ if (!hintUpdates) {
810
+ return null;
811
+ }
812
+
813
+ // Merge the hint outcome into one sorted record set — the same shape and
814
+ // ordering the full discovery path produces.
815
+ const mergedRecordsByPath = new Map(previousRecordsByPath);
816
+ for (const removedPath of hintUpdates.removals) {
817
+ mergedRecordsByPath.delete(removedPath);
818
+ }
819
+ for (const [upsertedPath, record] of hintUpdates.upserts) {
820
+ mergedRecordsByPath.set(upsertedPath, record);
821
+ }
822
+ const mergedFileRecords = [...mergedRecordsByPath.values()]
823
+ .sort((a, b) => a.filePath.localeCompare(b.filePath));
824
+
825
+ const recordsChanged = !areFileRecordSnapshotsEqual(filesArtifact.items, mergedFileRecords);
826
+
827
+ // Only hinted code files whose on-disk identity actually moved are
828
+ // re-parsed; an unchanged hint must not rewrite anything.
829
+ const parsedFiles = [];
830
+ for (const [filePath, record] of hintUpdates.upserts) {
831
+ if (!CODE_EXTENSIONS.has(record.ext)) {
832
+ continue;
833
+ }
834
+ const previous = previousRecordsByPath.get(filePath);
835
+ const unchanged = previous
836
+ && Number(previous.mtimeMs ?? -1) === Number(record.mtimeMs)
837
+ && Number(previous.size ?? -1) === Number(record.size);
838
+ if (!unchanged) {
839
+ parsedFiles.push(filePath);
840
+ }
841
+ }
842
+ parsedFiles.sort((a, b) => a.localeCompare(b));
843
+
844
+ // Re-apply style hints to the persisted style list; a listed style file that
845
+ // vanished without a hint is real drift the update heals the same way a full
846
+ // rebuild would (by dropping it).
847
+ const styleSet = new Set(relationsArtifact.sourceSnapshot.styleFiles);
848
+ let styleListChanged = false;
849
+ for (const stylePath of hintUpdates.styleUpserts) {
850
+ if (!styleSet.has(stylePath)) {
851
+ styleSet.add(stylePath);
852
+ styleListChanged = true;
853
+ }
854
+ }
855
+ for (const stylePath of hintUpdates.styleRemovals) {
856
+ if (styleSet.has(stylePath)) {
857
+ styleSet.delete(stylePath);
858
+ styleListChanged = true;
859
+ }
860
+ }
861
+ const styleEntries = (await mapLimit([...styleSet].sort((a, b) => a.localeCompare(b)), INDEX_IO_CONCURRENCY, async (stylePath) => {
862
+ try {
863
+ const stat = await fs.stat(path.join(absoluteRoot, stylePath));
864
+ return {
865
+ absolutePath: path.join(absoluteRoot, stylePath),
866
+ mtimeMs: Math.floor(stat.mtimeMs),
867
+ size: stat.size,
868
+ };
869
+ } catch (error) {
870
+ if (error?.code === 'ENOENT') {
871
+ styleListChanged = true;
872
+ return null;
873
+ }
874
+ // Unknown stat failure: cannot reconstruct the fingerprint input.
875
+ return { statError: true };
876
+ }
877
+ }, signal, 'stat-style'));
878
+ if (styleEntries.some((entry) => entry?.statError)) {
879
+ return null;
880
+ }
881
+ const styleFilePaths = styleEntries.filter(Boolean).map((entry) => normalizeRelative(absoluteRoot, entry.absolutePath));
882
+
883
+ const previousSymbolsByPath = groupBy(symbolsArtifact.items ?? [], (item) => item.filePath);
884
+ const previousImportsByPath = groupBy(importsArtifact.items ?? [], (item) => item.from);
885
+ const previousCallsByPath = groupBy(callsArtifact.items ?? [], (item) => item.filePath);
886
+
887
+ const parsedSet = new Set(parsedFiles);
888
+ const codeFiles = mergedFileRecords.filter((record) => CODE_EXTENSIONS.has(record.ext));
889
+ // A removal (or an add) changes code-file membership even when nothing is
890
+ // re-parsed — the merged artifacts must drop (or carry) that file's items.
891
+ const codeIdentitiesChanged = !haveStableTrackedFileIdentities(
892
+ filesArtifact.items.filter((item) => CODE_EXTENSIONS.has(item?.ext)),
893
+ codeFiles,
894
+ );
895
+ const parsedArtifactsChanged = parsedFiles.length > 0 || codeIdentitiesChanged;
896
+ const symbols = [];
897
+ const imports = [];
898
+ const calls = [];
899
+ let reusedCodeFileCount = 0;
900
+
901
+ // Full-path ordering: reused files first (sorted), then newly parsed files
902
+ // (sorted), so a merged artifact carries the same item order a rebuild
903
+ // produces for the same state.
904
+ for (const file of codeFiles) {
905
+ if (parsedSet.has(file.filePath)) {
906
+ continue;
907
+ }
908
+ symbols.push(...(previousSymbolsByPath.get(file.filePath) ?? []));
909
+ imports.push(...(previousImportsByPath.get(file.filePath) ?? []));
910
+ calls.push(...(previousCallsByPath.get(file.filePath) ?? []));
911
+ reusedCodeFileCount += 1;
912
+ }
913
+
914
+ if (parsedFiles.length > 0) {
915
+ const parsed = await mapLimit(parsedFiles, INDEX_PARSE_BATCH_SIZE, async (filePath) => {
916
+ try {
917
+ const absolutePath = path.join(absoluteRoot, filePath);
918
+ const content = await fs.readFile(absolutePath, 'utf8');
919
+ const scriptContent = extractScriptContent(filePath, content);
920
+ return {
921
+ filePath,
922
+ symbols: extractSymbols(filePath, scriptContent),
923
+ imports: [
924
+ ...extractImports(filePath, scriptContent),
925
+ ...extractSupplementalImports(filePath, content),
926
+ ],
927
+ calls: extractFunctionCalls(filePath, scriptContent),
928
+ };
929
+ } catch {
930
+ return null;
931
+ }
932
+ }, signal, 'parse');
933
+
934
+ for (const parsedFile of parsed) {
935
+ if (!parsedFile) {
936
+ continue;
937
+ }
938
+ symbols.push(...parsedFile.symbols);
939
+ imports.push(...parsedFile.imports);
940
+ calls.push(...parsedFile.calls);
941
+ }
942
+ }
943
+
944
+ // Derived artifacts are recomputed in memory from the merged inputs — the
945
+ // expensive work being avoided is discovery and re-parsing, not these pure
946
+ // transformations, so their output stays identical to a clean rebuild.
947
+ const canReuseTestsMap = haveStableTrackedFileIdentities(filesArtifact.items, mergedFileRecords)
948
+ && testsMapArtifact?.schemaVersion === INDEX_SCHEMA_VERSION;
949
+ const testsMap = canReuseTestsMap ? testsMapArtifact.items : buildTestsMap(mergedFileRecords);
950
+
951
+ const bugIndexSnapshot = await readBugIndexSnapshot(absoluteRoot);
952
+ const canReuseHotspots = hotspotsArtifact?.schemaVersion === INDEX_SCHEMA_VERSION
953
+ && areBugIndexSnapshotsEqual(hotspotsArtifact?.sourceSnapshot, bugIndexSnapshot);
954
+ const hotspots = canReuseHotspots ? hotspotsArtifact.items : await buildHotspots(absoluteRoot, bugIndexSnapshot);
955
+
956
+ const archetypes = classifyArchetypes(mergedFileRecords, symbols);
957
+ const previousCodePaths = new Set(
958
+ filesArtifact.items.filter((item) => CODE_EXTENSIONS.has(item?.ext)).map((item) => item.filePath),
959
+ );
960
+ const currentCodePaths = new Set(codeFiles.map((file) => file.filePath));
961
+ const canReuseArchetypes = parsedFiles.length === 0
962
+ && !recordsChanged
963
+ && archetypesArtifact?.schemaVersion === INDEX_SCHEMA_VERSION
964
+ && codeFiles.every((file) => previousCodePaths.has(file.filePath))
965
+ && archetypesArtifact?.items?.length === archetypes.length
966
+ && (archetypesArtifact.items ?? []).every((item) => currentCodePaths.has(item?.filePath));
967
+ const archetypeItems = canReuseArchetypes ? archetypesArtifact.items : archetypes;
968
+
969
+ const needsAliasContext = importsNeedAliasContext(imports);
970
+ const importAliasState = needsAliasContext
971
+ ? await loadImportAliasContextState({ rootDir: absoluteRoot })
972
+ : null;
973
+ const importAliasContext = importAliasState?.context ?? null;
974
+ const styleSnapshot = {
975
+ styleFiles: styleFilePaths,
976
+ importAliasSnapshot: importAliasState?.snapshot ?? [],
977
+ };
978
+
979
+ const graphInputsChanged = parsedArtifactsChanged || styleListChanged;
980
+ const canReuseRelations = !graphInputsChanged
981
+ && areStringArraysEqual(relationsArtifact?.sourceSnapshot?.styleFiles, styleFilePaths)
982
+ && arePathSnapshotsEqual(relationsArtifact?.sourceSnapshot?.importAliasSnapshot, styleSnapshot.importAliasSnapshot);
983
+ const canReuseAnalogs = canReuseRelations
984
+ && analogsArtifact?.schemaVersion === INDEX_SCHEMA_VERSION;
985
+
986
+ let relations = relationsArtifact.items;
987
+ let analogs = analogsArtifact?.items ?? null;
988
+ if (!canReuseRelations || !canReuseAnalogs) {
989
+ const indexedFileSet = new Set(codeFiles.map((file) => file.filePath));
990
+ const resolvedImportsBySource = buildResolvedImportMap(absoluteRoot, imports, indexedFileSet, importAliasContext);
991
+ const resolvedStyleImportsBySource = buildResolvedImportMap(absoluteRoot, imports, new Set(styleFilePaths), importAliasContext);
992
+ if (!canReuseRelations) {
993
+ relations = buildRelations(mergedFileRecords, archetypeItems, testsMap, resolvedImportsBySource, resolvedStyleImportsBySource);
994
+ }
995
+ if (!canReuseAnalogs) {
996
+ analogs = buildAnalogs(mergedFileRecords, archetypeItems, relations, resolvedImportsBySource);
997
+ }
998
+ }
999
+
1000
+ // The fingerprint covers the merged discovery view (tracked records +
1001
+ // persisted style list), so recomputing it detects a style file whose
1002
+ // content/mtime moved without changing list membership — something
1003
+ // `styleListChanged` alone cannot see, and which a clean full rebuild would
1004
+ // fold into `meta.sourceFingerprint`.
1005
+ const mergedDiscoveredEntries = [
1006
+ ...mergedFileRecords.map((record) => ({
1007
+ absolutePath: path.join(absoluteRoot, record.filePath),
1008
+ mtimeMs: record.mtimeMs,
1009
+ size: record.size,
1010
+ })),
1011
+ ...styleEntries.filter(Boolean),
1012
+ ];
1013
+ const refreshedFingerprint = createSourceFingerprint(absoluteRoot, mergedDiscoveredEntries);
1014
+ const fingerprintChanged = !areSourceFingerprintsEqual(metaArtifact.sourceFingerprint, refreshedFingerprint);
1015
+
1016
+ const stateChanged = recordsChanged
1017
+ || parsedFiles.length > 0
1018
+ || styleListChanged
1019
+ || fingerprintChanged;
1020
+ const generatedAt = new Date().toISOString();
1021
+
1022
+ // Same publish gate as the full path: an abort landing after parsing must
1023
+ // not replace any artifact.
1024
+ assertNotAborted(signal, 'publish');
1025
+ await fs.mkdir(indexDir, { recursive: true });
1026
+ if (recordsChanged) {
1027
+ await writeArtifact(absoluteRoot, INDEX_ARTIFACTS.files, { schemaVersion: INDEX_SCHEMA_VERSION, generatedAt, items: mergedFileRecords });
1028
+ }
1029
+ if (parsedArtifactsChanged) {
1030
+ await writeArtifact(absoluteRoot, INDEX_ARTIFACTS.symbols, { schemaVersion: INDEX_SCHEMA_VERSION, generatedAt, items: symbols });
1031
+ await writeArtifact(absoluteRoot, INDEX_ARTIFACTS.imports, { schemaVersion: INDEX_SCHEMA_VERSION, generatedAt, items: imports });
1032
+ await writeArtifact(absoluteRoot, INDEX_ARTIFACTS.calls, { schemaVersion: INDEX_SCHEMA_VERSION, generatedAt, items: calls });
1033
+ }
1034
+ if (!canReuseTestsMap) {
1035
+ await writeArtifact(absoluteRoot, INDEX_ARTIFACTS.testsMap, { schemaVersion: INDEX_SCHEMA_VERSION, generatedAt, items: testsMap });
1036
+ }
1037
+ if (!canReuseHotspots) {
1038
+ await writeArtifact(absoluteRoot, INDEX_ARTIFACTS.hotspots, { schemaVersion: INDEX_SCHEMA_VERSION, generatedAt, sourceSnapshot: bugIndexSnapshot, items: hotspots });
1039
+ }
1040
+ if (!canReuseArchetypes) {
1041
+ await writeArtifact(absoluteRoot, INDEX_ARTIFACTS.archetypes, { schemaVersion: INDEX_SCHEMA_VERSION, generatedAt, items: archetypeItems });
1042
+ }
1043
+ if (!canReuseRelations) {
1044
+ await writeArtifact(absoluteRoot, INDEX_ARTIFACTS.relations, { schemaVersion: INDEX_SCHEMA_VERSION, generatedAt, sourceSnapshot: styleSnapshot, items: relations });
1045
+ }
1046
+ if (!canReuseAnalogs) {
1047
+ await writeArtifact(absoluteRoot, INDEX_ARTIFACTS.analogs, { schemaVersion: INDEX_SCHEMA_VERSION, generatedAt, sourceSnapshot: styleSnapshot, items: analogs });
1048
+ }
1049
+ if (stateChanged) {
1050
+ // Refresh the fingerprint over the merged discovery view (tracked records
1051
+ // + persisted style list), so staleness detection agrees with a clean
1052
+ // rebuild of the same disk state.
1053
+ await writeArtifact(absoluteRoot, INDEX_ARTIFACTS.meta, {
1054
+ schemaVersion: INDEX_SCHEMA_VERSION,
1055
+ generatedAt,
1056
+ sourceFingerprint: refreshedFingerprint,
1057
+ // Keep the truncation guard armed for the next incremental update: these
1058
+ // are the counts of the artifacts that now hold those items.
1059
+ artifactItemCounts: {
1060
+ [INDEX_ARTIFACTS.files]: mergedFileRecords.length,
1061
+ [INDEX_ARTIFACTS.symbols]: symbols.length,
1062
+ [INDEX_ARTIFACTS.imports]: imports.length,
1063
+ [INDEX_ARTIFACTS.calls]: calls.length,
1064
+ [INDEX_ARTIFACTS.relations]: relations.length,
1065
+ },
1066
+ });
1067
+ }
1068
+
1069
+ const preservedRuntimeCaches = !recordsChanged
1070
+ && !parsedArtifactsChanged
1071
+ && !styleListChanged
1072
+ && canReuseTestsMap
1073
+ && canReuseHotspots
1074
+ && canReuseArchetypes
1075
+ && canReuseRelations
1076
+ && canReuseAnalogs;
1077
+ if (!preservedRuntimeCaches) {
1078
+ clearIndexArtifactCache(absoluteRoot);
1079
+ RELATED_TEST_LOOKUP_CACHE.delete(absoluteRoot);
1080
+ }
1081
+
1082
+ return {
1083
+ indexDir,
1084
+ generatedAt,
1085
+ generatedAtMs: Date.parse(generatedAt),
1086
+ fileCount: mergedFileRecords.length,
1087
+ symbolCount: symbols.length,
1088
+ importCount: imports.length,
1089
+ testsMapCount: testsMap.length,
1090
+ hotspotCount: hotspots.length,
1091
+ archetypeCount: archetypeItems.length,
1092
+ relationCount: relations.length,
1093
+ analogCount: analogs?.length ?? 0,
1094
+ parsedCodeFileCount: parsedFiles.length,
1095
+ reusedCodeFileCount,
1096
+ reusedTestsMap: canReuseTestsMap,
1097
+ reusedHotspots: canReuseHotspots,
1098
+ reusedRelations: canReuseRelations,
1099
+ reusedAnalogs: canReuseAnalogs,
1100
+ preservedRuntimeCaches,
1101
+ mode: 'incremental',
1102
+ parsedFiles,
1103
+ removedFiles: [...hintUpdates.removals].sort((a, b) => a.localeCompare(b)),
393
1104
  };
394
1105
  }
395
1106
 
@@ -1565,17 +2276,37 @@ function buildResolvedImportMap(rootDir, imports, indexedFileSet, importAliasCon
1565
2276
 
1566
2277
  // ── Helpers ──
1567
2278
 
1568
- async function collectFiles(scanRoots) {
2279
+ async function collectFiles(scanRoots, discovery = null) {
2280
+ const budget = discovery ?? createDiscoveryBudget({});
1569
2281
  const result = [];
1570
2282
  const stack = [...scanRoots];
2283
+ const rootPath = path.resolve(scanRoots[0]);
2284
+ let rootRealPath = null;
2285
+ try {
2286
+ rootRealPath = await resolveDiscoveryPath(rootPath, budget, 'realpath-root');
2287
+ } catch (error) {
2288
+ if (error instanceof IndexDiscoveryTimeoutError) {
2289
+ throw error;
2290
+ }
2291
+ }
2292
+
2293
+ const isInsideRoot = (realPath) => isDiscoveryPathContained(rootRealPath, realPath);
1571
2294
  const visitedDirectoryRealPaths = new Set();
1572
2295
 
1573
2296
  while (stack.length > 0) {
2297
+ assertDiscoveryAlive(budget, 'walk');
1574
2298
  const current = stack.pop();
1575
2299
  let currentRealPath;
1576
2300
  try {
1577
- currentRealPath = await fs.realpath(current);
1578
- } catch {
2301
+ currentRealPath = await resolveDiscoveryPath(current, budget, 'realpath');
2302
+ } catch (error) {
2303
+ if (error instanceof IndexDiscoveryTimeoutError) {
2304
+ throw error;
2305
+ }
2306
+ continue;
2307
+ }
2308
+
2309
+ if (!isInsideRoot(currentRealPath)) {
1579
2310
  continue;
1580
2311
  }
1581
2312
 
@@ -1586,8 +2317,15 @@ async function collectFiles(scanRoots) {
1586
2317
 
1587
2318
  let entries;
1588
2319
  try {
1589
- entries = await fs.readdir(current, { withFileTypes: true });
1590
- } catch {
2320
+ entries = await resolveDiscoveryOperation(
2321
+ () => fs.readdir(current, { withFileTypes: true }),
2322
+ budget,
2323
+ 'readdir',
2324
+ );
2325
+ } catch (error) {
2326
+ if (error instanceof IndexDiscoveryTimeoutError) {
2327
+ throw error;
2328
+ }
1591
2329
  continue;
1592
2330
  }
1593
2331
 
@@ -1601,15 +2339,33 @@ async function collectFiles(scanRoots) {
1601
2339
  directDirectories.push(fullPath);
1602
2340
  } else if (entry.isSymbolicLink()) {
1603
2341
  try {
1604
- const stat = await fs.stat(fullPath);
2342
+ const stat = await resolveDiscoveryOperation(
2343
+ () => fs.stat(fullPath),
2344
+ budget,
2345
+ 'stat-symlink',
2346
+ );
1605
2347
  if (stat.isDirectory() && !shouldSkipDirectory(entry.name)) {
2348
+ // Containment is re-checked from the link's realpath when the
2349
+ // directory is popped, so an escaping symlinked subtree is pruned.
1606
2350
  symlinkDirectories.push(fullPath);
1607
2351
  continue;
1608
2352
  }
1609
2353
 
1610
2354
  if (stat.isFile()) {
1611
2355
  const ext = path.extname(entry.name).toLowerCase();
1612
- if (DISCOVERED_EXTENSIONS.has(ext)) {
2356
+ if (!DISCOVERED_EXTENSIONS.has(ext)) continue;
2357
+ // A symlinked FILE is indexed only when its real path stays inside
2358
+ // the root; the link itself may point anywhere on disk.
2359
+ let realFilePath = null;
2360
+ try {
2361
+ realFilePath = await resolveDiscoveryPath(fullPath, budget, 'realpath-symlink');
2362
+ } catch (error) {
2363
+ if (error instanceof IndexDiscoveryTimeoutError) {
2364
+ throw error;
2365
+ }
2366
+ realFilePath = null;
2367
+ }
2368
+ if (isInsideRoot(realFilePath)) {
1613
2369
  symlinkFiles.push({
1614
2370
  absolutePath: fullPath,
1615
2371
  mtimeMs: Math.floor(stat.mtimeMs),
@@ -1617,7 +2373,13 @@ async function collectFiles(scanRoots) {
1617
2373
  });
1618
2374
  }
1619
2375
  }
1620
- } catch {
2376
+ } catch (error) {
2377
+ // A stalled stat/realpath past the shared deadline must surface as the
2378
+ // typed timeout, not be misclassified as a broken symlink and resolve
2379
+ // to silently truncated discovery.
2380
+ if (error instanceof IndexDiscoveryTimeoutError) {
2381
+ throw error;
2382
+ }
1621
2383
  // broken symlink, skip
1622
2384
  }
1623
2385
  }
@@ -1629,15 +2391,22 @@ async function collectFiles(scanRoots) {
1629
2391
  const ext = path.extname(entry.name).toLowerCase();
1630
2392
  return DISCOVERED_EXTENSIONS.has(ext);
1631
2393
  });
1632
- const fileStats = (await Promise.all(fileEntries.map(async (entry) => {
2394
+ const fileStats = (await mapLimit(fileEntries, INDEX_IO_CONCURRENCY, async (entry) => {
1633
2395
  try {
1634
2396
  const fullPath = path.join(current, entry.name);
1635
- const stat = await fs.stat(fullPath);
2397
+ const stat = await resolveDiscoveryOperation(
2398
+ () => fs.stat(fullPath),
2399
+ budget,
2400
+ 'stat-walk',
2401
+ );
1636
2402
  return { absolutePath: fullPath, mtimeMs: Math.floor(stat.mtimeMs), size: stat.size };
1637
- } catch {
2403
+ } catch (error) {
2404
+ if (error instanceof IndexDiscoveryTimeoutError) {
2405
+ throw error;
2406
+ }
1638
2407
  return null;
1639
2408
  }
1640
- }))).filter(Boolean);
2409
+ }, budget.signal, 'stat-walk')).filter(Boolean);
1641
2410
  result.push(...fileStats, ...symlinkFiles);
1642
2411
  }
1643
2412
 
@@ -2934,29 +3703,156 @@ function normalizeTestStem(testBase) {
2934
3703
  * is self-tuning per layout and needs no configuration. Non-git projects fall
2935
3704
  * back to walking the root with the same exclusion rules.
2936
3705
  */
2937
- async function discoverProjectFiles(rootDir) {
2938
- const gitFiles = await collectGitTrackedFiles(rootDir);
3706
+ // Discover project files under ONE top-level wall-clock deadline. `deadlineMs`
3707
+ // is a single ceiling divided across both git enumerations and the non-git
3708
+ // fallback walk — not a per-call timeout. `signal` cancels early and surfaces
3709
+ // through the same typed error. Throws IndexDiscoveryTimeoutError on expiry.
3710
+ export async function discoverProjectFiles(rootDir, { signal = null, deadlineMs = DEFAULT_DISCOVERY_DEADLINE_MS } = {}) {
3711
+ const budget = createDiscoveryBudget({ signal, deadlineMs });
3712
+ assertDiscoveryAlive(budget, 'start');
3713
+ const gitFiles = await collectGitTrackedFiles(rootDir, budget);
2939
3714
  if (gitFiles) return gitFiles;
2940
- return collectFiles([rootDir]);
3715
+ return collectFiles([rootDir], budget);
3716
+ }
3717
+
3718
+ function createDiscoveryBudget({ signal = null, deadlineMs = DEFAULT_DISCOVERY_DEADLINE_MS } = {}) {
3719
+ const effectiveDeadlineMs = Number.isFinite(deadlineMs) && deadlineMs > 0 ? deadlineMs : 0;
3720
+ const startedAt = Date.now();
3721
+ return {
3722
+ signal,
3723
+ deadlineMs: effectiveDeadlineMs,
3724
+ startedAt,
3725
+ deadline: startedAt + effectiveDeadlineMs,
3726
+ };
3727
+ }
3728
+
3729
+ function assertDiscoveryAlive({ signal, deadlineMs, deadline, startedAt }, phase) {
3730
+ const elapsedMs = Date.now() - startedAt;
3731
+ if (signal?.aborted) {
3732
+ throw new IndexDiscoveryTimeoutError({ deadlineMs, phase, elapsedMs, aborted: true });
3733
+ }
3734
+ if (Date.now() >= deadline) {
3735
+ throw new IndexDiscoveryTimeoutError({ deadlineMs, phase, elapsedMs });
3736
+ }
3737
+ }
3738
+
3739
+ function discoveryOperationTimeout(budget, phase) {
3740
+ assertDiscoveryAlive(budget, phase);
3741
+ return Math.max(1, Math.ceil(remainingDiscoveryMs(budget)));
3742
+ }
3743
+
3744
+ async function resolveDiscoveryOperation(operation, budget, phase) {
3745
+ const timeoutMs = discoveryOperationTimeout(budget, phase);
3746
+ let timer;
3747
+ const timeout = new Promise((_, reject) => {
3748
+ timer = setTimeout(() => {
3749
+ reject(new IndexDiscoveryTimeoutError({
3750
+ deadlineMs: budget.deadlineMs,
3751
+ phase,
3752
+ elapsedMs: Date.now() - budget.startedAt,
3753
+ }));
3754
+ }, timeoutMs);
3755
+ });
3756
+ try {
3757
+ return await Promise.race([Promise.resolve().then(operation), timeout]);
3758
+ } finally {
3759
+ clearTimeout(timer);
3760
+ }
3761
+ }
3762
+
3763
+ async function resolveDiscoveryPath(filePath, budget, phase) {
3764
+ return resolveDiscoveryOperation(() => fs.realpath(filePath), budget, phase);
3765
+ }
3766
+
3767
+ function remainingDiscoveryMs({ deadline }) {
3768
+ return deadline - Date.now();
3769
+ }
3770
+
3771
+ function isDiscoveryPathContained(rootRealPath, realPath) {
3772
+ if (!rootRealPath || !realPath) {
3773
+ return false;
3774
+ }
3775
+ if (realPath === rootRealPath) {
3776
+ return true;
3777
+ }
3778
+ const relative = path.relative(rootRealPath, realPath);
3779
+ return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative);
3780
+ }
3781
+
3782
+ function createDiscoverySnapshot(rootDir, files, fingerprint) {
3783
+ // Deep-frozen, and each file entry is cloned into a fresh frozen record: the
3784
+ // snapshot must stay byte-stable between the staleness inspection and the
3785
+ // build that consumes it, and a caller that keeps a reference must not be able
3786
+ // to redirect the build by mutating `entry.absolutePath` after inspection.
3787
+ return Object.freeze({
3788
+ snapshotVersion: DISCOVERY_SNAPSHOT_VERSION,
3789
+ schemaVersion: INDEX_SCHEMA_VERSION,
3790
+ rootDir,
3791
+ fingerprint: Object.freeze({ ...fingerprint }),
3792
+ files: Object.freeze(files.map((entry) => Object.freeze({
3793
+ absolutePath: entry?.absolutePath,
3794
+ mtimeMs: entry?.mtimeMs,
3795
+ size: entry?.size,
3796
+ }))),
3797
+ });
3798
+ }
3799
+
3800
+ function isSnapshotPathContained(rootDir, absolutePath) {
3801
+ if (typeof absolutePath !== 'string' || absolutePath === '' || !path.isAbsolute(absolutePath)) {
3802
+ return false;
3803
+ }
3804
+ const resolved = path.resolve(absolutePath);
3805
+ // A file entry can never be the root directory itself.
3806
+ if (resolved === rootDir) {
3807
+ return false;
3808
+ }
3809
+ const relative = path.relative(rootDir, resolved);
3810
+ return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative);
2941
3811
  }
2942
3812
 
2943
- function runGit(rootDir, args) {
3813
+ function isDiscoverySnapshotUsable(discoverySnapshot, absoluteRoot) {
3814
+ return Boolean(discoverySnapshot)
3815
+ && discoverySnapshot.snapshotVersion === DISCOVERY_SNAPSHOT_VERSION
3816
+ && discoverySnapshot.schemaVersion === INDEX_SCHEMA_VERSION
3817
+ && typeof discoverySnapshot.rootDir === 'string'
3818
+ && path.resolve(discoverySnapshot.rootDir) === absoluteRoot
3819
+ && Array.isArray(discoverySnapshot.files)
3820
+ // Fail closed: a snapshot is reusable only when EVERY entry is an absolute
3821
+ // path contained by the snapshotted root. A mutated, relative, or foreign
3822
+ // entry makes the whole snapshot untrusted — the repository is rediscovered
3823
+ // once instead of letting caller-supplied paths steer the build.
3824
+ && discoverySnapshot.files.every((entry) => Boolean(entry)
3825
+ && typeof entry === 'object'
3826
+ && isSnapshotPathContained(absoluteRoot, entry.absolutePath));
3827
+ }
3828
+
3829
+ function runGit(rootDir, args, budgetMs) {
3830
+ // spawnSync treats `timeout: 0` as "no timeout", so an exhausted budget must
3831
+ // short-circuit here instead of ever reaching an unbounded git call.
3832
+ if (!(budgetMs > 0)) return null;
3833
+
2944
3834
  const result = spawnSync('git', args, {
2945
3835
  cwd: rootDir,
2946
3836
  encoding: 'utf8',
2947
3837
  maxBuffer: 256 * 1024 * 1024,
2948
- timeout: GIT_ENUMERATION_TIMEOUT_MS,
3838
+ timeout: Math.ceil(budgetMs),
2949
3839
  windowsHide: true,
2950
3840
  });
2951
3841
  if (result.error || result.status !== 0 || typeof result.stdout !== 'string') return null;
2952
3842
  return result.stdout;
2953
3843
  }
2954
3844
 
2955
- async function collectGitTrackedFiles(rootDir) {
3845
+ async function collectGitTrackedFiles(rootDir, discovery = null) {
3846
+ const budget = discovery ?? createDiscoveryBudget({});
3847
+ assertDiscoveryAlive(budget, 'git');
2956
3848
  // `-z` also disables git path quoting, which would mangle non-ASCII filenames.
2957
- const tracked = runGit(rootDir, ['ls-files', '-z']);
3849
+ const tracked = runGit(rootDir, ['ls-files', '-z'], remainingDiscoveryMs(budget));
2958
3850
  if (tracked === null) return null;
2959
- const untracked = runGit(rootDir, ['ls-files', '-z', '--others', '--exclude-standard']) ?? '';
3851
+ // Both enumerations draw from the same budget: a first call that hung pays
3852
+ // the whole ceiling, and the second call (and the fallback walk after it)
3853
+ // only ever gets the remaining time.
3854
+ assertDiscoveryAlive(budget, 'git');
3855
+ const untracked = runGit(rootDir, ['ls-files', '-z', '--others', '--exclude-standard'], remainingDiscoveryMs(budget)) ?? '';
2960
3856
 
2961
3857
  const relativePaths = new Set();
2962
3858
  for (const entry of `${tracked}${untracked}`.split('\0')) {
@@ -2971,16 +3867,44 @@ async function collectGitTrackedFiles(rootDir) {
2971
3867
  return !relativePath.split('/').some((segment) => EXCLUDED_DIR_NAMES.has(segment));
2972
3868
  });
2973
3869
 
2974
- const entries = await Promise.all(candidates.map(async (relativePath) => {
3870
+ // Fixed pool instead of one unbounded promise per candidate: peak in-flight
3871
+ // stats stay at INDEX_IO_CONCURRENCY for a listing of any size, and an
3872
+ // aborted signal stops the fan-out with queued candidates unstat'ed.
3873
+ let rootRealPath;
3874
+ try {
3875
+ rootRealPath = await resolveDiscoveryPath(path.resolve(rootDir), budget, 'realpath-root');
3876
+ } catch (error) {
3877
+ if (error instanceof IndexDiscoveryTimeoutError) {
3878
+ throw error;
3879
+ }
3880
+ return null;
3881
+ }
3882
+
3883
+ const entries = await mapLimit(candidates, INDEX_IO_CONCURRENCY, async (relativePath) => {
2975
3884
  const absolutePath = path.join(rootDir, relativePath);
2976
3885
  try {
2977
- const stat = await fs.stat(absolutePath);
3886
+ const stat = await resolveDiscoveryOperation(
3887
+ () => fs.stat(absolutePath),
3888
+ budget,
3889
+ 'stat-git',
3890
+ );
2978
3891
  if (!stat.isFile()) return null;
3892
+ // fs.stat follows symlinks. Resolve the final target before accepting a
3893
+ // tracked path so Git cannot make an external file enter the index merely
3894
+ // by tracking an escaping symlink.
3895
+ const realPath = await resolveDiscoveryPath(absolutePath, budget, 'realpath-git');
3896
+ if (!isDiscoveryPathContained(rootRealPath, realPath)) {
3897
+ return null;
3898
+ }
2979
3899
  return { absolutePath, mtimeMs: Math.floor(stat.mtimeMs), size: stat.size };
2980
- } catch {
3900
+ } catch (error) {
3901
+ if (error instanceof IndexDiscoveryTimeoutError) {
3902
+ throw error;
3903
+ }
3904
+ // staged deletion, broken symlink, or an unreadable path
2981
3905
  return null;
2982
3906
  }
2983
- }));
3907
+ }, budget.signal, 'stat-git');
2984
3908
 
2985
3909
  return entries.filter(Boolean);
2986
3910
  }
@@ -3406,24 +4330,59 @@ export async function getIndexArtifactGeneratedAt({
3406
4330
  return parseArtifactGeneratedAt(artifact);
3407
4331
  }
3408
4332
 
4333
+ async function evaluateIndexStaleness({ rootDir, maxAgeMs, now, generatedAtMs, enumerateForSnapshot, signal = null, deadlineMs = DEFAULT_DISCOVERY_DEADLINE_MS }) {
4334
+ const absoluteRoot = path.resolve(rootDir);
4335
+ const metaArtifact = await readArtifactIfExists(absoluteRoot, INDEX_ARTIFACTS.meta);
4336
+ const effectiveGeneratedAtMs = Number.isFinite(generatedAtMs)
4337
+ ? generatedAtMs
4338
+ : parseArtifactGeneratedAt(metaArtifact);
4339
+
4340
+ const staleWithoutEnumeration = effectiveGeneratedAtMs === null
4341
+ || typeof maxAgeMs !== 'number'
4342
+ || Number.isNaN(maxAgeMs)
4343
+ || maxAgeMs < 0
4344
+ || (now - effectiveGeneratedAtMs) >= maxAgeMs
4345
+ || !metaArtifact?.sourceFingerprint;
4346
+
4347
+ // Legacy boolean callers (query-index, triage, verify-context) stop here and
4348
+ // let buildCodeIndex do its own discovery when a rebuild is needed.
4349
+ if (staleWithoutEnumeration && !enumerateForSnapshot) {
4350
+ return { stale: true, snapshot: null };
4351
+ }
4352
+
4353
+ // Exactly one enumeration per inspection. In the refresh flow this snapshot is
4354
+ // handed to buildCodeIndex, so stale-check + rebuild enumerates the repository
4355
+ // once instead of twice.
4356
+ const discoveredFiles = await discoverProjectFiles(absoluteRoot, { signal, deadlineMs });
4357
+ const fingerprint = createSourceFingerprint(absoluteRoot, discoveredFiles);
4358
+
4359
+ return {
4360
+ stale: staleWithoutEnumeration
4361
+ ? true
4362
+ : !areSourceFingerprintsEqual(metaArtifact.sourceFingerprint, fingerprint),
4363
+ snapshot: createDiscoverySnapshot(absoluteRoot, discoveredFiles, fingerprint),
4364
+ };
4365
+ }
4366
+
4367
+ export async function inspectIndexStaleness({
4368
+ rootDir = process.cwd(),
4369
+ maxAgeMs = DEFAULT_INDEX_CACHE_MAX_AGE_MS,
4370
+ now = Date.now(),
4371
+ generatedAtMs = null,
4372
+ signal = null,
4373
+ deadlineMs = DEFAULT_DISCOVERY_DEADLINE_MS,
4374
+ } = {}) {
4375
+ return evaluateIndexStaleness({ rootDir, maxAgeMs, now, generatedAtMs, enumerateForSnapshot: true, signal, deadlineMs });
4376
+ }
4377
+
3409
4378
  export async function isIndexStale({
3410
4379
  rootDir = process.cwd(),
3411
4380
  maxAgeMs = DEFAULT_INDEX_CACHE_MAX_AGE_MS,
3412
4381
  now = Date.now(),
3413
4382
  generatedAtMs = null,
4383
+ signal = null,
4384
+ deadlineMs = DEFAULT_DISCOVERY_DEADLINE_MS,
3414
4385
  } = {}) {
3415
- const absoluteRoot = path.resolve(rootDir);
3416
- const metaArtifact = await readArtifactIfExists(absoluteRoot, INDEX_ARTIFACTS.meta);
3417
- const effectiveGeneratedAtMs = Number.isFinite(generatedAtMs)
3418
- ? generatedAtMs
3419
- : parseArtifactGeneratedAt(metaArtifact);
3420
- if (effectiveGeneratedAtMs === null) return true;
3421
- if (typeof maxAgeMs !== 'number' || Number.isNaN(maxAgeMs) || maxAgeMs < 0) return true;
3422
- if ((now - effectiveGeneratedAtMs) >= maxAgeMs) return true;
3423
- if (!metaArtifact?.sourceFingerprint) return true;
3424
- const currentSourceFingerprint = createSourceFingerprint(
3425
- absoluteRoot,
3426
- await discoverProjectFiles(absoluteRoot),
3427
- );
3428
- return !areSourceFingerprintsEqual(metaArtifact.sourceFingerprint, currentSourceFingerprint);
4386
+ const { stale } = await evaluateIndexStaleness({ rootDir, maxAgeMs, now, generatedAtMs, enumerateForSnapshot: false, signal, deadlineMs });
4387
+ return stale;
3429
4388
  }