ucn 4.0.2 → 4.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/ucn/SKILL.md +31 -7
- package/README.md +90 -51
- package/cli/index.js +199 -94
- package/core/account.js +3 -1
- package/core/analysis.js +322 -304
- package/core/bridge.js +13 -8
- package/core/cache.js +109 -19
- package/core/callers.js +969 -77
- package/core/check.js +19 -8
- package/core/deadcode.js +368 -40
- package/core/discovery.js +31 -11
- package/core/entrypoints.js +149 -17
- package/core/execute.js +330 -43
- package/core/graph-build.js +61 -10
- package/core/graph.js +282 -61
- package/core/imports.js +72 -3
- package/core/output/analysis-ext.js +70 -10
- package/core/output/analysis.js +52 -33
- package/core/output/check.js +4 -1
- package/core/output/endpoints.js +8 -3
- package/core/output/extraction.js +12 -1
- package/core/output/find.js +23 -9
- package/core/output/graph.js +32 -9
- package/core/output/refactoring.js +147 -49
- package/core/output/reporting.js +104 -5
- package/core/output/search.js +30 -3
- package/core/output/shared.js +31 -4
- package/core/output/tracing.js +22 -11
- package/core/parser.js +8 -6
- package/core/project.js +167 -7
- package/core/registry.js +20 -16
- package/core/reporting.js +131 -13
- package/core/search.js +270 -55
- package/core/shared.js +240 -1
- package/core/stacktrace.js +23 -1
- package/core/tracing.js +278 -36
- package/core/verify.js +352 -349
- package/languages/go.js +29 -17
- package/languages/index.js +56 -0
- package/languages/java.js +133 -16
- package/languages/javascript.js +215 -27
- package/languages/python.js +113 -47
- package/languages/rust.js +89 -26
- package/languages/utils.js +35 -7
- package/mcp/server.js +69 -30
- package/package.json +5 -1
package/core/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
|
|
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
|
*
|
|
@@ -202,11 +230,222 @@ function countTextBlindspots(content, language) {
|
|
|
202
230
|
};
|
|
203
231
|
}
|
|
204
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
|
+
|
|
205
438
|
module.exports = {
|
|
206
439
|
pickBestDefinition,
|
|
207
440
|
addTestExclusions,
|
|
208
441
|
escapeRegExp,
|
|
442
|
+
codeUnitCompare,
|
|
443
|
+
inlineTestRanges,
|
|
444
|
+
lineInRanges,
|
|
445
|
+
classDispatchNames,
|
|
446
|
+
maskBlockComments,
|
|
209
447
|
NON_CALLABLE_TYPES,
|
|
448
|
+
CALLABLE_SYMBOL_KINDS,
|
|
210
449
|
formatSymbolHandle,
|
|
211
450
|
parseSymbolHandle,
|
|
212
451
|
looksLikeHandle,
|
package/core/stacktrace.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|