ucn 5.3.8 → 5.4.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.
- package/.claude/skills/ucn/SKILL.md +28 -2
- package/.claude/skills/ucn/references/commands.md +6 -0
- package/core/account.js +4 -0
- package/core/analysis.js +80 -1
- package/core/cache.js +2 -1
- package/core/callers.js +21 -2
- package/core/execute.js +18 -2
- package/core/output/analysis.js +5 -5
- package/core/output/public.js +13 -4
- package/core/output/refactoring.js +4 -1
- package/core/project.js +5 -0
- package/core/search.js +9 -1
- package/core/shared.js +13 -4
- package/core/verify.js +6 -1
- package/languages/index.js +4 -0
- package/languages/python.js +8 -1
- package/package.json +2 -2
|
@@ -143,8 +143,10 @@ token when it differs from `startLine`). Structural `search --unused` keeps its
|
|
|
143
143
|
safety note and decorator tags in shell output; runtime registrations can appear
|
|
144
144
|
and zero call edges do not prove a symbol is safe to delete.
|
|
145
145
|
`--lines` lists the whole band without default row/character caps, so pipe through
|
|
146
|
-
`grep -v '# unverified'` for the confirmed tier
|
|
147
|
-
|
|
146
|
+
`grep -v '# unverified'` for the confirmed tier. To count distinct source lines
|
|
147
|
+
per file, use `cut -d: -f1,2 | sort -u | cut -d: -f1 | sort | uniq -c`;
|
|
148
|
+
counting raw usage records can count a source line more than once.
|
|
149
|
+
Nothing to list prints nothing and exits 1, grep's
|
|
148
150
|
contract; errors exit 2. Explicit `--top`/`--limit` still apply and disclose
|
|
149
151
|
omissions. `show --lines` accepts only callers/callees sections; target-less
|
|
150
152
|
`impact --lines` lists Git-diff callers with per-target accounting. Closing a
|
|
@@ -208,6 +210,30 @@ The selection note discloses that approximation. `find`, text `search`,
|
|
|
208
210
|
(structural `search`: 50). Use an explicit `--limit=N` to request more;
|
|
209
211
|
`usages` and `--lines` have no default row cap.
|
|
210
212
|
|
|
213
|
+
Automatic test filtering follows the language's conventions: Python `spec.py`
|
|
214
|
+
and `chart_spec.py` are production paths; `test_*.py`, `*_test.py`, and test
|
|
215
|
+
directories remain test paths. Structural search discloses hidden test files,
|
|
216
|
+
including empty results; `--include-tests` gives the full indexed inventory.
|
|
217
|
+
Explicit `--exclude=spec` still means the requested path exclusion.
|
|
218
|
+
|
|
219
|
+
Public JSON source `file` fields are relative to `meta.pathBase` (the absolute
|
|
220
|
+
project root). Dependency edge paths use the same base. Both absolute and
|
|
221
|
+
relative indexed-file handles are accepted. Definition handles retain their
|
|
222
|
+
decorator span; use `nameLine` for the name token rather than joining usages
|
|
223
|
+
to a handle's start line.
|
|
224
|
+
|
|
225
|
+
Callable references passed to another function remain visible when a project
|
|
226
|
+
method or function could be their target. An ordinary attribute read with no
|
|
227
|
+
callable member candidate stays a non-call reference in ACCOUNT and `usages`.
|
|
228
|
+
The caller model includes callback dependencies; it does not prove that the
|
|
229
|
+
receiving function invokes every passed callable.
|
|
230
|
+
|
|
231
|
+
`audit-async` checks recognized async producers. In JS/TS/HTML it also checks
|
|
232
|
+
captured promises used in arithmetic, conditions, or resolved-value member
|
|
233
|
+
access within the same lexical scope. Awaiting, returning, promise handlers,
|
|
234
|
+
reassignment, and shadowed bindings are distinguished. It is a bounded AST
|
|
235
|
+
audit, not a compiler-wide proof that every missing await has been found.
|
|
236
|
+
|
|
211
237
|
For `plan --rename-to`, the selected declaration is only the starting point.
|
|
212
238
|
When the index proves the relationship, the rename unit closes over
|
|
213
239
|
overload/signature groups, base and override declarations, Rust trait slots,
|
|
@@ -57,6 +57,12 @@ Structural `search --param` matches parameter names, types, and defaults; `--ret
|
|
|
57
57
|
|
|
58
58
|
`--lines` writes one record per output line. `usages` records occurrences, so multiple tokens on the same source line can produce repeated `path:line` values. Deduplicate those values when counting source lines.
|
|
59
59
|
|
|
60
|
+
Public JSON source paths (`file`, caller files, dependency roots and edges) are project-relative, with the absolute base in `meta.pathBase`. Project roots and external paths remain absolute. Indexed absolute handles are accepted as well as relative handles.
|
|
61
|
+
|
|
62
|
+
Default test exclusions follow language conventions. Python `spec.py` and `*_spec.py` are included; `test_*.py`, `*_test.py`, and test directories are excluded. Structural search reports hidden test-file counts, including on empty results. `--include-tests` disables these defaults; explicit `--exclude` patterns still apply.
|
|
63
|
+
|
|
64
|
+
`audit-async` checks recognized async producers, including captured JS/TS/HTML promises used as resolved values in the same lexical scope. Promise returns and handlers are valid; alias flow and unknown receivers require compiler/type-checker review.
|
|
65
|
+
|
|
60
66
|
## Common flags
|
|
61
67
|
|
|
62
68
|
| Flag | Meaning |
|
package/core/account.js
CHANGED
|
@@ -301,6 +301,10 @@ function classifyGroundLines(index, name, groundSet, claimedKeys) {
|
|
|
301
301
|
const callLines = new Set();
|
|
302
302
|
if (Array.isArray(cachedCalls)) {
|
|
303
303
|
for (const c of cachedCalls) {
|
|
304
|
+
// Unclaimed callback/type references have no invocation
|
|
305
|
+
// syntax. Let the usage AST classify them as references;
|
|
306
|
+
// merely entering the candidate cache is not a call fact.
|
|
307
|
+
if (c.isFunctionReference || c.isTypeReference) continue;
|
|
304
308
|
if (c.name === name || c.resolvedName === name ||
|
|
305
309
|
(c.resolvedNames && c.resolvedNames.includes(name))) {
|
|
306
310
|
callLines.add(c.line);
|
package/core/analysis.js
CHANGED
|
@@ -2656,6 +2656,78 @@ const _ASYNCIO_CONSUMER_FNS = new Set([
|
|
|
2656
2656
|
'gather', 'create_task', 'ensure_future', 'wait', 'as_completed',
|
|
2657
2657
|
]);
|
|
2658
2658
|
|
|
2659
|
+
// Follow a captured JS promise within its lexical scope, looking only for
|
|
2660
|
+
// operations that require its resolved value. Passing/returning/awaiting the
|
|
2661
|
+
// promise and its own methods are valid. Reassignment stops the inference;
|
|
2662
|
+
// nested functions and shadowing blocks cannot borrow the outer binding.
|
|
2663
|
+
function storedPromiseMisuse(call, functionNodes) {
|
|
2664
|
+
const { sameNode } = require('../languages/utils');
|
|
2665
|
+
let value = call;
|
|
2666
|
+
while (value.parent?.type === 'parenthesized_expression') value = value.parent;
|
|
2667
|
+
const assignment = value.parent;
|
|
2668
|
+
if (!assignment || !['variable_declarator', 'assignment_expression'].includes(assignment.type)) return null;
|
|
2669
|
+
const binding = assignment.childForFieldName(assignment.type === 'variable_declarator' ? 'name' : 'left');
|
|
2670
|
+
if (binding?.type !== 'identifier') return null;
|
|
2671
|
+
let scope = assignment.parent;
|
|
2672
|
+
while (scope && scope.type !== 'statement_block' && !functionNodes.has(scope.type)) scope = scope.parent;
|
|
2673
|
+
if (!scope) return null;
|
|
2674
|
+
const name = binding.text;
|
|
2675
|
+
const promiseMembers = new Set(['then', 'catch', 'finally', 'constructor',
|
|
2676
|
+
'toString', 'toLocaleString', 'valueOf', 'hasOwnProperty', 'isPrototypeOf', 'propertyIsEnumerable']);
|
|
2677
|
+
let stopped = false;
|
|
2678
|
+
let misuse = null;
|
|
2679
|
+
const namesBinding = node => !!node && ((['identifier', 'shorthand_property_identifier_pattern'].includes(node.type) && node.text === name) ||
|
|
2680
|
+
node.namedChildren.some(namesBinding));
|
|
2681
|
+
const shadows = block => (block.namedChildren || []).some(statement =>
|
|
2682
|
+
['lexical_declaration', 'variable_declaration'].includes(statement.type) &&
|
|
2683
|
+
statement.namedChildren.some(decl => namesBinding(decl.childForFieldName('name'))));
|
|
2684
|
+
const visit = node => {
|
|
2685
|
+
if (stopped || misuse || node.endIndex <= call.endIndex) return;
|
|
2686
|
+
if (functionNodes.has(node.type)) return;
|
|
2687
|
+
if (!sameNode(node, scope) && node.type === 'statement_block' && shadows(node)) return;
|
|
2688
|
+
if (node.type === 'catch_clause' && namesBinding(node.childForFieldName('parameter'))) return;
|
|
2689
|
+
if (node.type === 'for_statement' && shadows(node)) return;
|
|
2690
|
+
if (node.type === 'for_in_statement' && namesBinding(node.childForFieldName('left'))) {
|
|
2691
|
+
// A declared loop variable shadows; an undeclared one overwrites
|
|
2692
|
+
// the promise, so later uses cannot inherit its earlier type.
|
|
2693
|
+
if (!node.children.some(child => ['let', 'const'].includes(child.type))) stopped = true;
|
|
2694
|
+
return;
|
|
2695
|
+
}
|
|
2696
|
+
if (node.type === 'assignment_expression' && !sameNode(node, assignment) && node.childForFieldName('left')?.text === name) {
|
|
2697
|
+
const right = node.childForFieldName('right');
|
|
2698
|
+
if (right) visit(right);
|
|
2699
|
+
stopped = true;
|
|
2700
|
+
return;
|
|
2701
|
+
}
|
|
2702
|
+
if (node.type === 'identifier' && node.text === name && node.startIndex >= call.endIndex) {
|
|
2703
|
+
let use = node;
|
|
2704
|
+
while (use.parent?.type === 'parenthesized_expression') use = use.parent;
|
|
2705
|
+
const parent = use.parent;
|
|
2706
|
+
if (parent?.type === 'member_expression' && sameNode(parent.childForFieldName('object'), use)) {
|
|
2707
|
+
const property = parent.childForFieldName('property');
|
|
2708
|
+
if (property && !promiseMembers.has(property.text)) misuse = node;
|
|
2709
|
+
} else if (parent?.type === 'subscript_expression' && sameNode(parent.childForFieldName('object'), use)) {
|
|
2710
|
+
misuse = node;
|
|
2711
|
+
} else if (parent?.type === 'binary_expression') {
|
|
2712
|
+
const operator = parent.childForFieldName('operator')?.text;
|
|
2713
|
+
if (['+', '-', '*', '/', '%', '**', '<', '>', '<=', '>=', '|', '&', '^', '<<', '>>', '>>>'].includes(operator)) misuse = node;
|
|
2714
|
+
} else if (parent?.type === 'unary_expression' && ['+', '-', '~'].includes(parent.childForFieldName('operator')?.text)) {
|
|
2715
|
+
misuse = node;
|
|
2716
|
+
} else if (parent && ['update_expression', 'augmented_assignment_expression'].includes(parent.type)) {
|
|
2717
|
+
misuse = node;
|
|
2718
|
+
} else if (parent && ['if_statement', 'while_statement', 'do_statement', 'ternary_expression'].includes(parent.type) &&
|
|
2719
|
+
sameNode(parent.childForFieldName('condition'), use)) {
|
|
2720
|
+
misuse = node;
|
|
2721
|
+
}
|
|
2722
|
+
}
|
|
2723
|
+
for (const child of node.namedChildren || []) visit(child);
|
|
2724
|
+
};
|
|
2725
|
+
// A function root's body is the scan scope, never its parameters.
|
|
2726
|
+
visit(scope.type === 'statement_block' ? scope : scope.childForFieldName('body') || scope);
|
|
2727
|
+
return misuse ? { line: misuse.startPosition.row + 1, variable: name,
|
|
2728
|
+
originLine: call.startPosition.row + 1, reason: 'stored-promise-used-as-value' } : null;
|
|
2729
|
+
}
|
|
2730
|
+
|
|
2659
2731
|
/**
|
|
2660
2732
|
* Run an async/await audit across the project.
|
|
2661
2733
|
*
|
|
@@ -3012,6 +3084,10 @@ function auditAsync(index, options = {}) {
|
|
|
3012
3084
|
let current = node.parent;
|
|
3013
3085
|
let awaitDepth = 0;
|
|
3014
3086
|
while (current && awaitDepth++ < 5) {
|
|
3087
|
+
if (current.type === 'parenthesized_expression') {
|
|
3088
|
+
current = current.parent;
|
|
3089
|
+
continue;
|
|
3090
|
+
}
|
|
3015
3091
|
if (current.type === 'await_expression' ||
|
|
3016
3092
|
current.type === 'await') {
|
|
3017
3093
|
awaited = true;
|
|
@@ -3034,12 +3110,15 @@ function auditAsync(index, options = {}) {
|
|
|
3034
3110
|
}
|
|
3035
3111
|
break;
|
|
3036
3112
|
}
|
|
3037
|
-
|
|
3113
|
+
const storedMisuse = !awaited && langTraits(language)?.storedPromises
|
|
3114
|
+
? storedPromiseMisuse(node, FN_NODE_TYPES) : null;
|
|
3115
|
+
if (storedMisuse || (!awaited && !isFireAndForget(node, language))) {
|
|
3038
3116
|
issues.push({
|
|
3039
3117
|
file: fileEntry.relativePath || filePath,
|
|
3040
3118
|
line,
|
|
3041
3119
|
callerName: enclosing.name,
|
|
3042
3120
|
calleeName,
|
|
3121
|
+
...(storedMisuse || {}),
|
|
3043
3122
|
});
|
|
3044
3123
|
}
|
|
3045
3124
|
}
|
package/core/cache.js
CHANGED
|
@@ -713,7 +713,8 @@ function clearAllCaches() {
|
|
|
713
713
|
// v225 (fix #357): Rust `use path::name as local` bindings record the original name with a paired `renames` alias.
|
|
714
714
|
// v226: bundled/minified filename exclusions are disclosed in discoveryIssues.
|
|
715
715
|
// v227: signature parameter/return text excludes AST comments in every language.
|
|
716
|
-
|
|
716
|
+
// v228: Python keyword arguments retain the same callable-reference facts as positional arguments.
|
|
717
|
+
const CACHE_FORMAT_VERSION = 228;
|
|
717
718
|
const USAGE_CACHE_FILE = 'usage-results.json';
|
|
718
719
|
|
|
719
720
|
/**
|
package/core/callers.js
CHANGED
|
@@ -291,6 +291,22 @@ function _javaConstructorDisposition(index, filePath, fileEntry, call, targetDef
|
|
|
291
291
|
return 'unknown';
|
|
292
292
|
}
|
|
293
293
|
|
|
294
|
+
// A structural member passed as a value is not invocation syntax. Keep it
|
|
295
|
+
// in the callback model only when a project member can actually be callable;
|
|
296
|
+
// an unrelated standalone function's spelling is not such evidence. Module
|
|
297
|
+
// members remain eligible because modules can export standalone functions.
|
|
298
|
+
function isDataMemberReference(fileEntry, call, definitions) {
|
|
299
|
+
if (!call.isFunctionReference || !call.isMethod || call.receiverIsModule ||
|
|
300
|
+
call.receiverModuleSpecifier || langTraits(fileEntry?.language)?.typeSystem !== 'structural') return false;
|
|
301
|
+
if (_structuralModuleBindings(fileEntry, call).length > 0) return false;
|
|
302
|
+
return !definitions.some(def => !NON_CALLABLE_TYPES.has(def.type) &&
|
|
303
|
+
// A typed Python descriptor read really invokes its getter; retain
|
|
304
|
+
// that existing proof path. An untyped property spelling belongs to
|
|
305
|
+
// the separate property-access inventory, never the call band.
|
|
306
|
+
(!require('./accessors').isAccessorDefinition(def) || call.receiverType) &&
|
|
307
|
+
(def.className || def.receiver));
|
|
308
|
+
}
|
|
309
|
+
|
|
294
310
|
/**
|
|
295
311
|
* Find all call sites that invoke the named symbol.
|
|
296
312
|
*
|
|
@@ -697,6 +713,7 @@ function findCallers(index, name, options = {}) {
|
|
|
697
713
|
langTraits(fileEntry.language)?.typeSystem === 'structural';
|
|
698
714
|
|
|
699
715
|
for (let call of calls) {
|
|
716
|
+
if (isDataMemberReference(fileEntry, call, options.targetDefinitions || definitions)) continue;
|
|
700
717
|
// fix #353: C# `Beta.Helper.Widget()` — the parser records a
|
|
701
718
|
// field hop rooted at `this` (Beta is no local). When the
|
|
702
719
|
// prefix names a project NAMESPACE that declares the last
|
|
@@ -5444,7 +5461,8 @@ function findCallees(index, definition, options = {}) {
|
|
|
5444
5461
|
// to this definition's source range. Nested closures deliberately
|
|
5445
5462
|
// remain in the slice and retain the existing inner-symbol rules.
|
|
5446
5463
|
const calls = _callsInDefinitionRange(index, def.file, allCalls,
|
|
5447
|
-
def.startLine, def.endLine)
|
|
5464
|
+
def.startLine, def.endLine).filter(call => !isDataMemberReference(
|
|
5465
|
+
index.files.get(def.file), call, index.symbols.get(call.name) || []));
|
|
5448
5466
|
// The reachability walk uses the legacy (non-accounting) path and most
|
|
5449
5467
|
// entry/test symbols contain no calls. Avoid constructing receiver,
|
|
5450
5468
|
// overload, and flow machinery for an empty source range. Contract
|
|
@@ -5545,7 +5563,8 @@ function findCallees(index, definition, options = {}) {
|
|
|
5545
5563
|
if (!entry) {
|
|
5546
5564
|
const defs = index.symbols.get(call.name) || [];
|
|
5547
5565
|
const owners = defs.filter(s => !NON_CALLABLE_TYPES.has(s.type)).length;
|
|
5548
|
-
entry = { name: call.name, reason, callCount: 0, sites: [], ownerCount: owners,
|
|
5566
|
+
entry = { name: call.name, reason, callCount: 0, sites: [], ownerCount: owners,
|
|
5567
|
+
...(call.isFunctionReference && { functionReference: true }), ...meta };
|
|
5549
5568
|
unverifiedCallees.set(key, entry);
|
|
5550
5569
|
}
|
|
5551
5570
|
entry.callCount++;
|
package/core/execute.js
CHANGED
|
@@ -1462,6 +1462,7 @@ const HANDLERS = {
|
|
|
1462
1462
|
unused: p.unused || false,
|
|
1463
1463
|
caseSensitive: p.caseSensitive || false,
|
|
1464
1464
|
exclude,
|
|
1465
|
+
testExclude: p.includeTests ? undefined : ['test files'],
|
|
1465
1466
|
in: p.in,
|
|
1466
1467
|
file: p.file,
|
|
1467
1468
|
top: topVal || (p.lines ? undefined : 50),
|
|
@@ -1470,13 +1471,15 @@ const HANDLERS = {
|
|
|
1470
1471
|
const unsupported = (!p.regex && (p.term || p.name))
|
|
1471
1472
|
? require('./account').scanUnsupportedFiles(index, p.term || p.name)
|
|
1472
1473
|
: null;
|
|
1473
|
-
let note
|
|
1474
|
+
let note = result.meta.filesSkipped > 0
|
|
1475
|
+
? `${result.meta.filesSkipped} test file(s) hidden by default (--include-tests).`
|
|
1476
|
+
: undefined;
|
|
1474
1477
|
if (unsupported?.lines > 0) {
|
|
1475
1478
|
Object.defineProperty(result, 'unsupportedMatches', {
|
|
1476
1479
|
value: unsupported,
|
|
1477
1480
|
enumerable: false, writable: true, configurable: true,
|
|
1478
1481
|
});
|
|
1479
|
-
note = `${unsupported.lines} matching line(s) in ${unsupported.fileCount} unsupported-language file(s) were not structurally analyzed; verify with grep/ripgrep
|
|
1482
|
+
note = combineNotes([note, `${unsupported.lines} matching line(s) in ${unsupported.fileCount} unsupported-language file(s) were not structurally analyzed; verify with grep/ripgrep.`]);
|
|
1480
1483
|
}
|
|
1481
1484
|
return { ok: true, result, structural: true, note };
|
|
1482
1485
|
}
|
|
@@ -2506,6 +2509,18 @@ function execute(index, command, params = {}) {
|
|
|
2506
2509
|
const validationError = validatePublicParams(command, params);
|
|
2507
2510
|
if (validationError) return { ok: false, error: validationError };
|
|
2508
2511
|
}
|
|
2512
|
+
// Public JSON paths and pasted stack frames may be absolute. Resolve
|
|
2513
|
+
// only indexed files, then use the same relative scope as our handles.
|
|
2514
|
+
const relativeIndexedFile = file => {
|
|
2515
|
+
if (!file || !path.isAbsolute(file)) return file;
|
|
2516
|
+
const resolved = index.resolveFilePathForQuery(file);
|
|
2517
|
+
return typeof resolved === 'string' ? index.files.get(resolved).relativePath : file;
|
|
2518
|
+
};
|
|
2519
|
+
if (params.file) params.file = relativeIndexedFile(params.file);
|
|
2520
|
+
const absoluteHandle = params.name && parseSymbolHandle(params.name);
|
|
2521
|
+
if (absoluteHandle && path.isAbsolute(absoluteHandle.file)) {
|
|
2522
|
+
params.name = relativeIndexedFile(absoluteHandle.file) + params.name.slice(absoluteHandle.file.length);
|
|
2523
|
+
}
|
|
2509
2524
|
// Resolve name-less handles (e.g. `lib.js:42`) via index lookup before dispatch.
|
|
2510
2525
|
// Handles WITH a name suffix are handled later by applyClassMethodSyntax.
|
|
2511
2526
|
if (params && params.name && looksLikeHandle(params.name)) {
|
|
@@ -2520,6 +2535,7 @@ function execute(index, command, params = {}) {
|
|
|
2520
2535
|
}
|
|
2521
2536
|
}
|
|
2522
2537
|
const response = handler(index, params);
|
|
2538
|
+
response.projectRoot = index.root;
|
|
2523
2539
|
const bundled = (index.discoveryIssues || []).filter(issue => issue.reason === 'bundled');
|
|
2524
2540
|
if (bundled.length > 0) {
|
|
2525
2541
|
const files = bundled.slice(0, 5).map(issue => issue.relativePath).join(', ');
|
package/core/output/analysis.js
CHANGED
|
@@ -205,7 +205,7 @@ function formatCalleeAccountLine(acct) {
|
|
|
205
205
|
function unverifiedCalleeLines(entries, compact) {
|
|
206
206
|
if (!entries || entries.length === 0) return [];
|
|
207
207
|
const lines = [];
|
|
208
|
-
lines.push(`${compact ? '' : '\n'}CALLEES — UNVERIFIED (${entries.length}) — call syntax, receiver/binding unresolved:`);
|
|
208
|
+
lines.push(`${compact ? '' : '\n'}CALLEES — UNVERIFIED (${entries.length}) — call or callable-reference syntax, receiver/binding unresolved:`);
|
|
209
209
|
for (const u of entries) {
|
|
210
210
|
const owners = u.ownerCount > 1 ? ` (${u.ownerCount} owners)` : '';
|
|
211
211
|
const sites = u.sites && u.sites.length > 0 ? ` L${u.sites.join(',L')}` : '';
|
|
@@ -461,7 +461,7 @@ function formatContext(ctx, options = {}) {
|
|
|
461
461
|
|
|
462
462
|
const typeUnverified = ctx.unverifiedCallers || [];
|
|
463
463
|
if (typeUnverified.length > 0) {
|
|
464
|
-
lines.push(`\nCALLERS — UNVERIFIED (${typeUnverified.length}) — call syntax, no binding/receiver evidence:`);
|
|
464
|
+
lines.push(`\nCALLERS — UNVERIFIED (${typeUnverified.length}) — call or callable-reference syntax, no binding/receiver evidence:`);
|
|
465
465
|
formatAmbiguityCandidates(lines, ctx.ambiguityCandidates);
|
|
466
466
|
const cap = 10;
|
|
467
467
|
let shown = 0;
|
|
@@ -688,7 +688,7 @@ function formatContext(ctx, options = {}) {
|
|
|
688
688
|
// Actionable ambiguity: call syntax without enough identity evidence.
|
|
689
689
|
// Always visible and capped at 10 one-liners unless --all.
|
|
690
690
|
if (actionableUnverified.length > 0) {
|
|
691
|
-
lines.push(`${compact ? '' : '\n'}CALLERS — UNVERIFIED (${actionableUnverified.length}) — call syntax, no binding/receiver evidence:`);
|
|
691
|
+
lines.push(`${compact ? '' : '\n'}CALLERS — UNVERIFIED (${actionableUnverified.length}) — call or callable-reference syntax, no binding/receiver evidence:`);
|
|
692
692
|
formatAmbiguityCandidates(lines, ctx.ambiguityCandidates);
|
|
693
693
|
const cap = (ctx.meta && ctx.meta.all) ? Infinity : 10;
|
|
694
694
|
let shown = 0;
|
|
@@ -927,7 +927,7 @@ function formatImpact(impact, options = {}) {
|
|
|
927
927
|
|
|
928
928
|
// Unverified tier: visible, capped at 10 one-liners
|
|
929
929
|
if (impactUnverified.length > 0) {
|
|
930
|
-
lines.push(`${compact ? '' : '\n'}UNVERIFIED CALL SITES (${impactUnverified.length}) — call syntax, no binding/receiver evidence:`);
|
|
930
|
+
lines.push(`${compact ? '' : '\n'}UNVERIFIED CALL SITES (${impactUnverified.length}) — call or callable-reference syntax, no binding/receiver evidence:`);
|
|
931
931
|
const cap = 10;
|
|
932
932
|
for (const site of impactUnverified.slice(0, cap)) {
|
|
933
933
|
const caller = site.callerName ? ` [${site.callerName}]` : '';
|
|
@@ -1085,7 +1085,7 @@ function formatAbout(about, options = {}) {
|
|
|
1085
1085
|
const aboutUnverified = about.callers.unverified;
|
|
1086
1086
|
if (aboutUnverified && aboutUnverified.total > 0) {
|
|
1087
1087
|
lines.push('');
|
|
1088
|
-
lines.push(`CALLERS — UNVERIFIED (${aboutUnverified.total}) — call syntax, no binding/receiver evidence:`);
|
|
1088
|
+
lines.push(`CALLERS — UNVERIFIED (${aboutUnverified.total}) — call or callable-reference syntax, no binding/receiver evidence:`);
|
|
1089
1089
|
for (const u of aboutUnverified.top) {
|
|
1090
1090
|
const caller = u.callerName ? ` [${u.callerName}]` : '';
|
|
1091
1091
|
const reason = u.reason ? ` (${unverifiedReasonLabel(u)})` : '';
|
package/core/output/public.js
CHANGED
|
@@ -40,12 +40,20 @@ function appendNote(text, note) {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
/** Canonicalize object keys so JSON bytes do not depend on index provenance. */
|
|
43
|
-
function canonicalJsonValue(value) {
|
|
44
|
-
if (Array.isArray(value)) return value.map(canonicalJsonValue);
|
|
43
|
+
function canonicalJsonValue(value, root, field = undefined) {
|
|
44
|
+
if (Array.isArray(value)) return value.map(item => canonicalJsonValue(item, root, field));
|
|
45
|
+
if (root && typeof value === 'string' &&
|
|
46
|
+
['file', 'filePath', 'callerFile', 'definitionFile', 'resolved', 'path', 'targetFile', 'from', 'to', 'root', 'files'].includes(field)) {
|
|
47
|
+
const path = require('path');
|
|
48
|
+
if (path.isAbsolute(value)) {
|
|
49
|
+
const relative = path.relative(root, value);
|
|
50
|
+
if (relative && relative !== '..' && !relative.startsWith('..' + path.sep) && !path.isAbsolute(relative)) return relative;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
45
53
|
if (!value || typeof value !== 'object') return value;
|
|
46
54
|
const canonical = {};
|
|
47
55
|
for (const key of Object.keys(value).sort()) {
|
|
48
|
-
canonical[key] = canonicalJsonValue(provenanceReplacer(key, value[key]));
|
|
56
|
+
canonical[key] = canonicalJsonValue(provenanceReplacer(key, value[key]), root, key);
|
|
49
57
|
}
|
|
50
58
|
return canonical;
|
|
51
59
|
}
|
|
@@ -474,12 +482,13 @@ function formatPublicJson(command, result, params = {}, execution = {}) {
|
|
|
474
482
|
...(data && data.ok === false && { ok: false }),
|
|
475
483
|
...(modeOf(command, result) && { mode: modeOf(command, result) }),
|
|
476
484
|
contract: contractMeta(command),
|
|
485
|
+
...(execution.projectRoot && { pathBase: execution.projectRoot }),
|
|
477
486
|
...commandMeta,
|
|
478
487
|
...(execution.note && { note: execution.note }),
|
|
479
488
|
},
|
|
480
489
|
data,
|
|
481
490
|
};
|
|
482
|
-
return JSON.stringify(canonicalJsonValue(envelope), null, 2);
|
|
491
|
+
return JSON.stringify(canonicalJsonValue(envelope, execution.projectRoot), null, 2);
|
|
483
492
|
}
|
|
484
493
|
|
|
485
494
|
module.exports = {
|
|
@@ -522,7 +522,10 @@ function formatAuditAsync(result) {
|
|
|
522
522
|
lines.push(`${file} (${fileIssues.length})`);
|
|
523
523
|
for (const issue of fileIssues) {
|
|
524
524
|
const caller = issue.callerName ? ` [${issue.callerName}]` : '';
|
|
525
|
-
|
|
525
|
+
const detail = issue.reason === 'stored-promise-used-as-value'
|
|
526
|
+
? `${issue.variable} used as a resolved value; promise from ${issue.calleeName}() at line ${issue.originLine}`
|
|
527
|
+
: `${issue.calleeName}() — async, not awaited`;
|
|
528
|
+
lines.push(` :${issue.line}${caller} ${detail}`);
|
|
526
529
|
}
|
|
527
530
|
}
|
|
528
531
|
return lines.join('\n');
|
package/core/project.js
CHANGED
|
@@ -1105,6 +1105,11 @@ class ProjectIndex {
|
|
|
1105
1105
|
const lowerPath = filePath.toLowerCase();
|
|
1106
1106
|
for (const pattern of filters.exclude) {
|
|
1107
1107
|
const lowerPattern = pattern.toLowerCase();
|
|
1108
|
+
if (lowerPattern === 'test files') {
|
|
1109
|
+
const rp = path.isAbsolute(filePath) ? path.relative(this.root, filePath) : filePath;
|
|
1110
|
+
if (require('./shared').isTestPath(rp)) return false;
|
|
1111
|
+
continue;
|
|
1112
|
+
}
|
|
1108
1113
|
let regex = this._excludeRegexCache?.get(lowerPattern);
|
|
1109
1114
|
if (!regex) {
|
|
1110
1115
|
const escaped = lowerPattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
package/core/search.js
CHANGED
|
@@ -727,6 +727,7 @@ function structuralSearch(index, options = {}) {
|
|
|
727
727
|
// Auto-infer type: --receiver implies type=call
|
|
728
728
|
const type = options.type || (receiver ? 'call' : undefined);
|
|
729
729
|
const results = [];
|
|
730
|
+
const skippedTestFiles = new Set();
|
|
730
731
|
|
|
731
732
|
// Validate type if provided
|
|
732
733
|
if (type && !STRUCTURAL_TYPES.has(type)) {
|
|
@@ -753,7 +754,13 @@ function structuralSearch(index, options = {}) {
|
|
|
753
754
|
if (!rp.includes(options.file) && !rp.endsWith(options.file)) return false;
|
|
754
755
|
}
|
|
755
756
|
if ((options.exclude && options.exclude.length > 0) || options.in) {
|
|
756
|
-
if (!index.matchesFilters(fileEntry.relativePath, {
|
|
757
|
+
if (!index.matchesFilters(fileEntry.relativePath, { in: options.in })) return false;
|
|
758
|
+
if (!index.matchesFilters(fileEntry.relativePath, { exclude: options.exclude })) {
|
|
759
|
+
if (options.testExclude && !index.matchesFilters(fileEntry.relativePath, { exclude: options.testExclude })) {
|
|
760
|
+
skippedTestFiles.add(fileEntry.relativePath);
|
|
761
|
+
}
|
|
762
|
+
return false;
|
|
763
|
+
}
|
|
757
764
|
}
|
|
758
765
|
return true;
|
|
759
766
|
};
|
|
@@ -978,6 +985,7 @@ function structuralSearch(index, options = {}) {
|
|
|
978
985
|
}).filter(([, v]) => v !== undefined && v !== null)),
|
|
979
986
|
totalMatched: total,
|
|
980
987
|
shown: results.length,
|
|
988
|
+
filesSkipped: skippedTestFiles.size,
|
|
981
989
|
...(unused && {
|
|
982
990
|
unusedScope: 'callable-symbols-only',
|
|
983
991
|
unusedSafety: 'candidate-only; use deadcode before deletion',
|
package/core/shared.js
CHANGED
|
@@ -50,15 +50,22 @@ function codeUnitCompare(a, b) {
|
|
|
50
50
|
* Path-based test heuristic — matches the same patterns as `find`'s exclusion
|
|
51
51
|
* logic so that `about` and `find` agree on which files are de-emphasized.
|
|
52
52
|
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
53
|
+
* Uses language-specific filename conventions plus `test|tests|__tests__|__mocks__|fixture|mock`
|
|
54
|
+
* as path segments (with word boundaries on both sides). `spec` is a test
|
|
55
|
+
* marker only for languages whose test-file conventions include it.
|
|
55
56
|
*
|
|
56
57
|
* Complement to `isTestFile` (filename pattern check) — together they catch
|
|
57
58
|
* both `foo.test.js` (filename) AND `test/agent-benchmark.js` (directory).
|
|
58
59
|
*/
|
|
59
60
|
function isTestPath(rp) {
|
|
60
61
|
if (!rp) return false;
|
|
61
|
-
|
|
62
|
+
const language = detectLanguage(rp);
|
|
63
|
+
const { langTraits } = require('../languages');
|
|
64
|
+
const specConvention = langTraits(language)?.testFileCandidates?.('sample', '')
|
|
65
|
+
.some(candidate => candidate.includes('.spec'));
|
|
66
|
+
return isTestFile(rp, language) ||
|
|
67
|
+
/(^|[/._-])(test|tests|__tests__|__mocks__|fixture|mock)s?([/._-]|$)/i.test(rp) ||
|
|
68
|
+
(!!specConvention && /(^|[/._-])specs?([/._-]|$)/i.test(rp));
|
|
62
69
|
}
|
|
63
70
|
|
|
64
71
|
/**
|
|
@@ -109,7 +116,9 @@ function pickBestDefinition(matches, opts = {}) {
|
|
|
109
116
|
* Returns a new array with test patterns appended (deduplicating).
|
|
110
117
|
*/
|
|
111
118
|
function addTestExclusions(exclude) {
|
|
112
|
-
|
|
119
|
+
// Keep automatic test classification distinct from explicit substring
|
|
120
|
+
// exclusions: --exclude=spec deliberately excludes Python specs too.
|
|
121
|
+
const testPatterns = ['test files'];
|
|
113
122
|
const existing = new Set((exclude || []).map(e => e.toLowerCase()));
|
|
114
123
|
const additions = testPatterns.filter(p => !existing.has(p));
|
|
115
124
|
return [...(exclude || []), ...additions];
|
package/core/verify.js
CHANGED
|
@@ -808,6 +808,8 @@ function computePlanCallSites(index, name, def) {
|
|
|
808
808
|
expression: (call.content || '').trim(),
|
|
809
809
|
args: analysis.args,
|
|
810
810
|
argCount: analysis.argCount,
|
|
811
|
+
...(analysis.keywordArgNames && { keywordArgNames: analysis.keywordArgNames }),
|
|
812
|
+
...(analysis.positionalCount != null && { positionalCount: analysis.positionalCount }),
|
|
811
813
|
...(c.calledAs && { calledAs: c.calledAs }),
|
|
812
814
|
});
|
|
813
815
|
}
|
|
@@ -2158,7 +2160,10 @@ function plan(index, name, options = {}) {
|
|
|
2158
2160
|
} else if (options.defaultValue) {
|
|
2159
2161
|
suggestion = `Add argument: ${options.defaultValue} (no default parameter values in ${planFileEntry?.language || 'this language'})`;
|
|
2160
2162
|
} else {
|
|
2161
|
-
|
|
2163
|
+
const keywordCall = langTraits(planLang)?.keywordArguments && site.keywordArgNames?.length > 0;
|
|
2164
|
+
suggestion = keywordCall
|
|
2165
|
+
? `Add keyword argument: ${options.addParam}=${options.addParam} (replace the right-hand value with the intended expression; keep existing keyword arguments)`
|
|
2166
|
+
: `Add argument: ${options.addParam}`;
|
|
2162
2167
|
}
|
|
2163
2168
|
changes.push({
|
|
2164
2169
|
file: site.file,
|
package/languages/index.js
CHANGED
|
@@ -144,6 +144,7 @@ const LANGUAGES = {
|
|
|
144
144
|
traits: {
|
|
145
145
|
...STRUCTURAL_TRAITS,
|
|
146
146
|
selfParam: ['this'],
|
|
147
|
+
storedPromises: true,
|
|
147
148
|
testFileCandidates: (base, ext) => [`${base}.test${ext}`, `${base}.spec${ext}`, `${base}.test.ts`, `${base}.test.js`, `${base}.spec.ts`, `${base}.spec.js`],
|
|
148
149
|
testDirs: ['__tests__'],
|
|
149
150
|
},
|
|
@@ -157,6 +158,7 @@ const LANGUAGES = {
|
|
|
157
158
|
traits: {
|
|
158
159
|
...STRUCTURAL_TRAITS,
|
|
159
160
|
selfParam: ['this'],
|
|
161
|
+
storedPromises: true,
|
|
160
162
|
testFileCandidates: (base, ext) => [`${base}.test${ext}`, `${base}.spec${ext}`, `${base}.test.ts`, `${base}.test.js`, `${base}.spec.ts`, `${base}.spec.js`],
|
|
161
163
|
testDirs: ['__tests__'],
|
|
162
164
|
},
|
|
@@ -170,6 +172,7 @@ const LANGUAGES = {
|
|
|
170
172
|
traits: {
|
|
171
173
|
...STRUCTURAL_TRAITS,
|
|
172
174
|
selfParam: ['this'],
|
|
175
|
+
storedPromises: true,
|
|
173
176
|
testFileCandidates: (base, ext) => [`${base}.test${ext}`, `${base}.spec${ext}`, `${base}.test.ts`, `${base}.test.js`, `${base}.spec.ts`, `${base}.spec.js`],
|
|
174
177
|
testDirs: ['__tests__'],
|
|
175
178
|
},
|
|
@@ -324,6 +327,7 @@ const LANGUAGES = {
|
|
|
324
327
|
traits: {
|
|
325
328
|
...STRUCTURAL_TRAITS,
|
|
326
329
|
selfParam: ['this'],
|
|
330
|
+
storedPromises: true,
|
|
327
331
|
testFileCandidates: (base, ext) => [`${base}.test${ext}`, `${base}.spec${ext}`],
|
|
328
332
|
},
|
|
329
333
|
}
|
package/languages/python.js
CHANGED
|
@@ -2805,7 +2805,14 @@ function findCallsInCode(code, parser) {
|
|
|
2805
2805
|
const argsNode = node.childForFieldName('arguments');
|
|
2806
2806
|
if (argsNode) {
|
|
2807
2807
|
for (let i = 0; i < argsNode.namedChildCount; i++) {
|
|
2808
|
-
const
|
|
2808
|
+
const rawArg = argsNode.namedChild(i);
|
|
2809
|
+
const arg = rawArg.type === 'keyword_argument'
|
|
2810
|
+
? rawArg.childForFieldName('value') : rawArg;
|
|
2811
|
+
if (!arg) continue;
|
|
2812
|
+
// Bare keyword values already belong to the reference
|
|
2813
|
+
// inventory/rename path; only extend the member-value
|
|
2814
|
+
// callback model to match its positional counterpart.
|
|
2815
|
+
if (rawArg.type === 'keyword_argument' && arg.type !== 'attribute') continue;
|
|
2809
2816
|
if (arg.type === 'identifier' && !PYTHON_SKIP.has(arg.text) && !nonCallableNames.has(arg.text)) {
|
|
2810
2817
|
calls.push({
|
|
2811
2818
|
name: arg.text,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ucn",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.4.0",
|
|
4
4
|
"mcpName": "io.github.mleoca/ucn",
|
|
5
5
|
"description": "Auditable AST code intelligence for AI agents: 18 task-oriented commands through one MCP tool, CLI, or agent skill. Supports JS/TS, Python, Go, Rust, Java, C, C++, C#, and HTML.",
|
|
6
6
|
"main": "index.js",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
},
|
|
11
11
|
"scripts": {
|
|
12
12
|
"version": "node scripts/sync-server-version.js && git add server.json",
|
|
13
|
-
"test": "node --test test/audit-5.3.7.test.js test/audit-5.3.6.test.js test/evidence-provenance.test.js test/provenance-unit.test.js test/shell-output.test.js test/parser-unit.test.js test/integration.test.js test/cache.test.js test/formatter.test.js test/interactive.test.js test/feature.test.js test/regression-js.test.js test/regression-py.test.js test/regression-go.test.js test/regression-java.test.js test/regression-rust.test.js test/regression-c-family.test.js test/regression-cross.test.js test/regression-mcp.test.js test/mcp-protocol.test.js test/mcp-sdk-compat.test.js test/dependency-security.test.js test/regression-parser.test.js test/regression-commands.test.js test/regression-fixes.test.js test/regression-bugfixes.test.js test/release-surface-regressions.test.js test/prerelease-audit.test.js test/release-readiness-audit.test.js test/cross-language.test.js test/accuracy.test.js test/command-coverage.test.js test/perf-optimizations.test.js test/performance-gate-policy.test.js test/oracle-gate-policy.test.js test/outcome-policy.test.js test/consistency-eval.test.js test/agent-public-surface-benchmark.test.js test/command-contracts.test.js test/language-adapter.test.js test/systematic-test.js test/mcp-edge-cases.js test/conservation.test.js test/parity-test.js test/trust-matrix.test.js",
|
|
13
|
+
"test": "node --test test/audit-5.3.8.test.js test/audit-5.3.7.test.js test/audit-5.3.6.test.js test/evidence-provenance.test.js test/provenance-unit.test.js test/shell-output.test.js test/parser-unit.test.js test/integration.test.js test/cache.test.js test/formatter.test.js test/interactive.test.js test/feature.test.js test/regression-js.test.js test/regression-py.test.js test/regression-go.test.js test/regression-java.test.js test/regression-rust.test.js test/regression-c-family.test.js test/regression-cross.test.js test/regression-mcp.test.js test/mcp-protocol.test.js test/mcp-sdk-compat.test.js test/dependency-security.test.js test/regression-parser.test.js test/regression-commands.test.js test/regression-fixes.test.js test/regression-bugfixes.test.js test/release-surface-regressions.test.js test/prerelease-audit.test.js test/release-readiness-audit.test.js test/cross-language.test.js test/accuracy.test.js test/command-coverage.test.js test/perf-optimizations.test.js test/performance-gate-policy.test.js test/oracle-gate-policy.test.js test/outcome-policy.test.js test/consistency-eval.test.js test/agent-public-surface-benchmark.test.js test/command-contracts.test.js test/language-adapter.test.js test/systematic-test.js test/mcp-edge-cases.js test/conservation.test.js test/parity-test.js test/trust-matrix.test.js",
|
|
14
14
|
"benchmark:agent": "node test/agent-public-surface-benchmark.js",
|
|
15
15
|
"benchmark:agent:gate": "node test/agent-public-surface-benchmark.js --gate",
|
|
16
16
|
"benchmark:agent:legacy": "node test/agent-understanding-benchmark.js",
|