ucn 3.8.22 → 3.8.25

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 +114 -11
  2. package/README.md +152 -156
  3. package/cli/index.js +363 -37
  4. package/core/analysis.js +960 -37
  5. package/core/bridge.js +1111 -0
  6. package/core/brief.js +408 -0
  7. package/core/cache.js +213 -59
  8. package/core/callers.js +117 -41
  9. package/core/check.js +200 -0
  10. package/core/deadcode.js +31 -2
  11. package/core/discovery.js +57 -34
  12. package/core/entrypoints.js +638 -4
  13. package/core/execute.js +304 -5
  14. package/core/git-enrich.js +130 -0
  15. package/core/graph-build.js +4 -4
  16. package/core/graph.js +31 -12
  17. package/core/output/analysis.js +157 -25
  18. package/core/output/brief.js +100 -0
  19. package/core/output/check.js +79 -0
  20. package/core/output/doctor.js +85 -0
  21. package/core/output/endpoints.js +239 -0
  22. package/core/output/extraction.js +2 -0
  23. package/core/output/find.js +126 -39
  24. package/core/output/graph.js +48 -15
  25. package/core/output/refactoring.js +103 -5
  26. package/core/output/reporting.js +63 -23
  27. package/core/output/search.js +110 -17
  28. package/core/output/shared.js +56 -2
  29. package/core/output.js +4 -0
  30. package/core/parallel-build.js +10 -7
  31. package/core/parser.js +8 -2
  32. package/core/project.js +147 -41
  33. package/core/registry.js +30 -14
  34. package/core/reporting.js +465 -2
  35. package/core/search.js +139 -15
  36. package/core/shared.js +101 -5
  37. package/core/tracing.js +31 -12
  38. package/core/verify.js +982 -95
  39. package/languages/go.js +91 -6
  40. package/languages/html.js +10 -0
  41. package/languages/java.js +151 -35
  42. package/languages/javascript.js +290 -33
  43. package/languages/python.js +78 -11
  44. package/languages/rust.js +267 -12
  45. package/languages/utils.js +315 -3
  46. package/mcp/server.js +91 -16
  47. package/package.json +10 -2
