ucn 4.0.2 → 4.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/ucn/SKILL.md +31 -7
- package/README.md +90 -51
- package/cli/index.js +199 -94
- package/core/account.js +3 -1
- package/core/analysis.js +322 -304
- package/core/bridge.js +13 -8
- package/core/cache.js +109 -19
- package/core/callers.js +969 -77
- package/core/check.js +19 -8
- package/core/deadcode.js +368 -40
- package/core/discovery.js +31 -11
- package/core/entrypoints.js +149 -17
- package/core/execute.js +330 -43
- package/core/graph-build.js +61 -10
- package/core/graph.js +282 -61
- package/core/imports.js +72 -3
- package/core/output/analysis-ext.js +70 -10
- package/core/output/analysis.js +52 -33
- package/core/output/check.js +4 -1
- package/core/output/endpoints.js +8 -3
- package/core/output/extraction.js +12 -1
- package/core/output/find.js +23 -9
- package/core/output/graph.js +32 -9
- package/core/output/refactoring.js +147 -49
- package/core/output/reporting.js +104 -5
- package/core/output/search.js +30 -3
- package/core/output/shared.js +31 -4
- package/core/output/tracing.js +22 -11
- package/core/parser.js +8 -6
- package/core/project.js +167 -7
- package/core/registry.js +20 -16
- package/core/reporting.js +131 -13
- package/core/search.js +270 -55
- package/core/shared.js +240 -1
- package/core/stacktrace.js +23 -1
- package/core/tracing.js +278 -36
- package/core/verify.js +352 -349
- package/languages/go.js +29 -17
- package/languages/index.js +56 -0
- package/languages/java.js +133 -16
- package/languages/javascript.js +215 -27
- package/languages/python.js +113 -47
- package/languages/rust.js +89 -26
- package/languages/utils.js +35 -7
- package/mcp/server.js +69 -30
- package/package.json +5 -1
package/core/output/shared.js
CHANGED
|
@@ -9,13 +9,17 @@ const fs = require('fs');
|
|
|
9
9
|
const { langTraits } = require('../../languages');
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
|
-
* Format dynamic imports note with language-appropriate terminology
|
|
13
|
-
* Go
|
|
12
|
+
* Format dynamic imports note with language-appropriate terminology:
|
|
13
|
+
* Go marks blank/dot imports, Rust marks glob imports (use foo::*) — both
|
|
14
|
+
* are name-resolution blind spots, neither is a "dynamic import".
|
|
14
15
|
*/
|
|
15
16
|
function dynamicImportsNote(count, meta) {
|
|
16
17
|
if (!count) return null;
|
|
17
|
-
|
|
18
|
-
|
|
18
|
+
const lang = meta?.projectLanguage;
|
|
19
|
+
if (lang && !langTraits(lang)?.hasDynamicImports) {
|
|
20
|
+
return lang === 'rust'
|
|
21
|
+
? `${count} glob import(s)`
|
|
22
|
+
: `${count} blank/dot import(s)`;
|
|
19
23
|
}
|
|
20
24
|
return `${count} dynamic import(s)`;
|
|
21
25
|
}
|
|
@@ -85,6 +89,11 @@ function formatFunctionSignature(fn) {
|
|
|
85
89
|
prefix.push(fn.modifiers.join(' '));
|
|
86
90
|
}
|
|
87
91
|
|
|
92
|
+
// Methods carry isAsync without an 'async' modifier entry (fix #252:
|
|
93
|
+
// `pub async fn get` and TS async methods rendered without the
|
|
94
|
+
// qualifier while standalone functions kept it).
|
|
95
|
+
if (fn.isAsync && !(fn.modifiers || []).includes('async')) prefix.push('async');
|
|
96
|
+
|
|
88
97
|
// Generator marker
|
|
89
98
|
if (fn.isGenerator) prefix.push('*');
|
|
90
99
|
|
|
@@ -336,7 +345,25 @@ function unverifiedReasonLabel(entry) {
|
|
|
336
345
|
return entry.reason;
|
|
337
346
|
}
|
|
338
347
|
|
|
348
|
+
// Advisory label line for heuristic commands (v4 two-tier surface: contracted
|
|
349
|
+
// commands carry accounts; advisory commands say so explicitly, in text AND
|
|
350
|
+
// via the result's `advisory` field for JSON consumers).
|
|
351
|
+
const ADVISORY_LABELS = {
|
|
352
|
+
'similarity-heuristics': 'ranked by similarity heuristics (same file, shared callers/callees, name overlap)',
|
|
353
|
+
'scored-selection': 'examples picked by usage-quality scoring',
|
|
354
|
+
'best-effort-frame-matching': 'frames matched by path similarity',
|
|
355
|
+
'heuristic-route-matching': 'route↔request matches are heuristic (see per-match EXACT/PARTIAL/UNCERTAIN tiers)',
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
/** Render the advisory line for a result's `advisory` field, or null. */
|
|
359
|
+
function advisoryLine(advisory) {
|
|
360
|
+
if (!advisory) return null;
|
|
361
|
+
const desc = ADVISORY_LABELS[advisory] || advisory;
|
|
362
|
+
return `Advisory: ${desc} — suggestions, not verified claims.`;
|
|
363
|
+
}
|
|
364
|
+
|
|
339
365
|
module.exports = {
|
|
366
|
+
advisoryLine,
|
|
340
367
|
dynamicImportsNote,
|
|
341
368
|
formatFileError,
|
|
342
369
|
unverifiedReasonLabel,
|
package/core/output/tracing.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
const { unverifiedReasonLabel } = require('./shared');
|
|
12
|
+
const { codeUnitCompare } = require('../shared');
|
|
12
13
|
const { formatAccountLines } = require('./analysis');
|
|
13
14
|
|
|
14
15
|
/**
|
|
@@ -56,10 +57,10 @@ function renderUnverifiedCallees(lines, node, prefix, isParentLast) {
|
|
|
56
57
|
function treeAccountLine(ta) {
|
|
57
58
|
if (!ta) return null;
|
|
58
59
|
const reasons = Object.entries(ta.unverifiedByReason || {})
|
|
59
|
-
.sort((a, b) => a[0]
|
|
60
|
+
.sort((a, b) => codeUnitCompare(a[0], b[0]))
|
|
60
61
|
.map(([r, n]) => `${n} ${r}`).join(', ');
|
|
61
62
|
const excludedReasons = Object.entries(ta.excludedByReason || {})
|
|
62
|
-
.sort((a, b) => a[0]
|
|
63
|
+
.sort((a, b) => codeUnitCompare(a[0], b[0]))
|
|
63
64
|
.map(([r, n]) => `${n} ${r}`).join(', ');
|
|
64
65
|
let line = `TREE ACCOUNT: ${ta.nodesExpanded} node${ta.nodesExpanded === 1 ? '' : 's'} expanded · ` +
|
|
65
66
|
`${ta.confirmedEdges} confirmed edge${ta.confirmedEdges === 1 ? '' : 's'} · ` +
|
|
@@ -75,7 +76,7 @@ function calleeAccountLine(ta) {
|
|
|
75
76
|
if (!ta || !ta.callSites) return null;
|
|
76
77
|
const cs = ta.callSites;
|
|
77
78
|
const reasons = Object.entries(ta.unverifiedByReason || {})
|
|
78
|
-
.sort((a, b) => a[0]
|
|
79
|
+
.sort((a, b) => codeUnitCompare(a[0], b[0]))
|
|
79
80
|
.map(([r, n]) => `${n} ${r}`).join(', ');
|
|
80
81
|
let line = `CALLEE ACCOUNT: ${ta.nodesExpanded} node${ta.nodesExpanded === 1 ? '' : 's'} expanded · ` +
|
|
81
82
|
`${cs.total} call site${cs.total === 1 ? '' : 's'} = ${cs.confirmed} confirmed + ` +
|
|
@@ -207,8 +208,11 @@ function formatTrace(trace, options = {}) {
|
|
|
207
208
|
lines.push(`\nSome results truncated. ${allHint}`);
|
|
208
209
|
}
|
|
209
210
|
|
|
210
|
-
|
|
211
|
-
|
|
211
|
+
// Only claim filtering when the account actually filtered edges — the
|
|
212
|
+
// flag is a no-op for languages where method callees are always analyzed.
|
|
213
|
+
const traceFiltered = (trace.treeAccount?.callSites?.filtered ?? trace.treeAccount?.filteredEdges ?? 0);
|
|
214
|
+
if (trace.includeMethods === false && traceFiltered > 0) {
|
|
215
|
+
const methodsHint = options.methodsHint || `Note: ${traceFiltered} obj.method() callee edge(s) hidden (counted as filtered in the account). Use --include-methods to show them.`;
|
|
212
216
|
lines.push(`\n${methodsHint}`);
|
|
213
217
|
}
|
|
214
218
|
|
|
@@ -339,8 +343,9 @@ function formatBlast(blast, options = {}) {
|
|
|
339
343
|
lines.push(`\nSome results truncated. ${allHint}`);
|
|
340
344
|
}
|
|
341
345
|
|
|
342
|
-
|
|
343
|
-
|
|
346
|
+
const blastFiltered = (blast.treeAccount?.filteredEdges ?? 0);
|
|
347
|
+
if (blast.includeMethods === false && blastFiltered > 0) {
|
|
348
|
+
lines.push(`\nNote: ${blastFiltered} obj.method() caller edge(s) hidden (counted as filtered in the account). Use --include-methods to show them.`);
|
|
344
349
|
}
|
|
345
350
|
|
|
346
351
|
return lines.join('\n');
|
|
@@ -459,9 +464,14 @@ function formatReverseTrace(result, options = {}) {
|
|
|
459
464
|
if (result.summary) {
|
|
460
465
|
lines.push('');
|
|
461
466
|
const { totalEntryPoints, totalFunctions, unverifiedEdges } = result.summary;
|
|
467
|
+
// totalFunctions counts every traversed node incl. the entry points
|
|
468
|
+
// themselves — "intermediate" excludes them (fix #237: '4 entry
|
|
469
|
+
// points reach X through 6 intermediate functions' counted the 4
|
|
470
|
+
// entry points among the 6).
|
|
471
|
+
const intermediates = Math.max(0, totalFunctions - (totalEntryPoints || 0));
|
|
462
472
|
let s;
|
|
463
473
|
if (totalFunctions > 0) {
|
|
464
|
-
s = `Summary: ${totalEntryPoints} entry point${totalEntryPoints !== 1 ? 's' : ''} reach${totalEntryPoints === 1 ? 'es' : ''} ${result.root} through ${
|
|
474
|
+
s = `Summary: ${totalEntryPoints} entry point${totalEntryPoints !== 1 ? 's' : ''} reach${totalEntryPoints === 1 ? 'es' : ''} ${result.root}${intermediates > 0 ? ` through ${intermediates} intermediate function${intermediates !== 1 ? 's' : ''}` : ' directly'}`;
|
|
465
475
|
} else if (unverifiedEdges > 0) {
|
|
466
476
|
s = `Summary: no confirmed callers — ${unverifiedEdges} unverified edge${unverifiedEdges !== 1 ? 's' : ''} (not an entry-point claim)`;
|
|
467
477
|
} else {
|
|
@@ -487,8 +497,9 @@ function formatReverseTrace(result, options = {}) {
|
|
|
487
497
|
lines.push(`\nSome results truncated. ${allHint}`);
|
|
488
498
|
}
|
|
489
499
|
|
|
490
|
-
|
|
491
|
-
|
|
500
|
+
const rtFiltered = (result.treeAccount?.filteredEdges ?? 0);
|
|
501
|
+
if (result.includeMethods === false && rtFiltered > 0) {
|
|
502
|
+
lines.push(`\nNote: ${rtFiltered} obj.method() caller edge(s) hidden (counted as filtered in the account). Use --include-methods to show them.`);
|
|
492
503
|
}
|
|
493
504
|
|
|
494
505
|
return lines.join('\n');
|
|
@@ -516,7 +527,7 @@ function formatAffectedTests(result, options = {}) {
|
|
|
516
527
|
lines.push(`affected-tests: ${result.root}`);
|
|
517
528
|
lines.push('═'.repeat(60));
|
|
518
529
|
lines.push(`${result.file}:${result.line}`);
|
|
519
|
-
lines.push(`1 function changed → ${summary.totalAffected}
|
|
530
|
+
lines.push(`1 function changed → ${summary.totalAffected} function${summary.totalAffected !== 1 ? 's' : ''} affected (depth ${result.depth})`);
|
|
520
531
|
lines.push('');
|
|
521
532
|
|
|
522
533
|
if (result.testFiles.length === 0) {
|
package/core/parser.js
CHANGED
|
@@ -282,13 +282,15 @@ function getExportedSymbols(result) {
|
|
|
282
282
|
*/
|
|
283
283
|
function cleanHtmlScriptTags(lines, language) {
|
|
284
284
|
if (language === 'html' && lines.length > 0) {
|
|
285
|
-
// Strip
|
|
286
|
-
//
|
|
287
|
-
|
|
288
|
-
//
|
|
289
|
-
|
|
285
|
+
// Strip everything up to and including the opening <script ...> tag
|
|
286
|
+
// on the first line — surrounding same-line markup is not code
|
|
287
|
+
// (fix #252: `<div><script>function foo()...` leaked `<div>` into
|
|
288
|
+
// the extraction). Indentation before the tag is kept.
|
|
289
|
+
lines[0] = lines[0].replace(/^(\s*).*<script\b[^>]*>/i, '$1');
|
|
290
|
+
// Strip the closing </script> tag and everything after it on the
|
|
291
|
+
// last line (`...</script></body></html>` leaked trailing markup).
|
|
290
292
|
const last = lines.length - 1;
|
|
291
|
-
lines[last] = lines[last].replace(/<\/script\s
|
|
293
|
+
lines[last] = lines[last].replace(/<\/script\s*>.*$/i, '');
|
|
292
294
|
}
|
|
293
295
|
return lines;
|
|
294
296
|
}
|
package/core/project.js
CHANGED
|
@@ -8,12 +8,12 @@
|
|
|
8
8
|
const fs = require('fs');
|
|
9
9
|
const path = require('path');
|
|
10
10
|
const crypto = require('crypto');
|
|
11
|
-
const { expandGlob, findProjectRoot, detectProjectPattern, isTestFile, parseGitignore, DEFAULT_IGNORES } = require('./discovery');
|
|
11
|
+
const { expandGlob, findProjectRoot, detectProjectPattern, isTestFile, parseGitignore, DEFAULT_IGNORES, compareNames } = require('./discovery');
|
|
12
12
|
const { extractImports, extractExports, resolveImport } = require('./imports');
|
|
13
13
|
const { parse, cleanHtmlScriptTags } = require('./parser');
|
|
14
|
-
const { detectLanguage, getParser, getLanguageModule, safeParse, langTraits } = require('../languages');
|
|
14
|
+
const { detectLanguage, getParser, getLanguageModule, safeParse, langTraits, PARSE_OPTIONS } = require('../languages');
|
|
15
15
|
const { getTokenTypeAtPosition } = require('../languages/utils');
|
|
16
|
-
const { escapeRegExp, NON_CALLABLE_TYPES } = require('./shared');
|
|
16
|
+
const { escapeRegExp, NON_CALLABLE_TYPES, codeUnitCompare } = require('./shared');
|
|
17
17
|
const stacktrace = require('./stacktrace');
|
|
18
18
|
const indexCache = require('./cache');
|
|
19
19
|
const deadcodeModule = require('./deadcode');
|
|
@@ -52,6 +52,8 @@ class ProjectIndex {
|
|
|
52
52
|
this.failedFiles = new Set(); // files that failed to index (e.g. large minified bundles)
|
|
53
53
|
this._opContentCache = null; // per-operation file content cache (Map<filePath, string>)
|
|
54
54
|
this._opUsagesCache = null; // per-operation findUsagesInCode cache (Map<"file:name", usages[]>)
|
|
55
|
+
this._opTreeCache = null; // per-operation parsed-tree cache (Map<filePath, tree|null>, bounded FIFO)
|
|
56
|
+
this._opLinesCache = null; // per-operation split-lines cache (Map<filePath, string[]>, bounded FIFO)
|
|
55
57
|
this.calleeIndex = null; // name -> Set<filePath> — inverted call index (built lazily)
|
|
56
58
|
}
|
|
57
59
|
|
|
@@ -78,6 +80,8 @@ class ProjectIndex {
|
|
|
78
80
|
this._opUsagesCache = new Map();
|
|
79
81
|
this._opCallsCountCache = new Map();
|
|
80
82
|
this._opEnclosingFnCache = new Map();
|
|
83
|
+
this._opTreeCache = new Map();
|
|
84
|
+
this._opLinesCache = new Map();
|
|
81
85
|
this._opDepth = 0;
|
|
82
86
|
}
|
|
83
87
|
this._opDepth++;
|
|
@@ -91,6 +95,8 @@ class ProjectIndex {
|
|
|
91
95
|
this._opUsagesCache = null;
|
|
92
96
|
this._opCallsCountCache = null;
|
|
93
97
|
this._opEnclosingFnCache = null;
|
|
98
|
+
this._opTreeCache = null;
|
|
99
|
+
this._opLinesCache = null;
|
|
94
100
|
// Free cached file content from callsCache entries (retained during
|
|
95
101
|
// operation for _readFile caching, not needed between operations)
|
|
96
102
|
for (const entry of this.callsCache.values()) {
|
|
@@ -100,6 +106,50 @@ class ProjectIndex {
|
|
|
100
106
|
}
|
|
101
107
|
}
|
|
102
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Parse a file once per operation and reuse the tree across symbol names.
|
|
111
|
+
* Multi-symbol commands (diff-impact, check) classify ground lines for MANY
|
|
112
|
+
* names against the SAME files; without this, each (file, name) pair costs a
|
|
113
|
+
* full tree-sitter parse. Bounded FIFO (trees hold native memory).
|
|
114
|
+
* Returns null when parsing fails or no operation cache is active — callers
|
|
115
|
+
* fall back to parsing themselves.
|
|
116
|
+
*/
|
|
117
|
+
_getParsedTree(filePath, content, language) {
|
|
118
|
+
if (!this._opTreeCache) return null;
|
|
119
|
+
const cached = this._opTreeCache.get(filePath);
|
|
120
|
+
if (cached !== undefined) return cached;
|
|
121
|
+
let tree = null;
|
|
122
|
+
try {
|
|
123
|
+
const parser = getParser(language);
|
|
124
|
+
tree = parser ? safeParse(parser, content, undefined, PARSE_OPTIONS) : null;
|
|
125
|
+
} catch (e) {
|
|
126
|
+
tree = null;
|
|
127
|
+
}
|
|
128
|
+
if (this._opTreeCache.size >= 512) {
|
|
129
|
+
this._opTreeCache.delete(this._opTreeCache.keys().next().value);
|
|
130
|
+
}
|
|
131
|
+
this._opTreeCache.set(filePath, tree);
|
|
132
|
+
return tree;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Split a file into lines once per operation. Callers must treat the
|
|
137
|
+
* returned array as read-only — it is shared across the whole operation.
|
|
138
|
+
*/
|
|
139
|
+
_getFileLines(filePath) {
|
|
140
|
+
if (this._opLinesCache) {
|
|
141
|
+
const cached = this._opLinesCache.get(filePath);
|
|
142
|
+
if (cached !== undefined) return cached;
|
|
143
|
+
const lines = this._readFile(filePath).split('\n');
|
|
144
|
+
if (this._opLinesCache.size >= 2048) {
|
|
145
|
+
this._opLinesCache.delete(this._opLinesCache.keys().next().value);
|
|
146
|
+
}
|
|
147
|
+
this._opLinesCache.set(filePath, lines);
|
|
148
|
+
return lines;
|
|
149
|
+
}
|
|
150
|
+
return this._readFile(filePath).split('\n');
|
|
151
|
+
}
|
|
152
|
+
|
|
103
153
|
/**
|
|
104
154
|
* Get findUsagesInCode results with per-operation caching.
|
|
105
155
|
* Avoids redundant tree-sitter parsing when the same (file, name) is queried
|
|
@@ -132,7 +182,12 @@ class ProjectIndex {
|
|
|
132
182
|
|
|
133
183
|
const parser = getParser(lang);
|
|
134
184
|
if (!parser) return null;
|
|
135
|
-
|
|
185
|
+
// Language modules that accept a pre-parsed tree (4th param) reuse
|
|
186
|
+
// the per-operation tree cache; others (html) parse internally.
|
|
187
|
+
const tree = langModule.findUsagesInCode.length >= 4
|
|
188
|
+
? this._getParsedTree(filePath, content, lang)
|
|
189
|
+
: null;
|
|
190
|
+
const usages = langModule.findUsagesInCode(content, name, parser, tree);
|
|
136
191
|
if (this._opUsagesCache) {
|
|
137
192
|
this._opUsagesCache.set(cacheKey, usages);
|
|
138
193
|
}
|
|
@@ -167,6 +222,16 @@ class ProjectIndex {
|
|
|
167
222
|
const startTime = Date.now();
|
|
168
223
|
const quiet = options.quiet !== false;
|
|
169
224
|
|
|
225
|
+
// A (re)build invalidates any cache-loaded reachability set — the
|
|
226
|
+
// fingerprint guard in computeReachability is content-shaped and
|
|
227
|
+
// cannot see every rebuild (fix #249: a stale loaded set survived
|
|
228
|
+
// the rebuild and poisoned "unreachable" notes). Recomputed lazily
|
|
229
|
+
// on the next reachability query.
|
|
230
|
+
if (this._reachableSymbols) {
|
|
231
|
+
this._reachableSymbols = null;
|
|
232
|
+
this._reachableFingerprint = null;
|
|
233
|
+
}
|
|
234
|
+
|
|
170
235
|
// Accept pre-expanded file array (glob mode) or a pattern string
|
|
171
236
|
let files;
|
|
172
237
|
if (Array.isArray(pattern)) {
|
|
@@ -204,6 +269,18 @@ class ProjectIndex {
|
|
|
204
269
|
console.error(`Indexing ${files.length} files in ${this.root}...`);
|
|
205
270
|
}
|
|
206
271
|
|
|
272
|
+
// Materialize a lazily-prepared calls cache BEFORE any indexing: the
|
|
273
|
+
// stale-file path (indexFile → removeFileSymbols) and the deleted-file
|
|
274
|
+
// cleanup below both delete callsCache entries, but with shards still
|
|
275
|
+
// unloaded those deletes are no-ops on an empty map — and the later
|
|
276
|
+
// ensureCallsCacheLoaded() (buildCalleeIndex) RESURRECTED the stale
|
|
277
|
+
// entries, so queries in this process ran against the pre-edit call
|
|
278
|
+
// records (fix #227). Cost-neutral: buildCalleeIndex loads all shards
|
|
279
|
+
// at the end of build() anyway.
|
|
280
|
+
if (this._callsCachePrepared && !this._callsCacheLoaded) {
|
|
281
|
+
indexCache.ensureCallsCacheLoaded(this);
|
|
282
|
+
}
|
|
283
|
+
|
|
207
284
|
let deletedInRebuild = 0;
|
|
208
285
|
if (options.forceRebuild) {
|
|
209
286
|
// Incremental rebuild: only remove files that no longer exist on disk.
|
|
@@ -271,6 +348,11 @@ class ProjectIndex {
|
|
|
271
348
|
}
|
|
272
349
|
}
|
|
273
350
|
|
|
351
|
+
// Canonical order BEFORE derived indexes, so graphs / dir index /
|
|
352
|
+
// callee index inherit it. This is what makes incremental rebuilds
|
|
353
|
+
// byte-equivalent to fresh builds (see _canonicalizeOrder).
|
|
354
|
+
this._canonicalizeOrder();
|
|
355
|
+
|
|
274
356
|
// Skip graph rebuild when incremental rebuild found no changes
|
|
275
357
|
if (changed > 0 || deletedInRebuild > 0 || !options.forceRebuild) {
|
|
276
358
|
this.buildImportGraph();
|
|
@@ -284,6 +366,11 @@ class ProjectIndex {
|
|
|
284
366
|
// avoiding the 2+ minute deferred cost when the first analysis command runs later.
|
|
285
367
|
this.buildCalleeIndex();
|
|
286
368
|
|
|
369
|
+
// buildCalleeIndex re-parses changed files via getCachedCalls, which
|
|
370
|
+
// appends their entries at the callsCache TAIL — restore canonical
|
|
371
|
+
// key order so iteration-order consumers match a fresh build.
|
|
372
|
+
this.callsCache = new Map([...this.callsCache.entries()].sort((a, b) => compareNames(a[0], b[0])));
|
|
373
|
+
|
|
287
374
|
this.buildTime = Date.now() - startTime;
|
|
288
375
|
|
|
289
376
|
if (!quiet) {
|
|
@@ -356,6 +443,11 @@ class ProjectIndex {
|
|
|
356
443
|
}
|
|
357
444
|
// Handle last line (no trailing newline)
|
|
358
445
|
if (content.length - lineStart > 1000) longLineCount++;
|
|
446
|
+
// A trailing newline TERMINATES the last line rather than opening a
|
|
447
|
+
// new one (fix #251: stats reported wc -l + 1 for newline-terminated
|
|
448
|
+
// files); an empty file has 0 lines.
|
|
449
|
+
if (content.length === 0) lineCount = 0;
|
|
450
|
+
else if (content.charCodeAt(content.length - 1) === 10) lineCount--;
|
|
359
451
|
|
|
360
452
|
const isBundled = (() => {
|
|
361
453
|
// Webpack bundles contain __webpack_require__ or __webpack_modules__
|
|
@@ -528,6 +620,64 @@ class ProjectIndex {
|
|
|
528
620
|
this._endpointsCache = null;
|
|
529
621
|
}
|
|
530
622
|
|
|
623
|
+
/**
|
|
624
|
+
* Put every order-bearing index structure into ONE canonical order, so the
|
|
625
|
+
* same repository content produces the same index state — and therefore
|
|
626
|
+
* byte-identical command output — no matter how the state was reached
|
|
627
|
+
* (fresh build, cache load, incremental rebuild, parallel build).
|
|
628
|
+
*
|
|
629
|
+
* Without this, three paths diverge: build fills fileEntry.symbols/bindings
|
|
630
|
+
* in parse-emission order while loadCache rebuilds them from the name map
|
|
631
|
+
* (name-grouped order); an incremental rebuild re-appends a changed file's
|
|
632
|
+
* defs at the TAIL of each symbols.get(name) array; and calleeIndex sets
|
|
633
|
+
* grow by tail-append. First-match resolution and account sampling then
|
|
634
|
+
* give different answers on consecutive runs over identical content.
|
|
635
|
+
*
|
|
636
|
+
* Canonical orders: files/callsCache by path (discovery's compareNames, so
|
|
637
|
+
* a fresh sequential build is already canonical); per-file symbols/bindings
|
|
638
|
+
* by (startLine, type, className, name); symbols map by name with defs
|
|
639
|
+
* sorted by (relativePath, startLine, type, className, endLine); calleeIndex
|
|
640
|
+
* by name with file sets in path order.
|
|
641
|
+
*/
|
|
642
|
+
_canonicalizeOrder() {
|
|
643
|
+
// Plain code-unit comparison — canonical order must not depend on the
|
|
644
|
+
// host ICU locale (localeCompare does).
|
|
645
|
+
const cmpStr = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
646
|
+
|
|
647
|
+
this.files = new Map([...this.files.entries()].sort((a, b) => compareNames(a[0], b[0])));
|
|
648
|
+
|
|
649
|
+
const feCmp = (a, b) => (a.startLine - b.startLine)
|
|
650
|
+
|| cmpStr(String(a.type), String(b.type))
|
|
651
|
+
|| cmpStr(String(a.className || ''), String(b.className || ''))
|
|
652
|
+
|| cmpStr(String(a.name), String(b.name));
|
|
653
|
+
for (const fe of this.files.values()) {
|
|
654
|
+
if (Array.isArray(fe.symbols)) fe.symbols.sort(feCmp);
|
|
655
|
+
if (Array.isArray(fe.bindings)) fe.bindings.sort(feCmp);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const defCmp = (a, b) => compareNames(a.relativePath || '', b.relativePath || '')
|
|
659
|
+
|| (a.startLine - b.startLine)
|
|
660
|
+
|| cmpStr(String(a.type), String(b.type))
|
|
661
|
+
|| cmpStr(String(a.className || ''), String(b.className || ''))
|
|
662
|
+
|| ((a.endLine || 0) - (b.endLine || 0));
|
|
663
|
+
const sortedNames = [...this.symbols.keys()].sort();
|
|
664
|
+
const canonicalSymbols = new Map();
|
|
665
|
+
for (const name of sortedNames) {
|
|
666
|
+
canonicalSymbols.set(name, this.symbols.get(name).sort(defCmp));
|
|
667
|
+
}
|
|
668
|
+
this.symbols = canonicalSymbols;
|
|
669
|
+
|
|
670
|
+
this.callsCache = new Map([...this.callsCache.entries()].sort((a, b) => compareNames(a[0], b[0])));
|
|
671
|
+
|
|
672
|
+
if (this.calleeIndex) {
|
|
673
|
+
const rebuilt = new Map();
|
|
674
|
+
for (const name of [...this.calleeIndex.keys()].sort()) {
|
|
675
|
+
rebuilt.set(name, new Set([...this.calleeIndex.get(name)].sort(compareNames)));
|
|
676
|
+
}
|
|
677
|
+
this.calleeIndex = rebuilt;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
531
681
|
/**
|
|
532
682
|
* Build directory→files index for O(1) same-package lookups.
|
|
533
683
|
* Replaces O(N) full-index scans in findCallers and countSymbolUsages.
|
|
@@ -965,9 +1115,16 @@ class ProjectIndex {
|
|
|
965
1115
|
const extra = others.length - shown.length;
|
|
966
1116
|
const alsoIn = shown.map(d => `${d.relativePath}:${d.startLine}`).join(', ');
|
|
967
1117
|
const suffix = extra > 0 ? `, and ${extra} more` : '';
|
|
1118
|
+
// file= can only disambiguate when the alternatives live in other
|
|
1119
|
+
// files (fix #246: same-file collisions — a standalone fn and a
|
|
1120
|
+
// same-name method — need a line or class pin instead).
|
|
1121
|
+
const allSameFile = others.every(d => d.relativePath === def.relativePath);
|
|
1122
|
+
const hint = allSameFile
|
|
1123
|
+
? 'Use line= or class_name= to disambiguate.'
|
|
1124
|
+
: 'Use file= to disambiguate.';
|
|
968
1125
|
warnings.push({
|
|
969
1126
|
type: 'ambiguous',
|
|
970
|
-
message: `Found ${definitions.length} definitions for "${name}". Using ${def.relativePath}:${def.startLine}. Also in: ${alsoIn}${suffix}.
|
|
1127
|
+
message: `Found ${definitions.length} definitions for "${name}". Using ${def.relativePath}:${def.startLine}. Also in: ${alsoIn}${suffix}. ${hint}`,
|
|
971
1128
|
alternatives: others.map(d => ({
|
|
972
1129
|
file: d.relativePath,
|
|
973
1130
|
line: d.startLine
|
|
@@ -1258,7 +1415,7 @@ class ProjectIndex {
|
|
|
1258
1415
|
|
|
1259
1416
|
// Sort by file then line
|
|
1260
1417
|
methods.sort((a, b) => {
|
|
1261
|
-
if (a.relativePath !== b.relativePath) return a.relativePath
|
|
1418
|
+
if (a.relativePath !== b.relativePath) return codeUnitCompare(a.relativePath, b.relativePath);
|
|
1262
1419
|
return a.startLine - b.startLine;
|
|
1263
1420
|
});
|
|
1264
1421
|
|
|
@@ -1411,7 +1568,10 @@ class ProjectIndex {
|
|
|
1411
1568
|
// Prefer typed rendering when paramTypes/paramsStructured carry annotations
|
|
1412
1569
|
const { renderTypedParams } = require('./output/shared');
|
|
1413
1570
|
const typed = renderTypedParams(def);
|
|
1414
|
-
|
|
1571
|
+
// Zero-param functions render `()` — some parsers store the '...'
|
|
1572
|
+
// placeholder for EMPTY lists, which reads as unknown/variadic.
|
|
1573
|
+
const noParams = Array.isArray(def.paramsStructured) && def.paramsStructured.length === 0;
|
|
1574
|
+
parts.push(`(${typed != null ? typed : (noParams ? '' : def.params)})`);
|
|
1415
1575
|
}
|
|
1416
1576
|
if (def.returnType) {
|
|
1417
1577
|
parts.push(`: ${String(def.returnType).replace(/\s+/g, ' ').trim()}`);
|
package/core/registry.js
CHANGED
|
@@ -25,7 +25,7 @@ const CANONICAL_COMMANDS = [
|
|
|
25
25
|
// Refactoring
|
|
26
26
|
'verify', 'plan', 'diffImpact', 'check',
|
|
27
27
|
// Other
|
|
28
|
-
'typedef', 'stacktrace', 'api', 'stats', 'doctor', 'auditAsync',
|
|
28
|
+
'typedef', 'stacktrace', 'api', 'stats', 'doctor', 'auditAsync', 'orient',
|
|
29
29
|
];
|
|
30
30
|
|
|
31
31
|
// ============================================================================
|
|
@@ -107,40 +107,40 @@ const PARAM_MAP = {
|
|
|
107
107
|
// file* = file is the command subject (required), not a filter pattern.
|
|
108
108
|
const FLAG_APPLICABILITY = {
|
|
109
109
|
// Understanding code
|
|
110
|
-
about: ['name', 'file', 'exclude', 'className', 'includeMethods', 'includeUncertain', 'includeTests', 'top', 'all', 'withTypes', 'minConfidence', 'showConfidence', 'unreachableOnly', 'compact', 'git'],
|
|
110
|
+
about: ['name', 'file', 'exclude', 'className', 'line', 'includeMethods', 'includeUncertain', 'includeTests', 'top', 'all', 'withTypes', 'minConfidence', 'showConfidence', 'unreachableOnly', 'compact', 'git'],
|
|
111
111
|
// Note: includeMethods/includeUncertain are deprecated no-ops for
|
|
112
112
|
// about/context/impact since the tiered-output contract (unverified
|
|
113
113
|
// callers are always shown in their own section); kept in the matrix so
|
|
114
114
|
// legacy invocations don't warn as "inapplicable". `all` lifts the
|
|
115
115
|
// unverified display cap.
|
|
116
|
-
context: ['name', 'file', 'exclude', 'className', 'includeMethods', 'includeUncertain', 'minConfidence', 'showConfidence', 'unreachableOnly', 'compact', 'all'],
|
|
117
|
-
impact: ['name', 'file', 'exclude', 'className', 'includeMethods', 'includeUncertain', 'top', 'unreachableOnly', 'compact'],
|
|
116
|
+
context: ['name', 'file', 'exclude', 'className', 'line', 'includeMethods', 'includeUncertain', 'minConfidence', 'showConfidence', 'unreachableOnly', 'compact', 'all'],
|
|
117
|
+
impact: ['name', 'file', 'exclude', 'className', 'line', 'includeMethods', 'includeUncertain', 'top', 'unreachableOnly', 'compact'],
|
|
118
118
|
// trace/blast/reverseTrace/affectedTests run the tiered tree contract:
|
|
119
119
|
// includeUncertain is an implied no-op (unverified edges are always
|
|
120
120
|
// visible — frontier/possible band); expandUnverified follows unverified
|
|
121
121
|
// CALLER edges, marking downstream nodes chainUnverified (blast/
|
|
122
122
|
// reverseTrace only — surface trace is down-direction, where unresolved
|
|
123
123
|
// callees have no definition to expand into).
|
|
124
|
-
blast: ['name', 'file', 'exclude', 'className', 'includeMethods', 'includeUncertain', 'depth', 'all', 'minConfidence', 'expandUnverified'],
|
|
125
|
-
reverseTrace: ['name', 'file', 'exclude', 'className', 'includeMethods', 'includeUncertain', 'depth', 'all', 'minConfidence', 'expandUnverified'],
|
|
126
|
-
smart: ['name', 'file', 'exclude', 'className', 'includeMethods', 'includeUncertain', 'withTypes', 'minConfidence'],
|
|
127
|
-
trace: ['name', 'file', 'exclude', 'className', 'includeMethods', 'includeUncertain', 'depth', 'all', 'minConfidence'],
|
|
124
|
+
blast: ['name', 'file', 'exclude', 'className', 'line', 'includeMethods', 'includeUncertain', 'depth', 'all', 'minConfidence', 'expandUnverified'],
|
|
125
|
+
reverseTrace: ['name', 'file', 'exclude', 'className', 'line', 'includeMethods', 'includeUncertain', 'depth', 'all', 'minConfidence', 'expandUnverified'],
|
|
126
|
+
smart: ['name', 'file', 'exclude', 'className', 'line', 'includeMethods', 'includeUncertain', 'withTypes', 'minConfidence'],
|
|
127
|
+
trace: ['name', 'file', 'exclude', 'className', 'line', 'includeMethods', 'includeUncertain', 'depth', 'all', 'minConfidence'],
|
|
128
128
|
example: ['name', 'file', 'className', 'diverse', 'top', 'includeTests'],
|
|
129
|
-
related: ['name', 'file', 'className', 'top', 'all'],
|
|
130
|
-
brief: ['name', 'file', 'className', 'git'],
|
|
129
|
+
related: ['name', 'file', 'className', 'line', 'top', 'all'],
|
|
130
|
+
brief: ['name', 'file', 'className', 'line', 'git'],
|
|
131
131
|
// Finding code
|
|
132
132
|
find: ['name', 'file', 'exclude', 'className', 'includeTests', 'top', 'limit', 'exact', 'in', 'all', 'depth', 'compact'],
|
|
133
133
|
usages: ['name', 'file', 'exclude', 'className', 'includeTests', 'limit', 'codeOnly', 'context', 'in', 'compact'],
|
|
134
134
|
toc: ['file', 'exclude', 'top', 'limit', 'all', 'detailed', 'topLevel', 'in'],
|
|
135
135
|
search: ['term', 'file', 'exclude', 'includeTests', 'top', 'limit', 'codeOnly', 'caseSensitive', 'context', 'regex', 'in', 'type', 'param', 'receiver', 'returns', 'decorator', 'exported', 'unused'],
|
|
136
136
|
tests: ['name', 'file', 'exclude', 'className', 'callsOnly'],
|
|
137
|
-
affectedTests:['name', 'file', 'exclude', 'className', 'includeMethods', 'includeUncertain', 'depth', 'minConfidence'],
|
|
137
|
+
affectedTests:['name', 'file', 'exclude', 'className', 'line', 'includeMethods', 'includeUncertain', 'depth', 'minConfidence'],
|
|
138
138
|
deadcode: ['file', 'exclude', 'includeTests', 'includeExported', 'includeDecorated', 'limit', 'in'],
|
|
139
139
|
entrypoints: ['file', 'exclude', 'includeTests', 'excludeTests', 'limit', 'type', 'framework'],
|
|
140
140
|
endpoints: ['file', 'exclude', 'limit', 'framework', 'bridge', 'serverOnly', 'clientOnly', 'unmatched', 'method', 'prefix', 'hideUncertain'],
|
|
141
141
|
// Extracting code
|
|
142
|
-
fn: ['name', 'file', 'className', 'all'],
|
|
143
|
-
class: ['name', 'file', 'all', 'maxLines'],
|
|
142
|
+
fn: ['name', 'file', 'className', 'line', 'all'],
|
|
143
|
+
class: ['name', 'file', 'line', 'all', 'maxLines'],
|
|
144
144
|
lines: ['file', 'range'],
|
|
145
145
|
expand: ['item'],
|
|
146
146
|
// File dependencies
|
|
@@ -150,8 +150,11 @@ const FLAG_APPLICABILITY = {
|
|
|
150
150
|
graph: ['file', 'depth', 'direction', 'all'],
|
|
151
151
|
circularDeps: ['file', 'exclude'],
|
|
152
152
|
// Refactoring
|
|
153
|
-
verify
|
|
154
|
-
|
|
153
|
+
// verify runs the tiered caller contract (v4): includeMethods/
|
|
154
|
+
// includeUncertain are implied no-ops (unverified sites always visible in
|
|
155
|
+
// their own band); kept in the matrix so legacy invocations don't warn.
|
|
156
|
+
verify: ['name', 'file', 'className', 'line', 'includeMethods', 'includeUncertain'],
|
|
157
|
+
plan: ['name', 'file', 'className', 'line', 'addParam', 'removeParam', 'renameTo', 'defaultValue'],
|
|
155
158
|
diffImpact: ['file', 'limit', 'base', 'staged', 'all'],
|
|
156
159
|
check: ['file', 'base', 'staged', 'limit'],
|
|
157
160
|
// Other
|
|
@@ -160,6 +163,7 @@ const FLAG_APPLICABILITY = {
|
|
|
160
163
|
api: ['file', 'limit'],
|
|
161
164
|
stats: ['functions', 'hot', 'top'],
|
|
162
165
|
doctor: ['file', 'in', 'limit', 'deep'],
|
|
166
|
+
orient: ['top'],
|
|
163
167
|
auditAsync: ['file', 'exclude', 'limit'],
|
|
164
168
|
};
|
|
165
169
|
|
|
@@ -168,7 +172,7 @@ const FLAG_APPLICABILITY = {
|
|
|
168
172
|
const BROAD_COMMANDS = new Set([
|
|
169
173
|
'toc', 'entrypoints', 'endpoints', 'diffImpact', 'affectedTests',
|
|
170
174
|
'deadcode', 'usages', 'reverseTrace', 'circularDeps',
|
|
171
|
-
'doctor', 'check', 'auditAsync',
|
|
175
|
+
'doctor', 'check', 'auditAsync', 'orient',
|
|
172
176
|
]);
|
|
173
177
|
|
|
174
178
|
// Commands that can operate on a single file without a project index.
|