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
package/core/shared.js CHANGED
@@ -5,6 +5,19 @@
5
5
  const { isTestFile } = require('./discovery');
6
6
  const { detectLanguage } = require('./parser');
7
7
 
8
+ /**
9
+ * Code-unit string comparison (rule 11 / fix #227): output ordering is part
10
+ * of the public contract and must be byte-identical across machines —
11
+ * localeCompare depends on the host ICU locale (case-insensitive-ish
12
+ * collation, locale tailoring), so two machines can render the same result
13
+ * in different orders. Every output-path comparator uses this instead.
14
+ */
15
+ function codeUnitCompare(a, b) {
16
+ const sa = String(a ?? '');
17
+ const sb = String(b ?? '');
18
+ return sa < sb ? -1 : sa > sb ? 1 : 0;
19
+ }
20
+
8
21
  /**
9
22
  * Path-based test heuristic — matches the same patterns as `find`'s exclusion
10
23
  * logic so that `about` and `find` agree on which files are de-emphasized.
@@ -59,7 +72,7 @@ function pickBestDefinition(matches, opts = {}) {
59
72
  });
60
73
  // Stable sort: by score desc, then alphabetical relativePath (so two equal-score
61
74
  // matches always pick the same one across runs).
62
- scored.sort((a, b) => (b.score - a.score) || a.rp.localeCompare(b.rp));
75
+ scored.sort((a, b) => (b.score - a.score) || codeUnitCompare(a.rp, b.rp));
63
76
  return scored[0].match;
64
77
  }
65
78
 
@@ -84,6 +97,21 @@ function escapeRegExp(text) {
84
97
  // Symbol types that are not callable (used to filter class/struct/type declarations from call analysis)
85
98
  const NON_CALLABLE_TYPES = new Set(['class', 'struct', 'interface', 'type', 'enum', 'trait', 'state', 'impl', 'field']);
86
99
 
100
+ /**
101
+ * Every function-shaped symbol kind across the parsers (fix #251 — stats
102
+ * ranked "longest/hottest functions" from a 7-kind subset, so private
103
+ * methods, accessors, and dunders were invisible to the rankings while the
104
+ * same command's "By Type" counted them). deadcode keeps its own narrower
105
+ * list: dunders ('special') stay out of the audit — protocol dispatch is
106
+ * invisible to the usage scan.
107
+ */
108
+ const CALLABLE_SYMBOL_KINDS = new Set([
109
+ 'function', 'method', 'static', 'public', 'abstract', 'constructor',
110
+ 'private', 'get', 'set', 'property', 'setter', 'deleter', 'classmethod',
111
+ 'special', 'override', 'static get', 'static set', 'override get',
112
+ 'override set', 'static override', 'static override get', 'static override set',
113
+ ]);
114
+
87
115
  /**
88
116
  * Stable symbol handle: `relativePath:line` or `relativePath:line:name`.
89
117
  *
@@ -159,14 +187,270 @@ function isOverrideMarked(def) {
159
187
  return false;
160
188
  }
161
189
 
190
+ // Per-language text patterns for the "blind spots" UCN's AST can't follow:
191
+ // eval/exec-style code execution and reflection (dynamic attribute access /
192
+ // dynamic dispatch). ONE source of truth so doctor's trust scan and
193
+ // detectCompleteness's about-footer warning count identically (field-report #2:
194
+ // they used to diverge — doctor 497 reflection vs footer 194, eval 3 vs 2 —
195
+ // because each kept its own regex set). Dynamic imports are NOT here: those are
196
+ // structural (fileEntry.dynamicImports), the AST-accurate count both paths share.
197
+ // `new Function(...)` is categorized as eval (code execution), not reflection.
198
+ const BLINDSPOT_TEXT_PATTERNS = {
199
+ reflection: {
200
+ python: /\b(getattr|hasattr|setattr|__import__|importlib\.import_module)\s*\(/g,
201
+ javascript: /\bReflect\.\w+\s*\(/g,
202
+ typescript: /\bReflect\.\w+\s*\(/g,
203
+ go: /\breflect\.\w+\s*\(/g,
204
+ java: /\.getDeclaredMethod\b|\.getMethod\b|\.getDeclaredField\b|Class\.forName\b/g,
205
+ rust: /\bAny::downcast/g,
206
+ },
207
+ eval: {
208
+ python: /\b(eval|exec)\s*\(/g,
209
+ javascript: /\beval\s*\(|\bnew\s+Function\s*\(/g,
210
+ typescript: /\beval\s*\(|\bnew\s+Function\s*\(/g,
211
+ },
212
+ };
213
+
214
+ /** True when a language has any text-blind-spot pattern (so callers can skip the file read otherwise). */
215
+ function hasTextBlindspots(language) {
216
+ return !!(BLINDSPOT_TEXT_PATTERNS.reflection[language] || BLINDSPOT_TEXT_PATTERNS.eval[language]);
217
+ }
218
+
219
+ /**
220
+ * Count text-detected blind spots (eval/exec, reflection) in one file's source.
221
+ * Returns { eval, reflection } OCCURRENCE counts (global match). Shared by doctor
222
+ * and detectCompleteness so both report the same numbers (field-report #2).
223
+ */
224
+ function countTextBlindspots(content, language) {
225
+ const reRe = BLINDSPOT_TEXT_PATTERNS.reflection[language];
226
+ const evRe = BLINDSPOT_TEXT_PATTERNS.eval[language];
227
+ return {
228
+ eval: evRe ? (content.match(evRe) || []).length : 0,
229
+ reflection: reRe ? (content.match(reRe) || []).length : 0,
230
+ };
231
+ }
232
+
233
+ /**
234
+ * Line ranges of INLINE test symbols in a source file — Rust #[test] fns and
235
+ * #[cfg(test)] module members. A production file promoted to "test file"
236
+ * because it CONTAINS an inline test module is test code only within these
237
+ * ranges; counting its production lines as test matches claimed false
238
+ * coverage (fix #244: `let url = self.build_url(path)` in a production
239
+ * method body was credited as a test of build_url).
240
+ * @returns {Array<[number, number]>}
241
+ */
242
+ function inlineTestRanges(fileEntry) {
243
+ const ranges = [];
244
+ for (const s of fileEntry.symbols || []) {
245
+ if (s.modifiers?.includes('test') || s.modifiers?.includes('cfg_test_module')) {
246
+ ranges.push([s.startLine, s.endLine ?? s.startLine]);
247
+ }
248
+ }
249
+ return ranges;
250
+ }
251
+
252
+ /** True when a line number falls inside any of the given [start, end] ranges. */
253
+ function lineInRanges(line, ranges) {
254
+ for (const [s, e] of ranges) {
255
+ if (line >= s && line <= e) return true;
256
+ }
257
+ return false;
258
+ }
259
+
260
+ /**
261
+ * Class names whose instances dispatch `methodName` to className's own
262
+ * definition: the class itself plus its transitive NON-overriding
263
+ * descendants (fix #246 — the #198 subtype rule brought to the test-scan
264
+ * className scoping: `c.describe()` on `Circle extends Shape` runs
265
+ * Shape.describe when Circle doesn't override it). Descent stops at an
266
+ * overriding child — its subtree binds the override, not the target.
267
+ * @returns {Set<string>}
268
+ */
269
+ function classDispatchNames(index, className, methodName, cap = 256) {
270
+ const out = new Set([className]);
271
+ if (!index?.extendedByGraph || !methodName) return out;
272
+ const methodDefs = index.symbols?.get(methodName) || [];
273
+ const queue = [className];
274
+ while (queue.length > 0 && out.size < cap) {
275
+ const children = index.extendedByGraph.get(queue.pop());
276
+ if (!children) continue;
277
+ for (const child of children) {
278
+ const cName = typeof child === 'string' ? child : child.name;
279
+ if (!cName || out.has(cName)) continue;
280
+ if (methodDefs.some(d => d.className === cName)) continue; // overrides
281
+ out.add(cName);
282
+ queue.push(cName);
283
+ }
284
+ }
285
+ return out;
286
+ }
287
+
288
+ // Languages with /* */ block comments (fix #253d). Python/HTML stay out:
289
+ // Python has none (docstrings are strings, and doctest code inside them is
290
+ // runnable), HTML's <!-- --> wraps virtual-JS line mapping.
291
+ const BLOCK_COMMENT_LANGS = new Set(['javascript', 'typescript', 'tsx', 'go', 'java', 'rust']);
292
+
293
+ // JS/TS chars after which a `/` starts a regex literal, not division.
294
+ const _REGEX_PREV_CHARS = new Set(['=', '(', '[', '{', ',', ';', ':', '!', '&', '|', '?', '+', '-', '*', '%', '~', '^', '<', '>']);
295
+ const _REGEX_PREV_WORDS = new Set(['return', 'typeof', 'case', 'in', 'of', 'new', 'delete', 'void', 'instanceof', 'do', 'else', 'yield', 'await']);
296
+
297
+ /**
298
+ * Replace /* ... *\/ block-comment interiors with spaces, preserving line
299
+ * structure, so line-based usage scans stop counting commented-out code as
300
+ * consumption (fix #253d — the deadcode scan only skipped // and # lines).
301
+ *
302
+ * Failure directions are asymmetric: masking real code drops real usages
303
+ * (false-dead risk), while missing a comment keeps the status quo (false-
304
+ * alive). The scanner therefore only opens a block on a literal `/*` whose
305
+ * string/regex context is positively ruled out, and every ambiguous
306
+ * construct (mis-detected regex, template interpolation, lifetime lookalike)
307
+ * resolves to "skip without masking".
308
+ */
309
+ function maskBlockComments(content, language) {
310
+ if (!BLOCK_COMMENT_LANGS.has(language) || !content.includes('/*')) return content;
311
+ const isJsLike = language === 'javascript' || language === 'typescript' || language === 'tsx';
312
+ const hasBacktick = isJsLike || language === 'go';
313
+ const out = content.split('');
314
+ const n = content.length;
315
+ let i = 0;
316
+ let prevSig = null; // last significant char seen in code mode
317
+ let prevWord = ''; // last identifier-ish word (survives whitespace)
318
+ const wordCh = (c) => c != null && /[\w$]/.test(c);
319
+ while (i < n) {
320
+ const ch = content[i];
321
+ const next = i + 1 < n ? content[i + 1] : '';
322
+ if (ch === '/' && next === '/') {
323
+ // Line comment — leave the text (the line scan handles // itself)
324
+ while (i < n && content[i] !== '\n') i++;
325
+ continue;
326
+ }
327
+ if (ch === '/' && next === '*') {
328
+ let depth = 1;
329
+ out[i] = ' '; out[i + 1] = ' ';
330
+ i += 2;
331
+ while (i < n && depth > 0) {
332
+ if (content[i] === '*' && content[i + 1] === '/') {
333
+ depth--; out[i] = ' '; out[i + 1] = ' '; i += 2; continue;
334
+ }
335
+ if (language === 'rust' && content[i] === '/' && content[i + 1] === '*') {
336
+ depth++; out[i] = ' '; out[i + 1] = ' '; i += 2; continue; // Rust block comments nest
337
+ }
338
+ if (content[i] !== '\n') out[i] = ' ';
339
+ i++;
340
+ }
341
+ prevSig = null; prevWord = '';
342
+ continue;
343
+ }
344
+ if (isJsLike && ch === '/') {
345
+ // Regex literal detection: a false "regex" here only SKIPS a
346
+ // region (missing comments inside it — safe); it never masks.
347
+ const isRegex = prevSig === null || _REGEX_PREV_CHARS.has(prevSig) ||
348
+ (wordCh(prevSig) && _REGEX_PREV_WORDS.has(prevWord));
349
+ if (isRegex) {
350
+ let j = i + 1, inClass = false;
351
+ while (j < n && content[j] !== '\n') {
352
+ const c = content[j];
353
+ if (c === '\\') { j += 2; continue; }
354
+ if (c === '[') inClass = true;
355
+ else if (c === ']') inClass = false;
356
+ else if (c === '/' && !inClass) break;
357
+ j++;
358
+ }
359
+ if (j < n && content[j] === '/') {
360
+ i = j + 1; prevSig = '/'; prevWord = '';
361
+ continue;
362
+ }
363
+ // No closing slash on the line — it was division after all.
364
+ }
365
+ }
366
+ if (language === 'rust' && (ch === 'r' || ch === 'b') && !(i > 0 && wordCh(content[i - 1]))) {
367
+ // Raw strings r"..." / r#"..."# / br#"..."# span lines with no escapes.
368
+ let j = i + (ch === 'b' && next === 'r' ? 2 : 1);
369
+ if (ch === 'r' || (ch === 'b' && next === 'r')) {
370
+ let hashes = 0;
371
+ while (content[j] === '#') { hashes++; j++; }
372
+ if (content[j] === '"') {
373
+ j++;
374
+ while (j < n) {
375
+ if (content[j] === '"') {
376
+ let h = 0;
377
+ while (h < hashes && content[j + 1 + h] === '#') h++;
378
+ if (h === hashes) { j += 1 + hashes; break; }
379
+ }
380
+ j++;
381
+ }
382
+ i = j; prevSig = '"'; prevWord = '';
383
+ continue;
384
+ }
385
+ }
386
+ }
387
+ if (ch === '"' || ch === "'") {
388
+ if (language === 'java' && ch === '"' && next === '"' && content[i + 2] === '"') {
389
+ // Java text block """...""" — spans lines
390
+ i += 3;
391
+ while (i < n && !(content[i] === '"' && content[i + 1] === '"' && content[i + 2] === '"')) i++;
392
+ i += 3; prevSig = '"'; prevWord = '';
393
+ continue;
394
+ }
395
+ if (language === 'rust' && ch === "'" && !(next === '\\' || content[i + 2] === "'")) {
396
+ // Lifetime/label ('a, 'outer:), not a char literal
397
+ prevSig = ch; prevWord = ''; i++;
398
+ continue;
399
+ }
400
+ const quote = ch;
401
+ i++;
402
+ if (language === 'rust' && quote === '"') {
403
+ // Rust plain strings may span lines
404
+ while (i < n && content[i] !== quote) {
405
+ if (content[i] === '\\') i++;
406
+ i++;
407
+ }
408
+ } else {
409
+ // Single-line semantics elsewhere; unterminated ends at EOL
410
+ while (i < n && content[i] !== quote && content[i] !== '\n') {
411
+ if (content[i] === '\\') i++;
412
+ i++;
413
+ }
414
+ }
415
+ if (i < n && content[i] === quote) i++;
416
+ prevSig = quote; prevWord = '';
417
+ continue;
418
+ }
419
+ if (hasBacktick && ch === '`') {
420
+ // JS/TS template literal / Go raw string — spans lines
421
+ i++;
422
+ while (i < n && content[i] !== '`') {
423
+ if (isJsLike && content[i] === '\\') i++; // Go raw strings have no escapes
424
+ i++;
425
+ }
426
+ i++; prevSig = '`'; prevWord = '';
427
+ continue;
428
+ }
429
+ if (!/\s/.test(ch)) {
430
+ prevWord = wordCh(ch) ? (wordCh(prevSig) ? prevWord + ch : ch) : '';
431
+ prevSig = ch;
432
+ }
433
+ i++;
434
+ }
435
+ return out.join('');
436
+ }
437
+
162
438
  module.exports = {
163
439
  pickBestDefinition,
164
440
  addTestExclusions,
165
441
  escapeRegExp,
442
+ codeUnitCompare,
443
+ inlineTestRanges,
444
+ lineInRanges,
445
+ classDispatchNames,
446
+ maskBlockComments,
166
447
  NON_CALLABLE_TYPES,
448
+ CALLABLE_SYMBOL_KINDS,
167
449
  formatSymbolHandle,
168
450
  parseSymbolHandle,
169
451
  looksLikeHandle,
170
452
  isTestPath,
171
453
  isOverrideMarked,
454
+ hasTextBlindspots,
455
+ countTextBlindspots,
172
456
  };
