@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.
- package/CHANGELOG.md +65 -0
- package/manifests/platform.full.yaml +19 -111
- package/package.json +2 -1
- package/scripts/index/refresh-index.mjs +48 -18
- package/src/cli/commands/doctor.js +59 -2
- package/src/core/compact/threshold.js +36 -6
- package/src/core/gatewayProbe.js +143 -15
- package/src/core/gatewayResilienceEnv.js +136 -7
- package/src/diagnostics/classifyHang.js +246 -0
- package/src/index/buildIndex.js +1096 -75
- package/templates/.claude/hooks/auto-allow-bash.sh +99 -87
- package/templates/.claude/hooks/auto-prune-bash.sh +4 -0
- package/templates/.claude/hooks/block-dangerous.sh +46 -1
- package/templates/.claude/hooks/completion-gate.sh +65 -7
- package/templates/.claude/hooks/compress-output.sh +49 -2
- package/templates/.claude/hooks/context-hardcap-gate.sh +52 -4
- package/templates/.claude/hooks/context-window-guard.sh +204 -71
- package/templates/.claude/hooks/handoff-model-guard.sh +50 -3
- package/templates/.claude/hooks/handoff-resume.sh +47 -3
- package/templates/.claude/hooks/post-edit-verify.sh +45 -2
- package/templates/.claude/hooks/pre-edit-backup.sh +45 -2
- package/templates/.claude/hooks/protect-files.sh +46 -1
- package/templates/.claude/hooks/record-execution.sh +46 -2
- package/templates/.claude/hooks/reinject-context.sh +1 -1
- package/templates/.claude/hooks/reset-compact-pressure.sh +4 -0
- package/templates/.claude/hooks/sensitive-data-guard.sh +101 -18
- package/templates/.claude/hooks/skill-router.sh +59 -5
- package/templates/.claude/hooks/stale-spec-guard.sh +47 -2
- package/templates/.claude/hooks/task-watchdog.sh +129 -126
- package/templates/.claude/hooks/verification-guard.sh +136 -106
- package/templates/.claude/hooks/vision-router.sh +138 -18
- package/templates/.claude/settings.json +0 -5
- package/templates/.claude/ukit/index/lib/index-core.mjs +1027 -68
- package/templates/.claude/ukit/index/post-edit-verify.mjs +8 -0
- package/templates/.claude/ukit/index/pre-edit-backup.mjs +8 -0
- package/templates/.claude/ukit/index/refresh-index.mjs +48 -18
- package/templates/.claude/ukit/index/route-task.mjs +610 -4
- package/templates/.claude/ukit/index/stale-spec-check.mjs +8 -0
- package/templates/.claude/ukit/runtime/async-lock.mjs +340 -0
- package/templates/.claude/ukit/runtime/compact-threshold.mjs +73 -24
- package/templates/.claude/ukit/runtime/context-capacity.mjs +144 -0
- package/templates/.claude/ukit/runtime/execution-ledger.mjs +672 -170
- package/templates/.claude/ukit/runtime/hook-chain-budget.mjs +92 -0
- package/templates/.claude/ukit/runtime/hook-chain-runner.mjs +84 -29
- package/templates/.claude/ukit/runtime/hook-input.mjs +120 -0
- package/templates/.claude/ukit/runtime/hook-input.sh +140 -0
- package/templates/.claude/ukit/runtime/hook-payload-store.mjs +160 -0
- package/templates/.claude/ukit/runtime/hook-process.mjs +250 -0
- package/templates/.claude/ukit/runtime/hook-telemetry.mjs +255 -0
- package/templates/.claude/ukit/runtime/hook-telemetry.sh +60 -0
- package/templates/.claude/ukit/runtime/output-compression.mjs +8 -0
- package/templates/.claude/ukit/runtime/reinject-context.mjs +8 -0
- package/templates/.claude/ukit/runtime/stop-coordinator.mjs +509 -0
- package/templates/.claude/ukit/runtime/task-watchdog.mjs +180 -6
- package/templates/.claude/ukit/runtime/transcript-tail.mjs +107 -0
- package/templates/.omp/hooks/pre/ukit-bridge.js +171 -57
package/src/index/buildIndex.js
CHANGED
|
@@ -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
|
-
|
|
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',
|
|
@@ -36,15 +132,36 @@ const STYLE_EXTENSIONS = new Set(['.css', '.scss', '.sass', '.less']);
|
|
|
36
132
|
const TRACKED_EXTENSIONS = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.vue', '.sql', '.json', '.yaml', '.yml', '.md', '.sh']);
|
|
37
133
|
const DISCOVERED_EXTENSIONS = new Set([...TRACKED_EXTENSIONS, ...STYLE_EXTENSIONS]);
|
|
38
134
|
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
|
|
136
|
+
// snapshots from a different version instead of trusting unknown fields.
|
|
137
|
+
export const DISCOVERY_SNAPSHOT_VERSION = 1;
|
|
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;
|
|
40
141
|
const CALL_IGNORE_WORDS = new Set([
|
|
41
142
|
'if', 'for', 'while', 'switch', 'catch', 'function', 'return', 'typeof',
|
|
42
143
|
]);
|
|
43
144
|
|
|
44
|
-
export async function buildCodeIndex({ rootDir = process.cwd() } = {}) {
|
|
145
|
+
export async function buildCodeIndex({ rootDir = process.cwd(), changedFiles = null, discoverySnapshot = null, signal = null, deadlineMs = DEFAULT_DISCOVERY_DEADLINE_MS } = {}) {
|
|
45
146
|
const absoluteRoot = path.resolve(rootDir);
|
|
46
147
|
const indexDir = getIndexDir(absoluteRoot);
|
|
47
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
|
+
|
|
48
165
|
const previousFilesArtifact = await readArtifactIfExists(absoluteRoot, INDEX_ARTIFACTS.files);
|
|
49
166
|
const canReusePrevious = previousFilesArtifact?.schemaVersion === INDEX_SCHEMA_VERSION;
|
|
50
167
|
const previousCodeFileRecords = canReusePrevious
|
|
@@ -55,7 +172,16 @@ export async function buildCodeIndex({ rootDir = process.cwd() } = {}) {
|
|
|
55
172
|
previousCodeFileRecords.map((item) => [item.filePath, { mtimeMs: Number(item.mtimeMs ?? -1), size: Number(item.size ?? -1) }]),
|
|
56
173
|
);
|
|
57
174
|
|
|
58
|
-
|
|
175
|
+
// Reuse the caller's discovery snapshot when it describes exactly this root
|
|
176
|
+
// under the current schema and snapshot version; anything else (foreign root,
|
|
177
|
+
// stale schema, wrong shape) is rejected and the repository is rediscovered
|
|
178
|
+
// once here. This is what keeps a stale-check + rebuild refresh at a single
|
|
179
|
+
// enumeration instead of two.
|
|
180
|
+
const discoveredFiles = isDiscoverySnapshotUsable(discoverySnapshot, absoluteRoot)
|
|
181
|
+
? discoverySnapshot.files
|
|
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 });
|
|
59
185
|
const sourceFingerprint = createSourceFingerprint(absoluteRoot, discoveredFiles);
|
|
60
186
|
const styleFilePaths = discoveredFiles
|
|
61
187
|
.map((entry) => {
|
|
@@ -142,32 +268,36 @@ export async function buildCodeIndex({ rootDir = process.cwd() } = {}) {
|
|
|
142
268
|
}
|
|
143
269
|
const canReuseAllParsedArtifacts = canReuseParsedArtifacts && filesToParse.length === 0 && reusableCodeFiles.length === codeFiles.length;
|
|
144
270
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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');
|
|
165
293
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
calls.push(...parsedFile.calls);
|
|
294
|
+
for (const parsedFile of parsedFiles) {
|
|
295
|
+
if (!parsedFile) {
|
|
296
|
+
continue;
|
|
170
297
|
}
|
|
298
|
+
symbols.push(...parsedFile.symbols);
|
|
299
|
+
imports.push(...parsedFile.imports);
|
|
300
|
+
calls.push(...parsedFile.calls);
|
|
171
301
|
}
|
|
172
302
|
|
|
173
303
|
const canReuseTestsMapCandidate = canReusePrevious
|
|
@@ -270,6 +400,10 @@ export async function buildCodeIndex({ rootDir = process.cwd() } = {}) {
|
|
|
270
400
|
|
|
271
401
|
const generatedAt = new Date().toISOString();
|
|
272
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');
|
|
273
407
|
await fs.mkdir(indexDir, { recursive: true });
|
|
274
408
|
if (!canReuseFilesArtifact) {
|
|
275
409
|
await writeArtifact(absoluteRoot, INDEX_ARTIFACTS.files, {
|
|
@@ -347,6 +481,15 @@ export async function buildCodeIndex({ rootDir = process.cwd() } = {}) {
|
|
|
347
481
|
generatedAt,
|
|
348
482
|
rootDir: absoluteRoot,
|
|
349
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
|
+
},
|
|
350
493
|
});
|
|
351
494
|
const preservedRuntimeCaches = canReuseFilesArtifact
|
|
352
495
|
&& canReuseAllParsedArtifacts
|
|
@@ -379,20 +522,671 @@ export async function buildCodeIndex({ rootDir = process.cwd() } = {}) {
|
|
|
379
522
|
reusedRelations: canReuseRelations,
|
|
380
523
|
reusedAnalogs: canReuseAnalogs,
|
|
381
524
|
preservedRuntimeCaches,
|
|
525
|
+
mode: 'full',
|
|
526
|
+
parsedFiles: filesToParse.slice(),
|
|
527
|
+
removedFiles: [],
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
|
|
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)),
|
|
382
1156
|
};
|
|
383
1157
|
}
|
|
384
1158
|
|
|
385
|
-
async function collectFiles(scanRoots) {
|
|
1159
|
+
async function collectFiles(scanRoots, discovery = null) {
|
|
1160
|
+
const budget = discovery ?? createDiscoveryBudget({});
|
|
386
1161
|
const result = [];
|
|
387
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);
|
|
388
1174
|
const visitedDirectoryRealPaths = new Set();
|
|
389
1175
|
|
|
390
1176
|
while (stack.length > 0) {
|
|
1177
|
+
assertDiscoveryAlive(budget, 'walk');
|
|
391
1178
|
const current = stack.pop();
|
|
392
1179
|
let currentRealPath;
|
|
393
1180
|
try {
|
|
394
|
-
currentRealPath = await
|
|
395
|
-
} 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)) {
|
|
396
1190
|
continue;
|
|
397
1191
|
}
|
|
398
1192
|
|
|
@@ -403,8 +1197,15 @@ async function collectFiles(scanRoots) {
|
|
|
403
1197
|
|
|
404
1198
|
let entries;
|
|
405
1199
|
try {
|
|
406
|
-
entries = await
|
|
407
|
-
|
|
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
|
+
}
|
|
408
1209
|
continue;
|
|
409
1210
|
}
|
|
410
1211
|
|
|
@@ -420,15 +1221,35 @@ async function collectFiles(scanRoots) {
|
|
|
420
1221
|
directDirectories.push(fullPath);
|
|
421
1222
|
} else if (entry.isSymbolicLink()) {
|
|
422
1223
|
try {
|
|
423
|
-
const stat = await
|
|
1224
|
+
const stat = await resolveDiscoveryOperation(
|
|
1225
|
+
() => fs.stat(fullPath),
|
|
1226
|
+
budget,
|
|
1227
|
+
'stat-symlink',
|
|
1228
|
+
);
|
|
424
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.
|
|
425
1232
|
symlinkDirectories.push(fullPath);
|
|
426
1233
|
continue;
|
|
427
1234
|
}
|
|
428
1235
|
|
|
429
1236
|
if (stat.isFile()) {
|
|
430
1237
|
const ext = path.extname(entry.name).toLowerCase();
|
|
431
|
-
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)) {
|
|
432
1253
|
symlinkFiles.push({
|
|
433
1254
|
absolutePath: fullPath,
|
|
434
1255
|
mtimeMs: Math.floor(stat.mtimeMs),
|
|
@@ -436,7 +1257,13 @@ async function collectFiles(scanRoots) {
|
|
|
436
1257
|
});
|
|
437
1258
|
}
|
|
438
1259
|
}
|
|
439
|
-
} 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
|
+
}
|
|
440
1267
|
// broken symlink, skip
|
|
441
1268
|
}
|
|
442
1269
|
}
|
|
@@ -450,19 +1277,26 @@ async function collectFiles(scanRoots) {
|
|
|
450
1277
|
const ext = path.extname(entry.name).toLowerCase();
|
|
451
1278
|
return DISCOVERED_EXTENSIONS.has(ext);
|
|
452
1279
|
});
|
|
453
|
-
const fileStats = (await
|
|
1280
|
+
const fileStats = (await mapLimit(fileEntries, INDEX_IO_CONCURRENCY, async (entry) => {
|
|
454
1281
|
try {
|
|
455
1282
|
const fullPath = path.join(current, entry.name);
|
|
456
|
-
const stat = await
|
|
1283
|
+
const stat = await resolveDiscoveryOperation(
|
|
1284
|
+
() => fs.stat(fullPath),
|
|
1285
|
+
budget,
|
|
1286
|
+
'stat-walk',
|
|
1287
|
+
);
|
|
457
1288
|
return {
|
|
458
1289
|
absolutePath: fullPath,
|
|
459
1290
|
mtimeMs: Math.floor(stat.mtimeMs),
|
|
460
1291
|
size: stat.size,
|
|
461
1292
|
};
|
|
462
|
-
} catch {
|
|
1293
|
+
} catch (error) {
|
|
1294
|
+
if (error instanceof IndexDiscoveryTimeoutError) {
|
|
1295
|
+
throw error;
|
|
1296
|
+
}
|
|
463
1297
|
return null;
|
|
464
1298
|
}
|
|
465
|
-
}))
|
|
1299
|
+
}, budget.signal, 'stat-walk')).filter(Boolean);
|
|
466
1300
|
result.push(...fileStats, ...symlinkFiles);
|
|
467
1301
|
}
|
|
468
1302
|
|
|
@@ -1062,21 +1896,152 @@ function areBugIndexSnapshotsEqual(previousSnapshot, nextSnapshot) {
|
|
|
1062
1896
|
*
|
|
1063
1897
|
* Non-git projects fall back to walking the root with the same exclusion rules.
|
|
1064
1898
|
*/
|
|
1065
|
-
|
|
1066
|
-
|
|
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);
|
|
1067
1915
|
if (gitFiles) {
|
|
1068
1916
|
return gitFiles;
|
|
1069
1917
|
}
|
|
1070
1918
|
|
|
1071
|
-
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);
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1986
|
+
function createDiscoverySnapshot(rootDir, files, fingerprint) {
|
|
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.
|
|
1991
|
+
return Object.freeze({
|
|
1992
|
+
snapshotVersion: DISCOVERY_SNAPSHOT_VERSION,
|
|
1993
|
+
schemaVersion: INDEX_SCHEMA_VERSION,
|
|
1994
|
+
rootDir,
|
|
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
|
+
}))),
|
|
2001
|
+
});
|
|
1072
2002
|
}
|
|
1073
2003
|
|
|
1074
|
-
function
|
|
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
|
+
|
|
2017
|
+
function isDiscoverySnapshotUsable(discoverySnapshot, absoluteRoot) {
|
|
2018
|
+
return Boolean(discoverySnapshot)
|
|
2019
|
+
&& discoverySnapshot.snapshotVersion === DISCOVERY_SNAPSHOT_VERSION
|
|
2020
|
+
&& discoverySnapshot.schemaVersion === INDEX_SCHEMA_VERSION
|
|
2021
|
+
&& typeof discoverySnapshot.rootDir === 'string'
|
|
2022
|
+
&& path.resolve(discoverySnapshot.rootDir) === absoluteRoot
|
|
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));
|
|
2031
|
+
}
|
|
2032
|
+
|
|
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
|
+
|
|
1075
2040
|
const result = spawnSync('git', args, {
|
|
1076
2041
|
cwd: rootDir,
|
|
1077
2042
|
encoding: 'utf8',
|
|
1078
2043
|
maxBuffer: 256 * 1024 * 1024,
|
|
1079
|
-
timeout:
|
|
2044
|
+
timeout: Math.ceil(budgetMs),
|
|
1080
2045
|
windowsHide: true,
|
|
1081
2046
|
});
|
|
1082
2047
|
|
|
@@ -1091,14 +2056,20 @@ function runGit(rootDir, args) {
|
|
|
1091
2056
|
* @returns {Promise<Array<{absolutePath: string, mtimeMs: number, size: number}>|null>}
|
|
1092
2057
|
* `null` when this is not a usable git worktree, so the caller can fall back.
|
|
1093
2058
|
*/
|
|
1094
|
-
async function collectGitTrackedFiles(rootDir) {
|
|
2059
|
+
async function collectGitTrackedFiles(rootDir, discovery = null) {
|
|
2060
|
+
const budget = discovery ?? createDiscoveryBudget({});
|
|
2061
|
+
assertDiscoveryAlive(budget, 'git');
|
|
1095
2062
|
// `-z` matters beyond separators: it also disables git's path quoting, which
|
|
1096
2063
|
// would otherwise mangle non-ASCII filenames.
|
|
1097
|
-
const tracked = runGit(rootDir, ['ls-files', '-z']);
|
|
2064
|
+
const tracked = runGit(rootDir, ['ls-files', '-z'], remainingDiscoveryMs(budget));
|
|
1098
2065
|
if (tracked === null) {
|
|
1099
2066
|
return null;
|
|
1100
2067
|
}
|
|
1101
|
-
|
|
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)) ?? '';
|
|
1102
2073
|
|
|
1103
2074
|
const relativePaths = new Set();
|
|
1104
2075
|
for (const entry of `${tracked}${untracked}`.split('\0')) {
|
|
@@ -1120,23 +2091,50 @@ async function collectGitTrackedFiles(rootDir) {
|
|
|
1120
2091
|
return !relativePath.split('/').some((segment) => EXCLUDED_DIR_NAMES.has(segment));
|
|
1121
2092
|
});
|
|
1122
2093
|
|
|
1123
|
-
|
|
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) => {
|
|
1124
2108
|
const absolutePath = path.join(rootDir, relativePath);
|
|
1125
2109
|
try {
|
|
1126
|
-
const stat = await
|
|
2110
|
+
const stat = await resolveDiscoveryOperation(
|
|
2111
|
+
() => fs.stat(absolutePath),
|
|
2112
|
+
budget,
|
|
2113
|
+
'stat-git',
|
|
2114
|
+
);
|
|
1127
2115
|
if (!stat.isFile()) {
|
|
1128
2116
|
return null;
|
|
1129
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
|
+
}
|
|
1130
2125
|
return {
|
|
1131
2126
|
absolutePath,
|
|
1132
2127
|
mtimeMs: Math.floor(stat.mtimeMs),
|
|
1133
2128
|
size: stat.size,
|
|
1134
2129
|
};
|
|
1135
|
-
} catch {
|
|
1136
|
-
|
|
2130
|
+
} catch (error) {
|
|
2131
|
+
if (error instanceof IndexDiscoveryTimeoutError) {
|
|
2132
|
+
throw error;
|
|
2133
|
+
}
|
|
2134
|
+
// staged deletion, broken symlink, or an unreadable path
|
|
1137
2135
|
return null;
|
|
1138
2136
|
}
|
|
1139
|
-
})
|
|
2137
|
+
}, budget.signal, 'stat-git');
|
|
1140
2138
|
|
|
1141
2139
|
return entries.filter(Boolean);
|
|
1142
2140
|
}
|
|
@@ -1810,36 +2808,59 @@ export async function getIndexArtifactGeneratedAt({
|
|
|
1810
2808
|
return parseArtifactGeneratedAt(artifact);
|
|
1811
2809
|
}
|
|
1812
2810
|
|
|
1813
|
-
|
|
1814
|
-
rootDir = process.cwd(),
|
|
1815
|
-
maxAgeMs = DEFAULT_INDEX_CACHE_MAX_AGE_MS,
|
|
1816
|
-
now = Date.now(),
|
|
1817
|
-
generatedAtMs = null,
|
|
1818
|
-
} = {}) {
|
|
2811
|
+
async function evaluateIndexStaleness({ rootDir, maxAgeMs, now, generatedAtMs, enumerateForSnapshot, signal = null, deadlineMs = DEFAULT_DISCOVERY_DEADLINE_MS }) {
|
|
1819
2812
|
const absoluteRoot = path.resolve(rootDir);
|
|
1820
2813
|
const metaArtifact = await readArtifactIfExists(absoluteRoot, INDEX_ARTIFACTS.meta);
|
|
1821
2814
|
const effectiveGeneratedAtMs = Number.isFinite(generatedAtMs)
|
|
1822
2815
|
? generatedAtMs
|
|
1823
2816
|
: parseArtifactGeneratedAt(metaArtifact);
|
|
1824
|
-
if (effectiveGeneratedAtMs === null) {
|
|
1825
|
-
return true;
|
|
1826
|
-
}
|
|
1827
2817
|
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
2818
|
+
const staleWithoutEnumeration = effectiveGeneratedAtMs === null
|
|
2819
|
+
|| typeof maxAgeMs !== 'number'
|
|
2820
|
+
|| Number.isNaN(maxAgeMs)
|
|
2821
|
+
|| maxAgeMs < 0
|
|
2822
|
+
|| (now - effectiveGeneratedAtMs) >= maxAgeMs
|
|
2823
|
+
|| !metaArtifact?.sourceFingerprint;
|
|
1831
2824
|
|
|
1832
|
-
|
|
1833
|
-
|
|
2825
|
+
// Legacy boolean callers (query-index, triage, verify-context) stop here and
|
|
2826
|
+
// let buildCodeIndex do its own discovery when a rebuild is needed.
|
|
2827
|
+
if (staleWithoutEnumeration && !enumerateForSnapshot) {
|
|
2828
|
+
return { stale: true, snapshot: null };
|
|
1834
2829
|
}
|
|
1835
2830
|
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
2831
|
+
// Exactly one enumeration per inspection. In the refresh flow this snapshot is
|
|
2832
|
+
// handed to buildCodeIndex, so stale-check + rebuild enumerates the repository
|
|
2833
|
+
// once instead of twice.
|
|
2834
|
+
const discoveredFiles = await discoverProjectFiles(absoluteRoot, { signal, deadlineMs });
|
|
2835
|
+
const fingerprint = createSourceFingerprint(absoluteRoot, discoveredFiles);
|
|
1839
2836
|
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
2837
|
+
return {
|
|
2838
|
+
stale: staleWithoutEnumeration
|
|
2839
|
+
? true
|
|
2840
|
+
: !areSourceFingerprintsEqual(metaArtifact.sourceFingerprint, fingerprint),
|
|
2841
|
+
snapshot: createDiscoverySnapshot(absoluteRoot, discoveredFiles, fingerprint),
|
|
2842
|
+
};
|
|
2843
|
+
}
|
|
2844
|
+
|
|
2845
|
+
export async function inspectIndexStaleness({
|
|
2846
|
+
rootDir = process.cwd(),
|
|
2847
|
+
maxAgeMs = DEFAULT_INDEX_CACHE_MAX_AGE_MS,
|
|
2848
|
+
now = Date.now(),
|
|
2849
|
+
generatedAtMs = null,
|
|
2850
|
+
signal = null,
|
|
2851
|
+
deadlineMs = DEFAULT_DISCOVERY_DEADLINE_MS,
|
|
2852
|
+
} = {}) {
|
|
2853
|
+
return evaluateIndexStaleness({ rootDir, maxAgeMs, now, generatedAtMs, enumerateForSnapshot: true, signal, deadlineMs });
|
|
2854
|
+
}
|
|
2855
|
+
|
|
2856
|
+
export async function isIndexStale({
|
|
2857
|
+
rootDir = process.cwd(),
|
|
2858
|
+
maxAgeMs = DEFAULT_INDEX_CACHE_MAX_AGE_MS,
|
|
2859
|
+
now = Date.now(),
|
|
2860
|
+
generatedAtMs = null,
|
|
2861
|
+
signal = null,
|
|
2862
|
+
deadlineMs = DEFAULT_DISCOVERY_DEADLINE_MS,
|
|
2863
|
+
} = {}) {
|
|
2864
|
+
const { stale } = await evaluateIndexStaleness({ rootDir, maxAgeMs, now, generatedAtMs, enumerateForSnapshot: false, signal, deadlineMs });
|
|
2865
|
+
return stale;
|
|
1845
2866
|
}
|