package/core/search.js CHANGED
@@ -221,11 +221,15 @@ function usages(index, name, options = {}) {
221
221
  let _importedHasDef = null;
222
222
  const importedFileHasDef = () => {
223
223
  if (_importedHasDef !== null) return _importedHasDef;
224
- const importedFiles = index.importGraph.get(filePath) || [];
225
- _importedHasDef = importedFiles.some(imp => {
224
+ const importedFiles = index.importGraph.get(filePath);
225
+ _importedHasDef = false;
226
+ if (importedFiles) for (const imp of importedFiles) {
226
227
  const impEntry = index.files.get(imp);
227
- return impEntry?.symbols?.some(s => s.name === name);
228
- });
228
+ if (impEntry?.symbols?.some(s => s.name === name)) {
229
+ _importedHasDef = true;
230
+ break;
231
+ }
232
+ }
229
233
  return _importedHasDef;
230
234
  };
231
235
 
@@ -249,6 +253,15 @@ function usages(index, name, options = {}) {
249
253
 
250
254
  const lineContent = lines[u.line - 1] || '';
251
255
 
256
+ // BUG-4: enrich call usages with enclosing-function info so
257
+ // the JSON `handle` shows the real caller, not `_topLevel`.
258
+ // Only resolve for call usages — definitions, imports, and
259
+ // bare references don't need a caller token.
260
+ let callerSym = null;
261
+ if (u.usageType === 'call') {
262
+ callerSym = index.findEnclosingFunction(filePath, u.line, true);
263
+ }
264
+
252
265
  const usage = {
253
266
  file: filePath,
254
267
  relativePath: fileEntry.relativePath,
@@ -256,7 +269,11 @@ function usages(index, name, options = {}) {
256
269
  content: lineContent,
257
270
  usageType: u.usageType,
258
271
  isDefinition: false,
259
- ...(u.receiver && { receiver: u.receiver })
272
+ ...(u.receiver && { receiver: u.receiver }),
273
+ ...(callerSym && {
274
+ callerName: callerSym.name,
275
+ callerStartLine: callerSym.startLine
276
+ })
260
277
  };
261
278
 
262
279
  // Add context lines if requested
@@ -301,13 +318,23 @@ function usages(index, name, options = {}) {
301
318
  // Classify usage type (AST-based, defaults to 'reference' for unsupported languages)
302
319
  const usageType = index.classifyUsageAST(content, lineNum, name, filePath) ?? 'reference';
303
320
 
321
+ // BUG-4: enrich call usages with enclosing-function info.
322
+ let callerSym = null;
323
+ if (usageType === 'call') {
324
+ callerSym = index.findEnclosingFunction(filePath, lineNum, true);
325
+ }
326
+
304
327
  const usage = {
305
328
  file: filePath,
306
329
  relativePath: fileEntry.relativePath,
307
330
  line: lineNum,
308
331
  content: line,
309
332
  usageType,
310
- isDefinition: false
333
+ isDefinition: false,
334
+ ...(callerSym && {
335
+ callerName: callerSym.name,
336
+ callerStartLine: callerSym.startLine
337
+ })
311
338
  };
312
339
 
313
340
  // Add context lines if requested
@@ -718,25 +745,50 @@ function structuralSearch(index, options = {}) {
718
745
  }
719
746
 
720
747
  /**
721
- * Find the best usage example of a function
748
+ * Find the best usage example of a function (or, with `diverse`, one
749
+ * representative per distinct argument-shape cluster).
722
750
  *
723
751
  * @param {object} index - ProjectIndex instance
724
752
  * @param {string} name - Function name
725
- * @param {object} options - { className }
726
- * @returns {object|null} Best example with score
753
+ * @param {object} options - { className, diverse, top }
754
+ * @returns {object|null} Best example with score; with diverse=true, also `clusters`
727
755
  */
728
756
  function example(index, name, options = {}) {
729
757
  index._beginOp();
730
758
  try {
759
+ // MEDIUM-8: respect --include-tests. By default, test/fixture/mock files
760
+ // are excluded as low-signal examples. When the user passes
761
+ // includeTests:true, return matches from those files too.
762
+ const exclude = options.includeTests
763
+ ? []
764
+ : ['test', 'spec', '__tests__', '__mocks__', 'fixture', 'mock'];
731
765
  const usageResults = usages(index, name, {
732
766
  codeOnly: true,
733
767
  className: options.className,
734
- exclude: ['test', 'spec', '__tests__', '__mocks__', 'fixture', 'mock'],
768
+ exclude,
735
769
  context: 5
736
770
  });
737
771
 
738
772
  const calls = usageResults.filter(u => u.usageType === 'call' && !u.isDefinition);
739
- if (calls.length === 0) return null;
773
+ if (calls.length === 0) {
774
+ // No matches in non-test files. Look at how many WERE excluded so we
775
+ // can tell the user there's something they could see with
776
+ // --include-tests, instead of saying "no examples found" silently.
777
+ if (!options.includeTests) {
778
+ const allUsages = usages(index, name, {
779
+ codeOnly: true,
780
+ className: options.className,
781
+ exclude: [],
782
+ context: 0,
783
+ });
784
+ const excludedCalls = allUsages.filter(u =>
785
+ u.usageType === 'call' && !u.isDefinition).length;
786
+ if (excludedCalls > 0) {
787
+ return { best: null, totalCalls: 0, excludedTestCalls: excludedCalls };
788
+ }
789
+ }
790
+ return null;
791
+ }
740
792
 
741
793
  const scored = calls.map(call => {
742
794
  let score = 0;
@@ -777,7 +829,73 @@ function example(index, name, options = {}) {
777
829
  });
778
830
 
779
831
  scored.sort((a, b) => b.score - a.score);
780
- return { best: scored[0], totalCalls: calls.length };
832
+ const best = scored[0];
833
+
834
+ // Default behavior: one best representative.
835
+ if (!options.diverse) {
836
+ return { best, totalCalls: calls.length };
837
+ }
838
+
839
+ // --diverse: cluster call sites by AST argument-shape and return one
840
+ // representative per cluster (up to `top`, default 3).
841
+ //
842
+ // Shape key = "argCount:argKind1,argKind2,..." (see classifyArgNode in
843
+ // verify.js for the kind set). Calls with no resolvable AST shape go into
844
+ // a single 'unknown' cluster so they're still surfaced.
845
+ //
846
+ // Representative selection: highest existing example score within the
847
+ // cluster — this reuses the quality heuristic above (typed assignment,
848
+ // documented, standalone, etc.) so each cluster's example is the *best*
849
+ // member of that shape, not just the first-seen one.
850
+ const top = (options.top != null && Number(options.top) > 0) ? Number(options.top) : 3;
851
+ const clusterMap = new Map(); // shapeKey -> { shapeKey, kinds, count, members:[] }
852
+
853
+ for (const call of scored) {
854
+ const shape = index._analyzeCallShape(call.file, call.line, name);
855
+ const key = shape ? shape.shapeKey : 'unknown';
856
+ if (!clusterMap.has(key)) {
857
+ clusterMap.set(key, {
858
+ shapeKey: key,
859
+ argKinds: shape ? shape.argKinds : null,
860
+ argCount: shape ? shape.argCount : null,
861
+ count: 0,
862
+ members: [],
863
+ });
864
+ }
865
+ const c = clusterMap.get(key);
866
+ c.count++;
867
+ c.members.push({ ...call, _argTexts: shape ? shape.argTexts : null });
868
+ }
869
+
870
+ // Order clusters: largest first, ties broken by shapeKey for determinism.
871
+ const clusterList = [...clusterMap.values()].sort((a, b) =>
872
+ (b.count - a.count) || a.shapeKey.localeCompare(b.shapeKey)
873
+ );
874
+
875
+ // Pick representative per cluster: highest-scoring member; ties broken
876
+ // by (file, line) for stable output across runs.
877
+ const clusters = clusterList.slice(0, top).map(cluster => {
878
+ const sortedMembers = [...cluster.members].sort((a, b) =>
879
+ (b.score - a.score) ||
880
+ (a.relativePath || a.file || '').localeCompare(b.relativePath || b.file || '') ||
881
+ (a.line || 0) - (b.line || 0)
882
+ );
883
+ const rep = sortedMembers[0];
884
+ return {
885
+ shapeKey: cluster.shapeKey,
886
+ argKinds: cluster.argKinds,
887
+ argCount: cluster.argCount,
888
+ count: cluster.count,
889
+ representative: rep,
890
+ };
891
+ });
892
+
893
+ return {
894
+ best,
895
+ totalCalls: calls.length,
896
+ clusters,
897
+ totalClusters: clusterList.length,
898
+ };
781
899
  } finally { index._endOp(); }
782
900
  }
783
901
 
@@ -1008,7 +1126,7 @@ function _buildSourceFileImporters(index, defs) {
1008
1126
 
1009
1127
  while (queue.length > 0) {
1010
1128
  const current = queue.shift();
1011
- const directImporters = index.exportGraph?.get(current) || [];
1129
+ const directImporters = index.exportGraph?.get(current) || new Set();
1012
1130
  for (const imp of directImporters) {
1013
1131
  importers.add(imp);
1014
1132
  // Check if this importer re-exports the symbol (barrel pattern).
@@ -1244,11 +1362,17 @@ function _addTestCaseMatches(index, filePath, fileEntry, searchTerm, className,
1244
1362
  if (!fileEntry.symbols) return;
1245
1363
  try {
1246
1364
  const langModule = getLanguageModule(lang);
1247
- if (!langModule || !langModule.isEntryPoint) return;
1365
+ if (!langModule) return;
1366
+ // Prefer kinded predicate so fn main() and fn init() aren't classified
1367
+ // as test cases (BUG-CX). Fall back to isEntryPoint for compat.
1368
+ const classify = langModule.getEntryPointKind
1369
+ ? (s) => langModule.getEntryPointKind(s) === 'test'
1370
+ : langModule.isEntryPoint;
1371
+ if (!classify) return;
1248
1372
 
1249
1373
  // Find test symbols
1250
1374
  for (const symbol of fileEntry.symbols) {
1251
- if (!langModule.isEntryPoint(symbol)) continue;
1375
+ if (!classify(symbol)) continue;
1252
1376
  // Check if any non-import usage of searchTerm falls within this test function
1253
1377
  const usageInRange = matches.some(m =>
1254
1378
  m.line >= symbol.startLine && m.line <= symbol.endLine &&
package/core/shared.js CHANGED
@@ -5,29 +5,61 @@
5
5
  const { isTestFile } = require('./discovery');
6
6
  const { detectLanguage } = require('./parser');
7
7
 
8
+ /**
9
+ * Path-based test heuristic — matches the same patterns as `find`'s exclusion
10
+ * logic so that `about` and `find` agree on which files are de-emphasized.
11
+ *
12
+ * Triggers when any of `test|tests|spec|__tests__|__mocks__|fixture|mock`
13
+ * appears as a path segment (with word boundaries on both sides).
14
+ *
15
+ * Complement to `isTestFile` (filename pattern check) — together they catch
16
+ * both `foo.test.js` (filename) AND `test/agent-benchmark.js` (directory).
17
+ */
18
+ function isTestPath(rp) {
19
+ if (!rp) return false;
20
+ return /(^|[/._-])(test|tests|spec|__tests__|__mocks__|fixture|mock)s?([/._-]|$)/i.test(rp);
21
+ }
22
+
8
23
  /**
9
24
  * Pick the best definition from multiple matches.
10
25
  * Prefers non-test, src/lib files, larger function bodies.
26
+ *
27
+ * BUG-M4: align with `find`'s exclusion ordering so `about` and `find` pick
28
+ * the same primary. Adds path-based test detection (covers `test/foo.js`
29
+ * which `isTestFile` misses) and prefers files that are imported by others
30
+ * (real source) over test/fixture files.
11
31
  */
12
- function pickBestDefinition(matches) {
32
+ function pickBestDefinition(matches, opts = {}) {
13
33
  const typeOrder = new Set(['class', 'struct', 'interface', 'type', 'impl']);
34
+ const importGraph = opts.importGraph;
14
35
  const scored = matches.map(m => {
15
36
  let score = 0;
16
37
  const rp = m.relativePath || '';
17
38
  // Prefer class/struct/interface types (+1000) - same as resolveSymbol
18
39
  if (typeOrder.has(m.type)) score += 1000;
19
- if (isTestFile(rp, detectLanguage(m.file))) score -= 500;
40
+ // Test file penalties: -500 for filename pattern OR path segment.
41
+ // Both checks because `isTestFile` only matches `*.test.js`/`__tests__/`,
42
+ // not `test/agent-benchmark.js` which `find` excludes via path regex.
43
+ if (isTestFile(rp, detectLanguage(m.file)) || isTestPath(rp)) score -= 500;
20
44
  if (/^(examples?|docs?|vendor|third[_-]?party|benchmarks?|samples?)\//i.test(rp)) score -= 300;
21
45
  if (/^(lib|src|core|internal|pkg|crates)\//i.test(rp)) score += 200;
46
+ // Prefer files that are imported by something — real source over scripts/fixtures.
47
+ if (importGraph && m.file) {
48
+ for (const [, importedFiles] of importGraph) {
49
+ if (importedFiles.has(m.file)) { score += 100; break; }
50
+ }
51
+ }
22
52
  // Deprioritize type-only overload signatures (TypeScript function_signature)
23
53
  if (m.isSignature) score -= 200;
24
54
  // Tiebreaker: prefer larger function bodies (more important/complex)
25
55
  if (m.startLine && m.endLine) {
26
56
  score += Math.min(m.endLine - m.startLine, 100);
27
57
  }
28
- return { match: m, score };
58
+ return { match: m, score, rp };
29
59
  });
30
- scored.sort((a, b) => b.score - a.score);
60
+ // Stable sort: by score desc, then alphabetical relativePath (so two equal-score
61
+ // matches always pick the same one across runs).
62
+ scored.sort((a, b) => (b.score - a.score) || a.rp.localeCompare(b.rp));
31
63
  return scored[0].match;
32
64
  }
33
65
 
@@ -52,4 +84,68 @@ function escapeRegExp(text) {
52
84
  // Symbol types that are not callable (used to filter class/struct/type declarations from call analysis)
53
85
  const NON_CALLABLE_TYPES = new Set(['class', 'struct', 'interface', 'type', 'enum', 'trait', 'state', 'impl', 'field']);
54
86
 
55
- module.exports = { pickBestDefinition, addTestExclusions, escapeRegExp, NON_CALLABLE_TYPES };
87
+ /**
88
+ * Stable symbol handle: `relativePath:line` or `relativePath:line:name`.
89
+ *
90
+ * Handles let multi-step workflows roundtrip without name disambiguation —
91
+ * `find` returns handles, `brief`/`impact`/`context` accept them, and the result
92
+ * targets the exact same symbol even when names overlap. Renames break handles
93
+ * (intentionally — that's a real change, not noise).
94
+ *
95
+ * Format chosen because:
96
+ * - colon is the existing UCN location separator (`file.js:42`)
97
+ * - line number is the second-most-stable ID (after path); names move within files
98
+ * - the optional :name disambiguates when multiple symbols share a startLine
99
+ * (rare but possible: e.g. `class Foo {}` and a same-line method def)
100
+ */
101
+ function formatSymbolHandle(symbol) {
102
+ if (!symbol || !symbol.startLine) return null;
103
+ const file = symbol.relativePath || symbol.file;
104
+ if (!file) return null;
105
+ return symbol.name ? `${file}:${symbol.startLine}:${symbol.name}` : `${file}:${symbol.startLine}`;
106
+ }
107
+
108
+ /**
109
+ * Parse a handle string back into its components.
110
+ * Returns { file, line, name? } or null if input doesn't look like a handle.
111
+ *
112
+ * Strategy: scan from the right so paths containing `:` (Windows drive letters
113
+ * if anyone ever uses them as relative paths) survive. The line number is the
114
+ * rightmost run of digits between two colons or at the end.
115
+ */
116
+ function parseSymbolHandle(input) {
117
+ if (!input || typeof input !== 'string') return null;
118
+ // Two forms:
119
+ // path:digits → file + line
120
+ // path:digits:name → file + line + name (name may contain anything)
121
+ // The line is always digits; the path is everything before the line; the
122
+ // optional name is everything after the line.
123
+ const m = input.match(/^(.+):(\d+)(?::(.+))?$/);
124
+ if (!m) return null;
125
+ const file = m[1];
126
+ const line = parseInt(m[2], 10);
127
+ if (!Number.isFinite(line) || line < 1) return null;
128
+ const handle = { file, line };
129
+ if (m[3] != null) handle.name = m[3];
130
+ return handle;
131
+ }
132
+
133
+ /**
134
+ * Quick predicate: does this string look like a handle (vs a plain symbol name)?
135
+ * Used to short-circuit handle resolution before name lookup.
136
+ */
137
+ function looksLikeHandle(input) {
138
+ if (!input || typeof input !== 'string') return false;
139
+ return /^.+:\d+(?::.+)?$/.test(input);
140
+ }
141
+
142
+ module.exports = {
143
+ pickBestDefinition,
144
+ addTestExclusions,
145
+ escapeRegExp,
146
+ NON_CALLABLE_TYPES,
147
+ formatSymbolHandle,
148
+ parseSymbolHandle,
149
+ looksLikeHandle,
150
+ isTestPath,
151
+ };
package/core/tracing.js CHANGED
@@ -32,7 +32,7 @@ function trace(index, name, options = {}) {
32
32
  // trace defaults to includeMethods=true (execution flow should show method calls)
33
33
  const includeMethods = options.includeMethods ?? true;
34
34
 
35
- const { def, definitions, warnings } = index.resolveSymbol(name, { file: options.file, className: options.className });
35
+ const { def, definitions, warnings } = index.resolveSymbol(name, { file: options.file, className: options.className, line: options.line });
36
36
  if (!def) {
37
37
  return null;
38
38
  }
@@ -159,7 +159,7 @@ function blast(index, name, options = {}) {
159
159
  const includeUncertain = options.includeUncertain || false;
160
160
  const exclude = options.exclude || [];
161
161
 
162
- const { def, definitions, warnings } = index.resolveSymbol(name, { file: options.file, className: options.className });
162
+ const { def, definitions, warnings } = index.resolveSymbol(name, { file: options.file, className: options.className, line: options.line });
163
163
  if (!def) return null;
164
164
 
165
165
  const visited = new Set();
@@ -334,7 +334,7 @@ function reverseTrace(index, name, options = {}) {
334
334
  const includeUncertain = options.includeUncertain || false;
335
335
  const exclude = options.exclude || [];
336
336
 
337
- const { def, definitions, warnings } = index.resolveSymbol(name, { file: options.file, className: options.className });
337
+ const { def, definitions, warnings } = index.resolveSymbol(name, { file: options.file, className: options.className, line: options.line });
338
338
  if (!def) return null;
339
339
 
340
340
  const visited = new Set();
@@ -435,13 +435,22 @@ function reverseTrace(index, name, options = {}) {
435
435
  if (callerEntries.length > maxChildren) {
436
436
  node.truncatedChildren = callerEntries.length - maxChildren;
437
437
  // Count entry points in truncated branches so summary is accurate
438
+ // Use callerCache to avoid redundant findCallers calls
438
439
  for (const { def: cDef } of callerEntries.slice(maxChildren)) {
439
- const key = `${cDef.file}:${cDef.startLine}`;
440
- if (!visited.has(key)) {
441
- const cCallers = index.findCallers(cDef.name, {
442
- includeMethods, includeUncertain,
443
- targetDefinitions: cDef.bindingId ? [cDef] : undefined,
444
- });
440
+ const cKey = `${cDef.file}:${cDef.startLine}`;
441
+ if (!visited.has(cKey)) {
442
+ const cCacheKey = cDef.bindingId
443
+ ? `${cDef.name}:${cDef.bindingId}`
444
+ : `${cDef.name}:${cKey}`;
445
+ let cCallers = callerCache.get(cCacheKey);
446
+ if (!cCallers) {
447
+ cCallers = index.findCallers(cDef.name, {
448
+ includeMethods, includeUncertain,
449
+ targetDefinitions: cDef.bindingId ? [cDef] : undefined,
450
+ maxResults: 1, // Only need to know if any exist
451
+ });
452
+ callerCache.set(cCacheKey, cCallers);
453
+ }
445
454
  if (cCallers.length === 0) {
446
455
  entryPoints.push({ name: cDef.name, file: cDef.relativePath || path.relative(index.root, cDef.file), line: cDef.startLine });
447
456
  }
@@ -552,8 +561,11 @@ function affectedTests(index, name, options = {}) {
552
561
  for (const [filePath, fileEntry] of index.files) {
553
562
  let isTest = isTestFile(fileEntry.relativePath, fileEntry.language);
554
563
  // Rust inline #[cfg(test)] modules: source files with #[test]-marked symbols
564
+ // or symbols inside a #[cfg(test)] mod block (BUG-CY).
555
565
  if (!isTest && fileEntry.language === 'rust') {
556
- isTest = fileEntry.symbols?.some(s => s.modifiers?.includes('test'));
566
+ isTest = fileEntry.symbols?.some(s =>
567
+ s.modifiers?.includes('test') || s.modifiers?.includes('cfg_test_module')
568
+ );
557
569
  }
558
570
  if (!isTest) continue;
559
571
  if (excludeArr.length > 0 && !index.matchesFilters(fileEntry.relativePath, { exclude: excludeArr })) continue;
@@ -735,9 +747,16 @@ function _addAffectedTestCases(index, filePath, fileEntry, funcName, fileMatches
735
747
  if (!fileEntry.symbols) return;
736
748
  try {
737
749
  const langModule = getLanguageModule(lang);
738
- if (!langModule || !langModule.isEntryPoint) return;
750
+ if (!langModule) return;
751
+ // Prefer the kinded predicate so we don't mis-tag fn main() / fn init()
752
+ // (runtime entries) as test cases (BUG-CX). Fall back to isEntryPoint
753
+ // for backward compat with language modules that haven't been migrated.
754
+ const classify = langModule.getEntryPointKind
755
+ ? (s) => langModule.getEntryPointKind(s) === 'test'
756
+ : langModule.isEntryPoint;
757
+ if (!classify) return;
739
758
  for (const symbol of fileEntry.symbols) {
740
- if (!langModule.isEntryPoint(symbol)) continue;
759
+ if (!classify(symbol)) continue;
741
760
  // Only add test-case if a call match exists in the test body
742
761
  const hasCallInRange = existingMatches.some(m =>
743
762
  m.line >= symbol.startLine && m.line <= symbol.endLine &&