ucn 3.8.23 → 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 (44) 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 +936 -32
  5. package/core/bridge.js +1111 -0
  6. package/core/brief.js +408 -0
  7. package/core/cache.js +105 -5
  8. package/core/callers.js +72 -18
  9. package/core/check.js +200 -0
  10. package/core/discovery.js +57 -34
  11. package/core/entrypoints.js +638 -4
  12. package/core/execute.js +304 -5
  13. package/core/git-enrich.js +130 -0
  14. package/core/graph.js +24 -2
  15. package/core/output/analysis.js +157 -25
  16. package/core/output/brief.js +100 -0
  17. package/core/output/check.js +79 -0
  18. package/core/output/doctor.js +85 -0
  19. package/core/output/endpoints.js +239 -0
  20. package/core/output/extraction.js +2 -0
  21. package/core/output/find.js +126 -39
  22. package/core/output/graph.js +48 -15
  23. package/core/output/refactoring.js +103 -5
  24. package/core/output/reporting.js +63 -23
  25. package/core/output/search.js +110 -17
  26. package/core/output/shared.js +56 -2
  27. package/core/output.js +4 -0
  28. package/core/parser.js +8 -2
  29. package/core/project.js +39 -3
  30. package/core/registry.js +30 -14
  31. package/core/reporting.js +465 -2
  32. package/core/search.js +130 -10
  33. package/core/shared.js +101 -5
  34. package/core/tracing.js +16 -6
  35. package/core/verify.js +982 -95
  36. package/languages/go.js +91 -6
  37. package/languages/html.js +10 -0
  38. package/languages/java.js +151 -35
  39. package/languages/javascript.js +290 -33
  40. package/languages/python.js +78 -11
  41. package/languages/rust.js +267 -12
  42. package/languages/utils.js +315 -3
  43. package/mcp/server.js +91 -16
  44. package/package.json +9 -1
package/core/search.js CHANGED
@@ -253,6 +253,15 @@ function usages(index, name, options = {}) {
253
253
 
254
254
  const lineContent = lines[u.line - 1] || '';
255
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
+
256
265
  const usage = {
257
266
  file: filePath,
258
267
  relativePath: fileEntry.relativePath,
@@ -260,7 +269,11 @@ function usages(index, name, options = {}) {
260
269
  content: lineContent,
261
270
  usageType: u.usageType,
262
271
  isDefinition: false,
263
- ...(u.receiver && { receiver: u.receiver })
272
+ ...(u.receiver && { receiver: u.receiver }),
273
+ ...(callerSym && {
274
+ callerName: callerSym.name,
275
+ callerStartLine: callerSym.startLine
276
+ })
264
277
  };
265
278
 
266
279
  // Add context lines if requested
@@ -305,13 +318,23 @@ function usages(index, name, options = {}) {
305
318
  // Classify usage type (AST-based, defaults to 'reference' for unsupported languages)
306
319
  const usageType = index.classifyUsageAST(content, lineNum, name, filePath) ?? 'reference';
307
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
+
308
327
  const usage = {
309
328
  file: filePath,
310
329
  relativePath: fileEntry.relativePath,
311
330
  line: lineNum,
312
331
  content: line,
313
332
  usageType,
314
- isDefinition: false
333
+ isDefinition: false,
334
+ ...(callerSym && {
335
+ callerName: callerSym.name,
336
+ callerStartLine: callerSym.startLine
337
+ })
315
338
  };
316
339
 
317
340
  // Add context lines if requested
@@ -722,25 +745,50 @@ function structuralSearch(index, options = {}) {
722
745
  }
723
746
 
724
747
  /**
725
- * 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).
726
750
  *
727
751
  * @param {object} index - ProjectIndex instance
728
752
  * @param {string} name - Function name
729
- * @param {object} options - { className }
730
- * @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`
731
755
  */
732
756
  function example(index, name, options = {}) {
733
757
  index._beginOp();
734
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'];
735
765
  const usageResults = usages(index, name, {
736
766
  codeOnly: true,
737
767
  className: options.className,
738
- exclude: ['test', 'spec', '__tests__', '__mocks__', 'fixture', 'mock'],
768
+ exclude,
739
769
  context: 5
740
770
  });
741
771
 
742
772
  const calls = usageResults.filter(u => u.usageType === 'call' && !u.isDefinition);
743
- 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
+ }
744
792
 
745
793
  const scored = calls.map(call => {
746
794
  let score = 0;
@@ -781,7 +829,73 @@ function example(index, name, options = {}) {
781
829
  });
782
830
 
783
831
  scored.sort((a, b) => b.score - a.score);
784
- 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
+ };
785
899
  } finally { index._endOp(); }
786
900
  }
787
901
 
@@ -1248,11 +1362,17 @@ function _addTestCaseMatches(index, filePath, fileEntry, searchTerm, className,
1248
1362
  if (!fileEntry.symbols) return;
1249
1363
  try {
1250
1364
  const langModule = getLanguageModule(lang);
1251
- 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;
1252
1372
 
1253
1373
  // Find test symbols
1254
1374
  for (const symbol of fileEntry.symbols) {
1255
- if (!langModule.isEntryPoint(symbol)) continue;
1375
+ if (!classify(symbol)) continue;
1256
1376
  // Check if any non-import usage of searchTerm falls within this test function
1257
1377
  const usageInRange = matches.some(m =>
1258
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();
@@ -561,8 +561,11 @@ function affectedTests(index, name, options = {}) {
561
561
  for (const [filePath, fileEntry] of index.files) {
562
562
  let isTest = isTestFile(fileEntry.relativePath, fileEntry.language);
563
563
  // Rust inline #[cfg(test)] modules: source files with #[test]-marked symbols
564
+ // or symbols inside a #[cfg(test)] mod block (BUG-CY).
564
565
  if (!isTest && fileEntry.language === 'rust') {
565
- 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
+ );
566
569
  }
567
570
  if (!isTest) continue;
568
571
  if (excludeArr.length > 0 && !index.matchesFilters(fileEntry.relativePath, { exclude: excludeArr })) continue;
@@ -744,9 +747,16 @@ function _addAffectedTestCases(index, filePath, fileEntry, funcName, fileMatches
744
747
  if (!fileEntry.symbols) return;
745
748
  try {
746
749
  const langModule = getLanguageModule(lang);
747
- 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;
748
758
  for (const symbol of fileEntry.symbols) {
749
- if (!langModule.isEntryPoint(symbol)) continue;
759
+ if (!classify(symbol)) continue;
750
760
  // Only add test-case if a call match exists in the test body
751
761
  const hasCallInRange = existingMatches.some(m =>
752
762
  m.line >= symbol.startLine && m.line <= symbol.endLine &&