ucn 5.3.7 → 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 +35 -3
- package/.claude/skills/ucn/references/commands.md +15 -1
- package/cli/index.js +4 -2
- package/core/account.js +4 -0
- package/core/analysis.js +80 -1
- package/core/cache.js +3 -1
- package/core/callers.js +21 -2
- package/core/execute.js +24 -4
- package/core/graph.js +6 -2
- package/core/output/analysis.js +5 -5
- package/core/output/doctor.js +1 -1
- package/core/output/lines.js +8 -2
- package/core/output/public.js +16 -4
- package/core/output/refactoring.js +4 -1
- package/core/output/reporting.js +1 -1
- package/core/output/search.js +6 -1
- package/core/project.js +5 -0
- package/core/registry.js +1 -1
- package/core/reporting.js +2 -0
- package/core/search.js +13 -2
- package/core/shared.js +13 -4
- package/core/verify.js +6 -1
- package/languages/c-family.js +9 -8
- package/languages/csharp.js +11 -10
- package/languages/go.js +8 -7
- package/languages/index.js +4 -0
- package/languages/java.js +3 -2
- package/languages/javascript.js +2 -1
- package/languages/python.js +11 -3
- package/languages/rust.js +3 -2
- package/languages/utils.js +51 -29
- package/package.json +2 -2
|
@@ -126,7 +126,7 @@ answers:
|
|
|
126
126
|
ucn find handleRequest --lines # path:line:signature # kind
|
|
127
127
|
ucn show handleRequest --lines # callers as path:line:text; unverified ones end in "\t# unverified: <reason>"
|
|
128
128
|
ucn show handleRequest --lines --sections=callees
|
|
129
|
-
ucn usages handleRequest --lines # every literal-name
|
|
129
|
+
ucn usages handleRequest --lines # every literal-name occurrence; non-call kinds tagged "# import" / "# definition"
|
|
130
130
|
ucn search 'retry(' --lines # grep -n output, code-aware scope
|
|
131
131
|
ucn impact handleRequest --lines
|
|
132
132
|
ucn source handleRequest --raw # the code and nothing else, ready for an exact-string edit
|
|
@@ -136,9 +136,17 @@ ucn source src/server.js:40-80 --raw
|
|
|
136
136
|
Records go to stdout; the `ACCOUNT` / `CONTRACT` lines, notes, and the
|
|
137
137
|
same-name disambiguation go to stderr prefixed `# ` (MCP keeps them in the one
|
|
138
138
|
text block, and `--raw` appends its note as one trailing `# ` line there).
|
|
139
|
+
`usages` emits one record per occurrence, so a source line may repeat; deduplicate
|
|
140
|
+
`path:line` values for line counts. Definition handles start at decorators when
|
|
141
|
+
present, while usages point at token lines (`nameLine` identifies the declaration
|
|
142
|
+
token when it differs from `startLine`). Structural `search --unused` keeps its
|
|
143
|
+
safety note and decorator tags in shell output; runtime registrations can appear
|
|
144
|
+
and zero call edges do not prove a symbol is safe to delete.
|
|
139
145
|
`--lines` lists the whole band without default row/character caps, so pipe through
|
|
140
|
-
`grep -v '# unverified'` for the confirmed tier
|
|
141
|
-
|
|
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
|
|
142
150
|
contract; errors exit 2. Explicit `--top`/`--limit` still apply and disclose
|
|
143
151
|
omissions. `show --lines` accepts only callers/callees sections; target-less
|
|
144
152
|
`impact --lines` lists Git-diff callers with per-target accounting. Closing a
|
|
@@ -202,6 +210,30 @@ The selection note discloses that approximation. `find`, text `search`,
|
|
|
202
210
|
(structural `search`: 50). Use an explicit `--limit=N` to request more;
|
|
203
211
|
`usages` and `--lines` have no default row cap.
|
|
204
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
|
+
|
|
205
237
|
For `plan --rename-to`, the selected declaration is only the starting point.
|
|
206
238
|
When the index proves the relationship, the rename unit closes over
|
|
207
239
|
overload/signature groups, base and override declarations, Rust trait slots,
|
|
@@ -33,7 +33,7 @@ structural or code-only search.
|
|
|
33
33
|
|---|---|
|
|
34
34
|
| `repo` | Repository orientation. Select `summary,files,stats,health` with `--sections`; `--deep` includes readiness evidence. Skipped unsupported source is listed with a grep/language-tool handoff. |
|
|
35
35
|
| `deps <file>` | File dependency graph. Use `--direction=imports\|importers\|both`, `--detailed`, or `--cycles`. Cycles distinguish eager edges from function-local, Python typing-guarded, and TypeScript type-only edges. Complete cycle groups remain visible when enumeration is capped. |
|
|
36
|
-
| `api [file]` | Static exported/public surface for a project or file. |
|
|
36
|
+
| `api [file]` | Static exported/public surface for a project or file. An exact file includes tests; broader scans exclude tests with a count. Use `--include-tests` to include them. |
|
|
37
37
|
| `entrypoints` | Framework, route, task, test, and runtime entry points. |
|
|
38
38
|
| `endpoints` | Server/client HTTP surface; `--bridge` adds advisory matching. |
|
|
39
39
|
|
|
@@ -49,6 +49,20 @@ structural or code-only search.
|
|
|
49
49
|
|
|
50
50
|
Symbol-listing commands emit handles such as `src/api.ts:42:handler`. Pass the full handle to symbol commands. `path:line` also works. Handles prevent same-named definitions from being silently combined.
|
|
51
51
|
|
|
52
|
+
Definition handles and source spans start at the first decorator or annotation when present; literal usages point at the actual token line. Use a symbol's `nameLine` (when present, otherwise `startLine`) to compare declaration tokens with usages.
|
|
53
|
+
|
|
54
|
+
Structural `search --param` matches parameter names, types, and defaults; `--returns` matches return annotations. Both exclude AST comments and preserve string contents. `--unused` lists callable symbols without call edges, not safe-delete candidates; decorated runtime registrations may still appear. Its safety note and decorator tags are retained in `--lines` output. Use `deadcode` and `usages` before deletion.
|
|
55
|
+
|
|
56
|
+
`repo` summary/stats `buildTime` is the duration of the last index build (discovery, parsing, and graphs), retained in the cache. It excludes cache loading/saving and query execution, so it is not command wall time; `buildTimeNote` states this boundary.
|
|
57
|
+
|
|
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
|
+
|
|
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
|
+
|
|
52
66
|
## Common flags
|
|
53
67
|
|
|
54
68
|
| Flag | Meaning |
|
package/cli/index.js
CHANGED
|
@@ -917,7 +917,8 @@ Commands:
|
|
|
917
917
|
deps <file> File graph; --direction=imports|importers|both
|
|
918
918
|
--detailed Include import declarations
|
|
919
919
|
deps --cycles Report circular dependencies (no file target)
|
|
920
|
-
api [file] Project or file public API
|
|
920
|
+
api [file] Project or file public API (exact files include tests;
|
|
921
|
+
broader scans exclude tests unless --include-tests)
|
|
921
922
|
check [symbol] Signature check; without symbol, precommit check
|
|
922
923
|
plan <symbol> Preview rename or parameter edits
|
|
923
924
|
entrypoints Runtime and framework entry points
|
|
@@ -933,7 +934,8 @@ Common flags:
|
|
|
933
934
|
--base=REF --staged --no-cache --clear-cache [--all] --max-files=N --workers=N
|
|
934
935
|
--max-chars=N (text output; default 10K targeted / 3K broad, ceiling 100K)
|
|
935
936
|
--lines find/usages/search/show/impact: grep -n shape, one path:line:text
|
|
936
|
-
record per line
|
|
937
|
+
record per output line; usages may repeat a source line per occurrence
|
|
938
|
+
(tags after a tab: # unverified: <reason>, # import,
|
|
937
939
|
# callee); accounting and notes go to stderr as "# " lines; exit 1
|
|
938
940
|
when nothing matched; exit 2 on errors. No default result cap.
|
|
939
941
|
show defaults to callers; --sections=callers,callees selects bands.
|
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
|
@@ -712,7 +712,9 @@ function clearAllCaches() {
|
|
|
712
712
|
// copied bindings and qualified macro receivers; TS indexed array evidence.
|
|
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
|
+
// v228: Python keyword arguments retain the same callable-reference facts as positional arguments.
|
|
717
|
+
const CACHE_FORMAT_VERSION = 228;
|
|
716
718
|
const USAGE_CACHE_FILE = 'usage-results.json';
|
|
717
719
|
|
|
718
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
|
}
|
|
@@ -2330,7 +2333,7 @@ const HANDLERS = {
|
|
|
2330
2333
|
},
|
|
2331
2334
|
|
|
2332
2335
|
api: (index, p) => {
|
|
2333
|
-
if (p.file) {
|
|
2336
|
+
if (p.file && typeof index.resolveFilePathForQuery(p.file) !== 'string') {
|
|
2334
2337
|
const fileErr = checkFilePatternMatch(index, p.file);
|
|
2335
2338
|
if (fileErr) return { ok: false, error: fileErr };
|
|
2336
2339
|
}
|
|
@@ -2346,7 +2349,7 @@ const HANDLERS = {
|
|
|
2346
2349
|
return { ok: false, error: `No files matched the 'in' directory filter '${p.in}'.` };
|
|
2347
2350
|
}
|
|
2348
2351
|
}
|
|
2349
|
-
let result = index.api(p.file, { in: p.in });
|
|
2352
|
+
let result = index.api(p.file, { in: p.in, includeTests: p.includeTests });
|
|
2350
2353
|
if (p.file) {
|
|
2351
2354
|
const fileErr = checkFileError(result, p.file, index);
|
|
2352
2355
|
if (fileErr) return { ok: false, error: fileErr };
|
|
@@ -2374,6 +2377,10 @@ const HANDLERS = {
|
|
|
2374
2377
|
}
|
|
2375
2378
|
result = items;
|
|
2376
2379
|
}
|
|
2380
|
+
if (result.apiInfo?.excludedTestFiles > 0) {
|
|
2381
|
+
const excluded = `${result.apiInfo.excludedTestFiles} test file(s) excluded from API; use --include-tests to include them, or name an exact file.`;
|
|
2382
|
+
note = note ? `${note}\n${excluded}` : excluded;
|
|
2383
|
+
}
|
|
2377
2384
|
return { ok: true, result, note };
|
|
2378
2385
|
},
|
|
2379
2386
|
|
|
@@ -2502,6 +2509,18 @@ function execute(index, command, params = {}) {
|
|
|
2502
2509
|
const validationError = validatePublicParams(command, params);
|
|
2503
2510
|
if (validationError) return { ok: false, error: validationError };
|
|
2504
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
|
+
}
|
|
2505
2524
|
// Resolve name-less handles (e.g. `lib.js:42`) via index lookup before dispatch.
|
|
2506
2525
|
// Handles WITH a name suffix are handled later by applyClassMethodSyntax.
|
|
2507
2526
|
if (params && params.name && looksLikeHandle(params.name)) {
|
|
@@ -2516,6 +2535,7 @@ function execute(index, command, params = {}) {
|
|
|
2516
2535
|
}
|
|
2517
2536
|
}
|
|
2518
2537
|
const response = handler(index, params);
|
|
2538
|
+
response.projectRoot = index.root;
|
|
2519
2539
|
const bundled = (index.discoveryIssues || []).filter(issue => issue.reason === 'bundled');
|
|
2520
2540
|
if (bundled.length > 0) {
|
|
2521
2541
|
const files = bundled.slice(0, 5).map(issue => issue.relativePath).join(', ');
|
package/core/graph.js
CHANGED
|
@@ -564,6 +564,8 @@ function api(index, filePath, options = {}) {
|
|
|
564
564
|
const results = [];
|
|
565
565
|
let scopedFiles = 0;
|
|
566
566
|
let pythonImplicitFiles = 0;
|
|
567
|
+
let excludedTestFiles = 0;
|
|
568
|
+
let explicitFile = false;
|
|
567
569
|
|
|
568
570
|
let fileIterator;
|
|
569
571
|
if (filePath) {
|
|
@@ -573,6 +575,7 @@ function api(index, filePath, options = {}) {
|
|
|
573
575
|
const fileEntry = index.files.get(resolved);
|
|
574
576
|
if (!fileEntry) return { error: 'file-not-found', filePath };
|
|
575
577
|
fileIterator = [[resolved, fileEntry]];
|
|
578
|
+
explicitFile = true;
|
|
576
579
|
} else {
|
|
577
580
|
// Fall back to pattern filter (substring match on relative path)
|
|
578
581
|
const matches = [];
|
|
@@ -596,7 +599,8 @@ function api(index, filePath, options = {}) {
|
|
|
596
599
|
}
|
|
597
600
|
|
|
598
601
|
// Skip test files by default (test classes aren't part of public API)
|
|
599
|
-
if (!options.includeTests && isTestFile(fileEntry.relativePath, fileEntry.language)) {
|
|
602
|
+
if (!explicitFile && !options.includeTests && isTestFile(fileEntry.relativePath, fileEntry.language)) {
|
|
603
|
+
excludedTestFiles++;
|
|
600
604
|
continue;
|
|
601
605
|
}
|
|
602
606
|
scopedFiles++;
|
|
@@ -612,7 +616,7 @@ function api(index, filePath, options = {}) {
|
|
|
612
616
|
results.sort((a, b) => codeUnitCompare(a.file, b.file) ||
|
|
613
617
|
(a.startLine - b.startLine) || codeUnitCompare(a.name, b.name));
|
|
614
618
|
Object.defineProperty(results, 'apiInfo', {
|
|
615
|
-
value: { scopedFiles, pythonImplicitFiles },
|
|
619
|
+
value: { scopedFiles, pythonImplicitFiles, excludedTestFiles },
|
|
616
620
|
enumerable: false, writable: true, configurable: true,
|
|
617
621
|
});
|
|
618
622
|
return results;
|
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/doctor.js
CHANGED
|
@@ -33,7 +33,7 @@ function formatDoctor(result, options = {}) {
|
|
|
33
33
|
// Cache state
|
|
34
34
|
if (result.cache) {
|
|
35
35
|
const state = result.cache.fresh === true ? 'fresh' : result.cache.fresh === false ? 'stale' : 'unknown';
|
|
36
|
-
const buildHint = result.cache.buildMs ? `, ${result.cache.buildMs}ms build` : '';
|
|
36
|
+
const buildHint = result.cache.buildMs ? `, ${result.cache.buildMs}ms last index build (excludes cache I/O and query execution)` : '';
|
|
37
37
|
lines.push(`Cache: ${state}${buildHint}`);
|
|
38
38
|
}
|
|
39
39
|
if (result.commandTrust) {
|
package/core/output/lines.js
CHANGED
|
@@ -29,7 +29,8 @@ function record(pathLike, line, text, tag = '') {
|
|
|
29
29
|
// Keep unusual filenames from becoming notes or extra physical records.
|
|
30
30
|
let file = String(pathLike).replace(/\\/g, '\\\\').replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t');
|
|
31
31
|
if (file.startsWith('# ')) file = './' + file;
|
|
32
|
-
|
|
32
|
+
const tagText = String(tag || '').replace(/\s+/g, ' ').trim();
|
|
33
|
+
return `${file}:${line == null ? 0 : line}:${body}${tagText ? `\t# ${tagText}` : ''}`;
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
function commentLines(text) {
|
|
@@ -95,9 +96,14 @@ function searchRecords(result) {
|
|
|
95
96
|
if (result && !Array.isArray(result) && Array.isArray(result.results)) {
|
|
96
97
|
for (const item of result.results) {
|
|
97
98
|
const text = item.params != null ? `${item.name}(${item.params})` : item.name;
|
|
98
|
-
|
|
99
|
+
const decorators = (item.decorators || []).map(d => `@${String(d).replace(/^@/, '')}`).join(', ');
|
|
100
|
+
const tag = [item.kind || item.type, decorators].filter(Boolean).join('; ');
|
|
101
|
+
out.push(record(item.file, item.line, text, tag));
|
|
99
102
|
}
|
|
100
103
|
const meta = result.meta;
|
|
104
|
+
if (meta?.query?.unused) {
|
|
105
|
+
notes.push(...commentLines(require('./search').unusedSearchNote()));
|
|
106
|
+
}
|
|
101
107
|
if (meta && meta.totalMatched > meta.shown) {
|
|
102
108
|
notes.push(`# ${meta.totalMatched - meta.shown} more match(es) (--limit=N / --all)`);
|
|
103
109
|
}
|
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
|
}
|
|
@@ -424,6 +432,9 @@ function formatPublicJson(command, result, params = {}, execution = {}) {
|
|
|
424
432
|
if (result.meta.truncatedMatches > 0) commandMeta.truncated = true;
|
|
425
433
|
if (result.unsupportedMatches) commandMeta.unsupportedMatches = result.unsupportedMatches;
|
|
426
434
|
}
|
|
435
|
+
if (command === 'api' && result?.apiInfo) {
|
|
436
|
+
commandMeta.apiInfo = result.apiInfo;
|
|
437
|
+
}
|
|
427
438
|
if (command === 'entrypoints' && result?.filterInfo) {
|
|
428
439
|
commandMeta.hiddenTestEntrypoints = result.filterInfo.hiddenTests;
|
|
429
440
|
commandMeta.testsIncluded = result.filterInfo.testsIncluded;
|
|
@@ -471,12 +482,13 @@ function formatPublicJson(command, result, params = {}, execution = {}) {
|
|
|
471
482
|
...(data && data.ok === false && { ok: false }),
|
|
472
483
|
...(modeOf(command, result) && { mode: modeOf(command, result) }),
|
|
473
484
|
contract: contractMeta(command),
|
|
485
|
+
...(execution.projectRoot && { pathBase: execution.projectRoot }),
|
|
474
486
|
...commandMeta,
|
|
475
487
|
...(execution.note && { note: execution.note }),
|
|
476
488
|
},
|
|
477
489
|
data,
|
|
478
490
|
};
|
|
479
|
-
return JSON.stringify(canonicalJsonValue(envelope), null, 2);
|
|
491
|
+
return JSON.stringify(canonicalJsonValue(envelope, execution.projectRoot), null, 2);
|
|
480
492
|
}
|
|
481
493
|
|
|
482
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/output/reporting.js
CHANGED
|
@@ -125,7 +125,7 @@ function formatStats(stats, options = {}) {
|
|
|
125
125
|
lines.push(`Files: ${stats.files}`);
|
|
126
126
|
}
|
|
127
127
|
lines.push(`Symbols: ${stats.symbols}`);
|
|
128
|
-
lines.push(`
|
|
128
|
+
lines.push(`Last index build: ${stats.buildTime}ms (excludes cache I/O and query execution; reused from cache)`);
|
|
129
129
|
|
|
130
130
|
lines.push('\nBy Language:');
|
|
131
131
|
for (const [lang, info] of Object.entries(stats.byLanguage)) {
|
package/core/output/search.js
CHANGED
|
@@ -87,6 +87,10 @@ function formatSearchJson(results, term) {
|
|
|
87
87
|
/**
|
|
88
88
|
* Format structural search results (index-based queries)
|
|
89
89
|
*/
|
|
90
|
+
function unusedSearchNote(flag = '--unused') {
|
|
91
|
+
return `${flag} lists callable symbols with no resolved call edge; it does not assess type/field/reference liveness and is not safe-delete proof. Confirm with deadcode and usages.`;
|
|
92
|
+
}
|
|
93
|
+
|
|
90
94
|
function formatStructuralSearch(result, options = {}) {
|
|
91
95
|
const { results, meta } = result;
|
|
92
96
|
const lines = [];
|
|
@@ -106,7 +110,7 @@ function formatStructuralSearch(result, options = {}) {
|
|
|
106
110
|
lines.push(`Structural search: ${queryStr}`);
|
|
107
111
|
lines.push('═'.repeat(60));
|
|
108
112
|
if (meta.query.unused) {
|
|
109
|
-
lines.push(`NOTE: ${options.unusedFlag
|
|
113
|
+
lines.push(`NOTE: ${unusedSearchNote(options.unusedFlag)}`);
|
|
110
114
|
lines.push('');
|
|
111
115
|
}
|
|
112
116
|
|
|
@@ -447,6 +451,7 @@ function formatTestsJson(tests, name) {
|
|
|
447
451
|
}
|
|
448
452
|
|
|
449
453
|
module.exports = {
|
|
454
|
+
unusedSearchNote,
|
|
450
455
|
formatSearch,
|
|
451
456
|
formatSearchJson,
|
|
452
457
|
formatStructuralSearch,
|
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/registry.js
CHANGED
|
@@ -143,7 +143,7 @@ const FLAG_APPLICABILITY = {
|
|
|
143
143
|
impact: ['name', 'file', 'exclude', 'className', 'line', 'includeMethods', 'top', 'unreachableOnly', 'compact', 'base', 'staged', 'limit', 'all', 'lines'],
|
|
144
144
|
tests: ['name', 'file', 'exclude', 'className', 'line', 'callsOnly', 'depth', 'includeMethods', 'all'],
|
|
145
145
|
deps: ['file', 'exclude', 'depth', 'direction', 'all', 'detailed', 'cycles'],
|
|
146
|
-
api: ['file', 'in', 'limit'],
|
|
146
|
+
api: ['file', 'in', 'limit', 'includeTests'],
|
|
147
147
|
check: ['name', 'file', 'className', 'line', 'includeMethods', 'base', 'staged', 'limit'],
|
|
148
148
|
plan: ['name', 'file', 'className', 'line', 'addParam', 'removeParam', 'renameTo', 'defaultValue'],
|
|
149
149
|
repo: ['file', 'exclude', 'top', 'limit', 'all', 'detailed', 'topLevel', 'in', 'functions', 'hot', 'deep', 'sections'],
|