ucn 4.0.1 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/.claude/skills/ucn/SKILL.md +31 -7
  2. package/README.md +89 -50
  3. package/cli/index.js +199 -94
  4. package/core/account.js +3 -1
  5. package/core/analysis.js +334 -316
  6. package/core/bridge.js +13 -8
  7. package/core/cache.js +109 -19
  8. package/core/callers.js +969 -77
  9. package/core/check.js +19 -8
  10. package/core/deadcode.js +368 -40
  11. package/core/discovery.js +31 -11
  12. package/core/entrypoints.js +149 -17
  13. package/core/execute.js +330 -43
  14. package/core/graph-build.js +61 -10
  15. package/core/graph.js +282 -61
  16. package/core/imports.js +72 -3
  17. package/core/output/analysis-ext.js +70 -10
  18. package/core/output/analysis.js +67 -33
  19. package/core/output/check.js +4 -1
  20. package/core/output/doctor.js +13 -3
  21. package/core/output/endpoints.js +8 -3
  22. package/core/output/extraction.js +12 -1
  23. package/core/output/find.js +23 -9
  24. package/core/output/graph.js +32 -9
  25. package/core/output/refactoring.js +147 -49
  26. package/core/output/reporting.js +104 -5
  27. package/core/output/search.js +30 -3
  28. package/core/output/shared.js +31 -4
  29. package/core/output/tracing.js +22 -11
  30. package/core/parser.js +8 -6
  31. package/core/project.js +167 -7
  32. package/core/registry.js +20 -16
  33. package/core/reporting.js +220 -84
  34. package/core/search.js +270 -55
  35. package/core/shared.js +285 -1
  36. package/core/stacktrace.js +23 -1
  37. package/core/tracing.js +278 -36
  38. package/core/verify.js +352 -349
  39. package/languages/go.js +29 -17
  40. package/languages/index.js +56 -0
  41. package/languages/java.js +133 -16
  42. package/languages/javascript.js +215 -27
  43. package/languages/python.js +113 -47
  44. package/languages/rust.js +89 -26
  45. package/languages/utils.js +35 -7
  46. package/mcp/server.js +72 -31
  47. package/package.json +4 -1
@@ -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 doesn't have "dynamic imports" uses "blank/dot imports" instead.
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
- if (meta?.projectLanguage && !langTraits(meta.projectLanguage)?.hasDynamicImports) {
18
- return `${count} blank/dot import(s)`;
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,
@@ -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].localeCompare(b[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].localeCompare(b[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].localeCompare(b[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
- if (trace.includeMethods === false) {
211
- const methodsHint = options.methodsHint || 'Note: obj.method() calls excluded use --include-methods to include them';
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
- if (blast.includeMethods === false) {
343
- lines.push('\nNote: obj.method() calls excluded. Use --include-methods to include them.');
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 ${totalFunctions} intermediate function${totalFunctions !== 1 ? 's' : ''}`;
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
- if (result.includeMethods === false) {
491
- lines.push('\nNote: obj.method() calls excluded. Use --include-methods to include them.');
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} functions affected (depth ${result.depth})`);
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 opening <script ...> tag wherever it appears on the first line
286
- // (may be preceded by other HTML on the same line, e.g. <p>x</p><script>...).
287
- lines[0] = lines[0].replace(/<script\b[^>]*>/i, '');
288
- // Strip closing </script> tag wherever it appears on the last line
289
- // (may be followed by other HTML on the same line, e.g. ...</script><p>x</p>).
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*>/i, '');
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
- const usages = langModule.findUsagesInCode(content, name, parser);
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}. Use file= to disambiguate.`,
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.localeCompare(b.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
- parts.push(`(${typed != null ? typed : def.params})`);
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: ['name', 'file', 'className', 'includeMethods', 'includeUncertain'],
154
- plan: ['name', 'file', 'className', 'addParam', 'removeParam', 'renameTo', 'defaultValue'],
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.