@@ -276,6 +276,15 @@ function parseStackTrace(index, stackText) {
276
276
  // Stack trace patterns for different languages/runtimes
277
277
  // Order matters - more specific patterns first
278
278
  const patterns = [
279
+ // Rust pre-1.65 panic header: "panicked at 'message', src/main.rs:150:9"
280
+ // MUST precede the Node pattern — its [^():]+ file group has no
281
+ // space/comma guard, so the quoted message glued into the file field
282
+ // and the panic-location frame never resolved (fix #251).
283
+ { regex: /panicked at '(?:[^'\\]|\\.)*',\s+([^\s:]+):(\d+)(?::(\d+))?/, extract: (m) => ({ file: m[1], line: parseInt(m[2]), col: m[3] ? parseInt(m[3]) : null, funcName: null }) },
284
+ // V8 nested eval: "at eval (eval at createProcessor (file.js:10:5), <anonymous>:1:20)"
285
+ // Attribute to the outer function — the inner <anonymous> position
286
+ // has no project file (fix #251: the name parsed as "eval (eval at …").
287
+ { regex: /at\s+.*?\beval at\s+(.+?)\s+\(([^():]+):(\d+)(?::(\d+))?\)/, extract: (m) => ({ funcName: m[1], file: m[2], line: parseInt(m[3]), col: m[4] ? parseInt(m[4]) : null }) },
279
288
  // JavaScript Node.js: "at functionName (file.js:line:col)" or "at file.js:line:col"
280
289
  { regex: /at\s+(?:async\s+)?(?:(.+?)\s+\()?([^():]+):(\d+)(?::(\d+))?\)?/, extract: (m) => ({ funcName: m[1] || null, file: m[2], line: parseInt(m[3]), col: m[4] ? parseInt(m[4]) : null }) },
281
290
  // Deno: "at functionName (file:///path/to/file.ts:line:col)"
@@ -305,6 +314,7 @@ function parseStackTrace(index, stackText) {
305
314
 
306
315
  // Track Go function names that appear on a line before the file:line
307
316
  let pendingGoFuncName = null;
317
+ let skippedFrames = 0;
308
318
 
309
319
  for (const line of lines) {
310
320
  const trimmed = line.trim();
@@ -318,7 +328,10 @@ function parseStackTrace(index, stackText) {
318
328
  if (pattern.extract === null) {
319
329
  // Go function-only line (e.g. "package.FunctionName()")
320
330
  // Extract the function name and carry it forward to the next file:line
321
- const fullName = match[1];
331
+ // Generic instantiation markers first: "pkg.MapKeys[...]"
332
+ // — lastIndexOf('.') landed inside the brackets and the
333
+ // frame name parsed as "]" (fix #251).
334
+ const fullName = match[1].replace(/\[[^\]]*\]$/, '');
322
335
  // Go uses fully-qualified names: pkg/path.FuncName or pkg/path.(*Type).Method
323
336
  const lastDot = fullName.lastIndexOf('.');
324
337
  pendingGoFuncName = lastDot >= 0 ? fullName.slice(lastDot + 1) : fullName;
@@ -351,12 +364,21 @@ function parseStackTrace(index, stackText) {
351
364
  }
352
365
  }
353
366
  if (!matched) {
367
+ // Frame-shaped lines without a resolvable file:line — Java
368
+ // "(Unknown Source)"/"(Native Method)", zero-position frames —
369
+ // used to vanish without a trace (fix #251: inconsistent with
370
+ // unfound-file frames, which render found=false).
371
+ if (/^at\s/.test(trimmed)) skippedFrames++;
354
372
  pendingGoFuncName = null; // Reset if line doesn't match any pattern
355
373
  }
356
374
  }
357
375
 
358
376
  return {
377
+ // Advisory command (v4 two-tier surface): frames matched by path
378
+ // similarity scoring, not verified identity.
379
+ advisory: 'best-effort-frame-matching',
359
380
  frameCount: frames.length,
381
+ ...(skippedFrames > 0 && { skippedFrames }),
360
382
  frames
361
383
  };
362
384
  }