ucn 5.2.1 → 5.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/ucn/SKILL.md +49 -3
- package/.claude/skills/ucn/references/commands.md +3 -1
- package/README.md +158 -533
- package/cli/index.js +74 -10
- package/core/account.js +36 -8
- package/core/cache.js +167 -21
- package/core/callers.js +1229 -162
- package/core/execute.js +24 -6
- package/core/graph.js +167 -35
- package/core/index-ir.js +17 -12
- package/core/ir.js +56 -8
- package/core/output/graph.js +60 -11
- package/core/output/lines.js +259 -0
- package/core/output/public.js +15 -0
- package/core/output/reporting.js +9 -2
- package/core/output-budget.js +7 -4
- package/core/project.js +103 -5
- package/core/registry.js +7 -6
- package/core/reporting.js +159 -14
- package/languages/c-family.js +19 -17
- package/languages/go.js +170 -42
- package/languages/javascript.js +470 -15
- package/languages/python.js +678 -38
- package/languages/rust.js +1 -0
- package/mcp/server.js +3 -1
- package/package.json +2 -2
- package/assets/demo.svg +0 -31
package/cli/index.js
CHANGED
|
@@ -30,6 +30,16 @@ const { buildPublicParams, isPublicCommand } = require('../core/public-command')
|
|
|
30
30
|
const { execute } = require('../core/execute');
|
|
31
31
|
const { applyOutputBudget, MAX_OUTPUT_CHARS } = require('../core/output-budget');
|
|
32
32
|
const { clearAllCaches } = require('../core/cache');
|
|
33
|
+
const { commentLines } = require('../core/output/lines');
|
|
34
|
+
|
|
35
|
+
// A downstream consumer such as head may finish before our write drains.
|
|
36
|
+
// Handle the pipe closure without an unhandled Node error or bypassing cache
|
|
37
|
+
// cleanup. Other write failures are real command errors.
|
|
38
|
+
process.stdout.on('error', error => {
|
|
39
|
+
if (error.code === 'EPIPE') return;
|
|
40
|
+
process.stderr.write(`Error writing stdout: ${error.message}\n`);
|
|
41
|
+
process.exitCode = 2;
|
|
42
|
+
});
|
|
33
43
|
|
|
34
44
|
let activeCanonicalCommand = null;
|
|
35
45
|
|
|
@@ -315,6 +325,8 @@ function parseFlags(tokens) {
|
|
|
315
325
|
functions: tokens.includes('--functions') || undefined,
|
|
316
326
|
hot: tokens.includes('--hot') || undefined,
|
|
317
327
|
diverse: tokens.includes('--diverse') || undefined,
|
|
328
|
+
raw: tokens.includes('--raw') || undefined,
|
|
329
|
+
lines: tokens.includes('--lines') || undefined,
|
|
318
330
|
git: tokens.includes('--git') || undefined,
|
|
319
331
|
className: getValueFlag('--class-name'),
|
|
320
332
|
// Explicit line pin (fix #249: our own disambiguation notes advertise
|
|
@@ -396,7 +408,7 @@ if (unknownFlags.length > 0) {
|
|
|
396
408
|
emitCliError(
|
|
397
409
|
`Unknown flag(s): ${unknownFlags.join(', ')}. Use --help to see available flags.`,
|
|
398
410
|
);
|
|
399
|
-
process.exit(1);
|
|
411
|
+
process.exit(flags.lines || flags.raw ? 2 : 1);
|
|
400
412
|
}
|
|
401
413
|
|
|
402
414
|
// Validate numeric flag values up front so bad input fails before we build
|
|
@@ -407,7 +419,7 @@ try {
|
|
|
407
419
|
} catch (e) {
|
|
408
420
|
if (e instanceof FlagValidationError) {
|
|
409
421
|
emitCliError(e.message);
|
|
410
|
-
process.exit(1);
|
|
422
|
+
process.exit(flags.lines || flags.raw ? 2 : 1);
|
|
411
423
|
}
|
|
412
424
|
throw e;
|
|
413
425
|
}
|
|
@@ -456,6 +468,16 @@ function formatCliText(command, result, params, execution, displayFlags) {
|
|
|
456
468
|
...execution,
|
|
457
469
|
surface: 'cli',
|
|
458
470
|
});
|
|
471
|
+
// --lines / --raw are pipe surfaces: records are compact, a truncated
|
|
472
|
+
// function body is worse than a long one, and an empty answer must stay
|
|
473
|
+
// empty (grep prints nothing and exits 1). An explicit --max-chars acts
|
|
474
|
+
// as a fail-before-output guard, never a lossy source/record truncation.
|
|
475
|
+
if (params?.lines || params?.raw) {
|
|
476
|
+
if (displayFlags?.maxChars && text.length > displayFlags.maxChars) {
|
|
477
|
+
fail(`Output exceeds --max-chars=${displayFlags.maxChars}; shell output cannot be truncated. Narrow the query, use --limit/--max-lines, or omit --max-chars.`);
|
|
478
|
+
}
|
|
479
|
+
return text;
|
|
480
|
+
}
|
|
459
481
|
return applyOutputBudget(text, {
|
|
460
482
|
command,
|
|
461
483
|
maxChars: displayFlags?.maxChars,
|
|
@@ -465,6 +487,36 @@ function formatCliText(command, result, params, execution, displayFlags) {
|
|
|
465
487
|
}).text;
|
|
466
488
|
}
|
|
467
489
|
|
|
490
|
+
/**
|
|
491
|
+
* Print a formatted answer the way the mode asks for it (fix #341).
|
|
492
|
+
* --lines: `path:line:text` records on stdout, `# ` comment lines (ACCOUNT,
|
|
493
|
+
* notes) on stderr, exit 1 when nothing matched — grep's own contract.
|
|
494
|
+
* --raw: the text verbatim with exactly one trailing newline.
|
|
495
|
+
*/
|
|
496
|
+
function emitCliText(text, params, json, note) {
|
|
497
|
+
if (!json && params?.lines) {
|
|
498
|
+
const records = [];
|
|
499
|
+
const comments = [];
|
|
500
|
+
for (const line of String(text).split('\n')) {
|
|
501
|
+
if (line === '') continue;
|
|
502
|
+
(line.startsWith('# ') ? comments : records).push(line);
|
|
503
|
+
}
|
|
504
|
+
if (records.length > 0) process.stdout.write(records.join('\n') + '\n');
|
|
505
|
+
if (comments.length > 0) process.stderr.write(comments.join('\n') + '\n');
|
|
506
|
+
if (records.length === 0) process.exitCode = Math.max(process.exitCode || 0, 1);
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
if (!json && params?.raw) {
|
|
510
|
+
const body = String(text);
|
|
511
|
+
process.stdout.write(body.endsWith('\n') ? body : body + '\n');
|
|
512
|
+
// Code lines are never reinterpreted (a Python comment starts with
|
|
513
|
+
// "# " too), so the note travels on its own channel.
|
|
514
|
+
if (note) process.stderr.write(commentLines(formatSurfaceMessage(note, 'cli')).join('\n') + '\n');
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
console.log(text);
|
|
518
|
+
}
|
|
519
|
+
|
|
468
520
|
// ============================================================================
|
|
469
521
|
// MAIN
|
|
470
522
|
// ============================================================================
|
|
@@ -555,7 +607,7 @@ function main() {
|
|
|
555
607
|
if (!(e instanceof CommandError)) {
|
|
556
608
|
emitCliError(`Error: ${e.message}`);
|
|
557
609
|
}
|
|
558
|
-
process.exitCode = 1;
|
|
610
|
+
process.exitCode = flags.lines || flags.raw ? 2 : 1;
|
|
559
611
|
}
|
|
560
612
|
}
|
|
561
613
|
|
|
@@ -665,11 +717,11 @@ function runFileCommand(filePath, command, arg) {
|
|
|
665
717
|
const execution = execute(index, canonical, params);
|
|
666
718
|
const { ok, result, error } = execution;
|
|
667
719
|
if (!ok) fail(formatSurfaceMessage(error, 'cli'));
|
|
668
|
-
|
|
720
|
+
emitCliText(flags.json
|
|
669
721
|
? output.formatPublicJson(canonical, result, params, {
|
|
670
722
|
...execution, surface: 'cli',
|
|
671
723
|
})
|
|
672
|
-
: formatCliText(canonical, result, params, execution, scopedFlags));
|
|
724
|
+
: formatCliText(canonical, result, params, execution, scopedFlags), params, flags.json, execution.note);
|
|
673
725
|
}
|
|
674
726
|
|
|
675
727
|
// ============================================================================
|
|
@@ -737,11 +789,12 @@ function runProjectCommand(rootDir, command, arg) {
|
|
|
737
789
|
if (!publicExecution.ok) {
|
|
738
790
|
fail(formatSurfaceMessage(publicExecution.error, 'cli'));
|
|
739
791
|
}
|
|
740
|
-
|
|
792
|
+
emitCliText(flags.json
|
|
741
793
|
? output.formatPublicJson(canonical, publicExecution.result, publicParams, {
|
|
742
794
|
...publicExecution, surface: 'cli',
|
|
743
795
|
})
|
|
744
|
-
: formatCliText(canonical, publicExecution.result, publicParams, publicExecution, flags)
|
|
796
|
+
: formatCliText(canonical, publicExecution.result, publicParams, publicExecution, flags),
|
|
797
|
+
publicParams, flags.json, publicExecution.note);
|
|
745
798
|
// A gate that could not run (check outside git / bad base ref) must not
|
|
746
799
|
// exit 0 — CI gating on the exit code would read "could not run" as "passed".
|
|
747
800
|
process.exitCode = Math.max(process.exitCode || 0,
|
|
@@ -750,7 +803,7 @@ function runProjectCommand(rootDir, command, arg) {
|
|
|
750
803
|
if (!(e instanceof CommandError)) {
|
|
751
804
|
emitCliError(`Error: ${e.message}`);
|
|
752
805
|
}
|
|
753
|
-
process.exitCode = 1;
|
|
806
|
+
process.exitCode = flags.lines || flags.raw ? 2 : 1;
|
|
754
807
|
} finally {
|
|
755
808
|
// Save cache after command execution so callsCache populated
|
|
756
809
|
// by findCallers/findCallees gets persisted to disk.
|
|
@@ -760,6 +813,9 @@ function runProjectCommand(rootDir, command, arg) {
|
|
|
760
813
|
if (flags.cache && (needsCacheSave || index.callsCacheDirty || index.reachabilityDirty || index.computedDispatchDirty)) {
|
|
761
814
|
try { index.saveCache(); } catch (e) { /* best-effort */ }
|
|
762
815
|
}
|
|
816
|
+
if (flags.cache && index.usageCacheDirty) {
|
|
817
|
+
try { index.saveUsageCache(); } catch (e) { /* best-effort */ }
|
|
818
|
+
}
|
|
763
819
|
}
|
|
764
820
|
}
|
|
765
821
|
|
|
@@ -793,11 +849,12 @@ function runGlobCommand(pattern, command, arg) {
|
|
|
793
849
|
if (!publicExecution.ok) {
|
|
794
850
|
fail(formatSurfaceMessage(publicExecution.error, 'cli'));
|
|
795
851
|
}
|
|
796
|
-
|
|
852
|
+
emitCliText(flags.json
|
|
797
853
|
? output.formatPublicJson(canonical, publicExecution.result, publicParams, {
|
|
798
854
|
...publicExecution, surface: 'cli',
|
|
799
855
|
})
|
|
800
|
-
: formatCliText(canonical, publicExecution.result, publicParams, publicExecution, flags)
|
|
856
|
+
: formatCliText(canonical, publicExecution.result, publicParams, publicExecution, flags),
|
|
857
|
+
publicParams, flags.json, publicExecution.note);
|
|
801
858
|
process.exitCode = Math.max(process.exitCode || 0,
|
|
802
859
|
resultExitCode(canonical, publicExecution.result));
|
|
803
860
|
}
|
|
@@ -857,6 +914,13 @@ Common flags:
|
|
|
857
914
|
--range=N-M (source with --file=PATH)
|
|
858
915
|
--base=REF --staged --no-cache --clear-cache [--all] --max-files=N --workers=N
|
|
859
916
|
--max-chars=N (text output; default 10K targeted / 3K broad, ceiling 100K)
|
|
917
|
+
--lines find/usages/search/show/impact: grep -n shape, one path:line:text
|
|
918
|
+
record per line (tags after a tab: # unverified: <reason>, # import,
|
|
919
|
+
# callee); accounting and notes go to stderr as "# " lines; exit 1
|
|
920
|
+
when nothing matched; exit 2 on errors. No default result cap.
|
|
921
|
+
show defaults to callers; --sections=callers,callees selects bands.
|
|
922
|
+
--raw source: full code, no header or gutter (including large classes).
|
|
923
|
+
Shell modes fail before output if an explicit --max-chars is exceeded.
|
|
860
924
|
Cache: per-user by default; set UCN_CACHE_DIR to override the cache root.
|
|
861
925
|
|
|
862
926
|
Accepted flags by command:
|
package/core/account.js
CHANGED
|
@@ -36,14 +36,16 @@
|
|
|
36
36
|
* Ground-set semantics are grep `-n -w`: unit is the (file, line) pair, each
|
|
37
37
|
* line with >= 1 word-boundary match counts once, case-sensitive.
|
|
38
38
|
*
|
|
39
|
-
* Performance: the ground scan is one `includes()`-gated read per project
|
|
40
|
-
*
|
|
41
|
-
*
|
|
39
|
+
* Performance: the first ground scan is one `includes()`-gated read per project
|
|
40
|
+
* file — the same I/O profile as the existing `search`/`usages` commands.
|
|
41
|
+
* Exact results are retained in a small LRU for the lifetime of one built index
|
|
42
|
+
* so context/about/impact projections of the same symbol do not rescan the
|
|
43
|
+
* project. Deriving counts from callsCache (zero reads) was rejected because
|
|
42
44
|
* comments/strings/references are not in the calls cache and the contract's
|
|
43
45
|
* ground set is text-defined. AST parsing (the expensive part) is restricted
|
|
44
|
-
* to files containing UNCLAIMED ground lines, via the
|
|
45
|
-
* `index._getCachedUsages`.
|
|
46
|
-
*
|
|
46
|
+
* to files containing UNCLAIMED ground lines, via the content-hash-keyed
|
|
47
|
+
* `index._getCachedUsages`. Build invalidation and cache bounds preserve the
|
|
48
|
+
* same answer without retaining an unbounded repository mirror.
|
|
47
49
|
*/
|
|
48
50
|
|
|
49
51
|
'use strict';
|
|
@@ -75,6 +77,12 @@ const UNSUPPORTED_SITE_TEXT_MAX = 160;
|
|
|
75
77
|
* }}
|
|
76
78
|
*/
|
|
77
79
|
function computeGroundSet(index, name) {
|
|
80
|
+
if (index._groundSetCache?.has(name)) {
|
|
81
|
+
const cached = index._groundSetCache.get(name);
|
|
82
|
+
index._groundSetCache.delete(name);
|
|
83
|
+
index._groundSetCache.set(name, cached);
|
|
84
|
+
return cached.result;
|
|
85
|
+
}
|
|
78
86
|
const wordRe = new RegExp('\\b' + escapeRegExp(name) + '\\b');
|
|
79
87
|
const perFile = new Map();
|
|
80
88
|
let total = 0;
|
|
@@ -117,7 +125,7 @@ function computeGroundSet(index, name) {
|
|
|
117
125
|
? index.discoveryIssues.map(issue => ({ ...issue })) : [];
|
|
118
126
|
unreadableFiles.sort();
|
|
119
127
|
|
|
120
|
-
|
|
128
|
+
const result = {
|
|
121
129
|
total: total + unparsed.lines + unsupported.lines,
|
|
122
130
|
fileCount: fileCount + unparsed.fileCount + unsupported.fileCount,
|
|
123
131
|
perFile,
|
|
@@ -126,6 +134,21 @@ function computeGroundSet(index, name) {
|
|
|
126
134
|
unreadableFiles,
|
|
127
135
|
skippedSources,
|
|
128
136
|
};
|
|
137
|
+
if (index._groundSetCache) {
|
|
138
|
+
const weight = result.total + result.fileCount;
|
|
139
|
+
index._groundSetCache.set(name, { result, weight });
|
|
140
|
+
index._groundSetCacheLines = (index._groundSetCacheLines || 0) + weight;
|
|
141
|
+
const maxNames = 64;
|
|
142
|
+
const maxLines = 100000;
|
|
143
|
+
while (index._groundSetCache.size > maxNames ||
|
|
144
|
+
index._groundSetCacheLines > maxLines) {
|
|
145
|
+
const oldest = index._groundSetCache.entries().next().value;
|
|
146
|
+
if (!oldest) break;
|
|
147
|
+
index._groundSetCache.delete(oldest[0]);
|
|
148
|
+
index._groundSetCacheLines -= oldest[1].weight;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return result;
|
|
129
152
|
}
|
|
130
153
|
|
|
131
154
|
/** Scan only files the parser/index could not ingest. */
|
|
@@ -300,7 +323,12 @@ function classifyGroundLines(index, name, groundSet, claimedKeys) {
|
|
|
300
323
|
|
|
301
324
|
// Remainder: AST usage scan distinguishes import/definition/reference
|
|
302
325
|
// from comment/string/skipped-token lines.
|
|
303
|
-
|
|
326
|
+
// Call lines were classified from the complete calls cache above.
|
|
327
|
+
// Language adapters may skip usage-only call recovery here; notably,
|
|
328
|
+
// C/C++ avoids reparsing every matching macro replacement list.
|
|
329
|
+
const usages = index._getCachedUsages(filePath, name, {
|
|
330
|
+
skipCallRecovery: true,
|
|
331
|
+
});
|
|
304
332
|
const byLine = new Map();
|
|
305
333
|
if (Array.isArray(usages)) {
|
|
306
334
|
for (const u of usages) {
|
package/core/cache.js
CHANGED
|
@@ -26,8 +26,9 @@ const CACHE_PRUNE_INTERVAL_MS = 60 * 60 * 1000;
|
|
|
26
26
|
const CACHE_MAX_PROJECTS = 128;
|
|
27
27
|
const CACHE_MAX_BYTES = 1024 * 1024 * 1024;
|
|
28
28
|
|
|
29
|
-
function discoveryRulesHash(root) {
|
|
30
|
-
|
|
29
|
+
function discoveryRulesHash(root, patterns = null) {
|
|
30
|
+
const rules = patterns || parseGitignore(root);
|
|
31
|
+
return crypto.createHash('md5').update(rules.join('\0')).digest('hex');
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
/**
|
|
@@ -648,7 +649,134 @@ function clearAllCaches() {
|
|
|
648
649
|
// reporting can classify import-time vs lazy edges from fresh and cached
|
|
649
650
|
// indexes; C# properties retain property identity instead of masquerading as
|
|
650
651
|
// ordinary fields for accessor impact/refactoring.
|
|
651
|
-
|
|
652
|
+
// v190: members of Rust generic impl owners retain ownerGenerics so blanket
|
|
653
|
+
// impl parameters (`impl<I> Trait for I`) cannot be mistaken for concrete
|
|
654
|
+
// receiver types after a cache round-trip (fix #302).
|
|
655
|
+
// v191: Python call records retain untyped loop-element provenance so a
|
|
656
|
+
// same-spelled project method cannot gain confirmed identity after reload.
|
|
657
|
+
// v192: JS/TS callback records carry moduleLocalBinding so dynamically
|
|
658
|
+
// produced module values cannot borrow target identity from file imports.
|
|
659
|
+
// v193: overload-heavy JS/TS class-member aliases materialize when every
|
|
660
|
+
// declared return has one concrete runtime head, changing indexed symbols.
|
|
661
|
+
// v194: JS/TS static fields preserve direct same-file callable forwarding for
|
|
662
|
+
// immutable module/export aliases (fix #313).
|
|
663
|
+
// v195: JS/TS call records retain safe ordered namespace-spread receiver
|
|
664
|
+
// compositions for exact module export ownership (fix #314).
|
|
665
|
+
// v196: TS parameter type references no longer mark namespace receiver roots
|
|
666
|
+
// as locally shadowed, changing receiverLocalBinding evidence (fix #315).
|
|
667
|
+
// v197 (fix #317): JS/TS expression-bodied arrow symbols persist the exact
|
|
668
|
+
// returned call span so query-time flow can resolve compiler-inferred factory
|
|
669
|
+
// results without treating block bodies or arbitrary expressions as returns.
|
|
670
|
+
// v198 (fix #318): JS/TS one-return methods persist exact this-field paths for
|
|
671
|
+
// generic declared-field result flow.
|
|
672
|
+
// v199 (fix #321): Python call records persist receiver types derived from
|
|
673
|
+
// exact, scope-local class-value aliases such as `_Segment = Segment`.
|
|
674
|
+
// v200 (fix #323): Python dotted module calls persist their exact import
|
|
675
|
+
// specifier (`import rich.repr` → `rich.repr.auto()`).
|
|
676
|
+
// v201 (fix #324): Python method-call records persist simple subscript roots
|
|
677
|
+
// and their local type provenance for `items[key].method()` dispatch; local
|
|
678
|
+
// callable aliases/non-callable bindings no longer leak across functions.
|
|
679
|
+
// v202 (fix #325): Python call records preserve exact typed subscript sources
|
|
680
|
+
// across one local assignment (`item = items[key]; item.method()`).
|
|
681
|
+
// v203 (fix #326): Python call records type stable, direct module constructor
|
|
682
|
+
// globals even when their functions are declared before the assignment.
|
|
683
|
+
// v204 (fix #328): Python union-alias symbols retain their concrete members
|
|
684
|
+
// so imported annotations can participate in exact conditional narrowing.
|
|
685
|
+
// v205 (fix #332): Go method-call records retain capture-aware lexical scope
|
|
686
|
+
// chains so return-type flow reaches nested closures without crossing shadows.
|
|
687
|
+
// v206 (fix #333): Go calls assigned in `var` declarations retain their
|
|
688
|
+
// declaration targets for compiler-return-type receiver flow.
|
|
689
|
+
// v207 (fix #334): Go method-call records retain compiler-exact receiver
|
|
690
|
+
// types for values bound from declared map, slice, and array indexes.
|
|
691
|
+
// v208 (fix #335): Go indexed-value calls preserve receiver-root/field
|
|
692
|
+
// provenance so sibling-file container declarations resolve query-time.
|
|
693
|
+
// v210 (fixes #337-#339): importDetails persisted for every language (was Python-only),
|
|
694
|
+
// require(path.join(__dirname, ...)) composes to a static relative specifier,
|
|
695
|
+
// and import records carry deferredReason (function-local / type-checking / type-only).
|
|
696
|
+
// v211: path-utility composition requires unshadowed binding evidence; mixed
|
|
697
|
+
// default/type imports and inline type re-exports preserve execution timing.
|
|
698
|
+
// Python TYPE_CHECKING guards require typing ownership and no rebinding.
|
|
699
|
+
// v212 (fix #342): extendsGraph/extendedByGraph no longer persisted (rebuilt on load).
|
|
700
|
+
const CACHE_FORMAT_VERSION = 212;
|
|
701
|
+
const USAGE_CACHE_FILE = 'usage-results.json';
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* Persist the small hot-name usage cache independently of index/call shards.
|
|
705
|
+
* This keeps one-shot CLI repeats fast without rewriting the whole project
|
|
706
|
+
* cache after every read-only account query.
|
|
707
|
+
*/
|
|
708
|
+
function saveUsageCache(index, cachePath) {
|
|
709
|
+
if (!index.usageCacheDirty) return null;
|
|
710
|
+
const cacheDir = cachePath
|
|
711
|
+
? path.dirname(cachePath)
|
|
712
|
+
: getProjectCacheDir(index.root);
|
|
713
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
714
|
+
const entries = [];
|
|
715
|
+
for (const [key, cached] of index._usageResultCache || []) {
|
|
716
|
+
const parts = key.split('\0');
|
|
717
|
+
if (parts.length < 3 || !Array.isArray(cached?.value)) continue;
|
|
718
|
+
const [filePath, fileHash, name, mode = ''] = parts;
|
|
719
|
+
const fileEntry = index.files.get(filePath);
|
|
720
|
+
// Drop removed/changed-file generations rather than carrying dead LRU
|
|
721
|
+
// weight across incremental builds.
|
|
722
|
+
if (!fileEntry || fileEntry.hash !== fileHash) continue;
|
|
723
|
+
entries.push([
|
|
724
|
+
path.relative(index.root, filePath), fileHash, name, mode,
|
|
725
|
+
cached.value,
|
|
726
|
+
]);
|
|
727
|
+
}
|
|
728
|
+
const usageFile = path.join(cacheDir, USAGE_CACHE_FILE);
|
|
729
|
+
const tmpFile = usageFile + '.tmp';
|
|
730
|
+
fs.writeFileSync(tmpFile, JSON.stringify({
|
|
731
|
+
version: CACHE_FORMAT_VERSION,
|
|
732
|
+
ucnVersion: UCN_VERSION,
|
|
733
|
+
entries,
|
|
734
|
+
}));
|
|
735
|
+
fs.renameSync(tmpFile, usageFile);
|
|
736
|
+
index.usageCacheDirty = false;
|
|
737
|
+
return usageFile;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function loadUsageCache(index, cacheFile) {
|
|
741
|
+
const usageFile = path.join(path.dirname(cacheFile), USAGE_CACHE_FILE);
|
|
742
|
+
if (!fs.existsSync(usageFile)) return false;
|
|
743
|
+
try {
|
|
744
|
+
const payload = JSON.parse(fs.readFileSync(usageFile, 'utf-8'));
|
|
745
|
+
if (payload.version !== CACHE_FORMAT_VERSION ||
|
|
746
|
+
payload.ucnVersion !== UCN_VERSION ||
|
|
747
|
+
!Array.isArray(payload.entries)) return false;
|
|
748
|
+
const restored = new Map();
|
|
749
|
+
let weightTotal = 0;
|
|
750
|
+
for (const entry of payload.entries) {
|
|
751
|
+
if (!Array.isArray(entry) || entry.length < 5) continue;
|
|
752
|
+
const [relativePath, fileHash, name, mode, value] = entry;
|
|
753
|
+
if (typeof relativePath !== 'string' ||
|
|
754
|
+
typeof fileHash !== 'string' ||
|
|
755
|
+
typeof name !== 'string' || typeof mode !== 'string' ||
|
|
756
|
+
!Array.isArray(value)) continue;
|
|
757
|
+
const filePath = path.resolve(index.root, relativePath);
|
|
758
|
+
const fileEntry = index.files.get(filePath);
|
|
759
|
+
if (!fileEntry || fileEntry.hash !== fileHash) continue;
|
|
760
|
+
const suffix = mode ? `\0${mode}` : '';
|
|
761
|
+
const key = `${filePath}\0${fileHash}\0${name}${suffix}`;
|
|
762
|
+
const weight = 64 + value.length * 40;
|
|
763
|
+
restored.set(key, { value, weight });
|
|
764
|
+
weightTotal += weight;
|
|
765
|
+
while (restored.size > 4096 || weightTotal > 16 * 1024 * 1024) {
|
|
766
|
+
const oldest = restored.entries().next().value;
|
|
767
|
+
if (!oldest) break;
|
|
768
|
+
restored.delete(oldest[0]);
|
|
769
|
+
weightTotal -= oldest[1].weight;
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
index._usageResultCache = restored;
|
|
773
|
+
index._usageResultCacheWeight = weightTotal;
|
|
774
|
+
index.usageCacheDirty = false;
|
|
775
|
+
return true;
|
|
776
|
+
} catch (_) {
|
|
777
|
+
return false;
|
|
778
|
+
}
|
|
779
|
+
}
|
|
652
780
|
|
|
653
781
|
/**
|
|
654
782
|
* Save index to cache file
|
|
@@ -780,9 +908,13 @@ function saveCache(index, cachePath) {
|
|
|
780
908
|
symbols: strippedSymbols,
|
|
781
909
|
importGraph: relGraph(index.importGraph),
|
|
782
910
|
exportGraph: relGraph(index.exportGraph),
|
|
783
|
-
// extendsGraph/extendedByGraph
|
|
784
|
-
|
|
785
|
-
|
|
911
|
+
// extendsGraph/extendedByGraph are NOT persisted (fix #342): their
|
|
912
|
+
// entries carry absolute file paths in the build-time spelling, and
|
|
913
|
+
// the cache key is realpath-normalized — a root reached through a
|
|
914
|
+
// symlink (/var → /private/var on macOS) loaded a graph whose files
|
|
915
|
+
// matched nothing, so an overriding Go embed read as a non-overriding
|
|
916
|
+
// subclass and confirmed two false callers. loadCache rebuilds both
|
|
917
|
+
// from the rehydrated symbols (8ms on 1037 files).
|
|
786
918
|
failedFiles: index.failedFiles
|
|
787
919
|
? Array.from(index.failedFiles).map(f => path.relative(root, f))
|
|
788
920
|
: [],
|
|
@@ -926,8 +1058,14 @@ function loadCache(index, cachePath) {
|
|
|
926
1058
|
: (relPath) => rootPrefix + relPath.replace(/\//g, path.sep);
|
|
927
1059
|
|
|
928
1060
|
// Loading into a previously-used ProjectIndex replaces its indexed
|
|
929
|
-
// contents, so no parsed tree
|
|
1061
|
+
// contents, so no parsed tree or cross-file derived query answer from
|
|
1062
|
+
// the old state may survive. Usage results are content-hash keyed and
|
|
1063
|
+
// remain safe; these caches are graph/text-universe keyed instead.
|
|
930
1064
|
index._clearParsedTreeCache?.();
|
|
1065
|
+
index._groundSetCache = new Map();
|
|
1066
|
+
index._groundSetCacheLines = 0;
|
|
1067
|
+
index._nameBindingReachCache = new Map();
|
|
1068
|
+
index._returnTypeFlowCache = new Map();
|
|
931
1069
|
|
|
932
1070
|
// Reconstruct files Map: relative key → absolute key, restore path and relativePath
|
|
933
1071
|
// Initialize symbols/bindings arrays (will be populated from top-level symbols)
|
|
@@ -989,13 +1127,8 @@ function loadCache(index, cachePath) {
|
|
|
989
1127
|
index.buildTime = cacheData.buildTime;
|
|
990
1128
|
|
|
991
1129
|
// Restore optional graphs if present
|
|
992
|
-
// extendsGraph/extendedByGraph
|
|
993
|
-
|
|
994
|
-
index.extendsGraph = new Map(cacheData.extendsGraph);
|
|
995
|
-
}
|
|
996
|
-
if (Array.isArray(cacheData.extendedByGraph)) {
|
|
997
|
-
index.extendedByGraph = new Map(cacheData.extendedByGraph);
|
|
998
|
-
}
|
|
1130
|
+
// extendsGraph/extendedByGraph are derived below from the rehydrated
|
|
1131
|
+
// symbols (fix #342) — never read from the payload.
|
|
999
1132
|
|
|
1000
1133
|
// Prepare lazy calls cache loading — load manifest but defer shard parsing.
|
|
1001
1134
|
// Shards are loaded on first getCachedCalls access via ensureCallsCacheLoaded().
|
|
@@ -1067,13 +1200,18 @@ function loadCache(index, cachePath) {
|
|
|
1067
1200
|
}
|
|
1068
1201
|
}
|
|
1069
1202
|
|
|
1070
|
-
// Only rebuild
|
|
1203
|
+
// Only rebuild the import graph if config changed (e.g., aliases
|
|
1204
|
+
// modified); it is persisted with relative paths. The inheritance
|
|
1205
|
+
// graph is always derived from the rehydrated symbols (fix #342) so
|
|
1206
|
+
// its file paths agree with index.root whatever spelling built it.
|
|
1071
1207
|
const currentConfigHash = crypto.createHash('md5')
|
|
1072
1208
|
.update(JSON.stringify(index.config || {})).digest('hex');
|
|
1073
1209
|
if (currentConfigHash !== cacheData.configHash) {
|
|
1074
1210
|
index.buildImportGraph();
|
|
1075
|
-
index.buildInheritanceGraph();
|
|
1076
1211
|
}
|
|
1212
|
+
index.buildInheritanceGraph();
|
|
1213
|
+
|
|
1214
|
+
loadUsageCache(index, cacheFile);
|
|
1077
1215
|
|
|
1078
1216
|
return true;
|
|
1079
1217
|
} catch (e) {
|
|
@@ -1092,9 +1230,17 @@ function isCacheStale(index) {
|
|
|
1092
1230
|
if (index._loadedConfigHash && currentConfigHash !== index._loadedConfigHash) {
|
|
1093
1231
|
return true;
|
|
1094
1232
|
}
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1233
|
+
// Parse gitignore rules once for both the discovery fingerprint and the
|
|
1234
|
+
// new-file walk below. On a fresh cache these used to run two identical
|
|
1235
|
+
// `git ls-files` subprocesses per one-shot CLI invocation, accounting for
|
|
1236
|
+
// roughly a third of warm-start staleness time on measured repositories.
|
|
1237
|
+
let gitignorePatterns = null;
|
|
1238
|
+
if (index._loadedDiscoveryHash) {
|
|
1239
|
+
gitignorePatterns = parseGitignore(index.root);
|
|
1240
|
+
if (discoveryRulesHash(index.root, gitignorePatterns) !==
|
|
1241
|
+
index._loadedDiscoveryHash) {
|
|
1242
|
+
return true;
|
|
1243
|
+
}
|
|
1098
1244
|
}
|
|
1099
1245
|
// Modified/deleted detection (stat sweep) runs UNCONDITIONALLY — agents
|
|
1100
1246
|
// edit a file and re-query through MCP within seconds, and a stale answer
|
|
@@ -1153,7 +1299,7 @@ function isCacheStale(index) {
|
|
|
1153
1299
|
});
|
|
1154
1300
|
},
|
|
1155
1301
|
};
|
|
1156
|
-
|
|
1302
|
+
if (!gitignorePatterns) gitignorePatterns = parseGitignore(index.root);
|
|
1157
1303
|
globOpts.gitignorePatterns = gitignorePatterns;
|
|
1158
1304
|
globOpts.trackedPaths = gitTrackedPaths(index.root);
|
|
1159
1305
|
const configExclude = index.config.exclude || [];
|
|
@@ -1347,7 +1493,7 @@ function _computeReachabilityFingerprint(index) {
|
|
|
1347
1493
|
}
|
|
1348
1494
|
|
|
1349
1495
|
module.exports = {
|
|
1350
|
-
saveCache, loadCache, loadCallsCache, isCacheStale, ensureCallsCacheLoaded,
|
|
1496
|
+
saveCache, saveUsageCache, loadCache, loadCallsCache, isCacheStale, ensureCallsCacheLoaded,
|
|
1351
1497
|
getUserCacheRoot, getProjectCacheDir, getProjectCachePath,
|
|
1352
1498
|
getLegacyProjectCacheDir, migrateLegacyProjectCache, clearProjectCache,
|
|
1353
1499
|
clearAllCaches, pruneUserCache,
|