ucn 5.4.0 → 5.4.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 +14 -4
- package/.claude/skills/ucn/references/commands.md +3 -3
- package/cli/index.js +1 -0
- package/core/accessors.js +48 -1
- package/core/analysis.js +144 -9
- package/core/ast-analysis.js +96 -0
- package/core/bridge.js +11 -6
- package/core/check.js +35 -6
- package/core/command-contracts.js +1 -1
- package/core/execute.js +4 -1
- package/core/output/analysis-ext.js +26 -2
- package/core/output/analysis.js +6 -4
- package/core/output/check.js +6 -2
- package/core/output/endpoints.js +10 -7
- package/core/output/lines.js +25 -3
- package/core/output/reporting.js +1 -1
- package/core/output-budget.js +8 -6
- package/core/registry.js +1 -1
- package/core/verify.js +23 -5
- package/package.json +2 -2
|
@@ -73,7 +73,9 @@ When the selected definition is a property/getter/setter, `impact` adds a
|
|
|
73
73
|
separate `PROPERTY ACCESS SITES` band. Confirmed reads/writes have receiver
|
|
74
74
|
identity; matching attribute syntax with an unresolved receiver stays
|
|
75
75
|
unverified. These are change dependencies, not fabricated caller edges, so
|
|
76
|
-
the caller `ACCOUNT` remains a call-shaped partition.
|
|
76
|
+
the caller `ACCOUNT` remains a call-shaped partition. Reads and writes include
|
|
77
|
+
access kind and token column; getter/setter handles describe the same property.
|
|
78
|
+
Unambiguous inherited properties share that owner evidence.
|
|
77
79
|
|
|
78
80
|
When the selected definition is a type, interface, enum, trait, or record,
|
|
79
81
|
`impact` adds a `TYPE REFERENCE SITES` band: annotation and reference sites
|
|
@@ -85,6 +87,10 @@ Target-less `impact` and `check` diff the working tree against `HEAD` AND
|
|
|
85
87
|
include untracked, non-ignored source files as whole-file additions, so new
|
|
86
88
|
modules are checked before `git add`. `--staged` keeps its index-only meaning.
|
|
87
89
|
The changed-path note also counts untracked documentation and configuration.
|
|
90
|
+
Classes, interfaces, types, and fields appear in separate declaration bands
|
|
91
|
+
with their dependency sites. Modified/deleted declarations block `check` until
|
|
92
|
+
reviewed with the language toolchain: arity checks cannot validate shape or
|
|
93
|
+
inheritance compatibility.
|
|
88
94
|
|
|
89
95
|
An observed-text zero is not semantic zero or safe-delete proof. Numeric evidence values are ordinal ranking weights, not probabilities.
|
|
90
96
|
|
|
@@ -136,8 +142,9 @@ ucn source src/server.js:40-80 --raw
|
|
|
136
142
|
Records go to stdout; the `ACCOUNT` / `CONTRACT` lines, notes, and the
|
|
137
143
|
same-name disambiguation go to stderr prefixed `# ` (MCP keeps them in the one
|
|
138
144
|
text block, and `--raw` appends its note as one trailing `# ` line there).
|
|
139
|
-
`usages` emits one record per
|
|
140
|
-
|
|
145
|
+
`usages --lines` emits one record per source line, combining occurrence kinds
|
|
146
|
+
and disclosing repeated occurrences (including JSX open/close tags). JSON keeps
|
|
147
|
+
the individual occurrence records. Definition handles start at decorators when
|
|
141
148
|
present, while usages point at token lines (`nameLine` identifies the declaration
|
|
142
149
|
token when it differs from `startLine`). Structural `search --unused` keeps its
|
|
143
150
|
safety note and decorator tags in shell output; runtime registrations can appear
|
|
@@ -145,7 +152,7 @@ and zero call edges do not prove a symbol is safe to delete.
|
|
|
145
152
|
`--lines` lists the whole band without default row/character caps, so pipe through
|
|
146
153
|
`grep -v '# unverified'` for the confirmed tier. To count distinct source lines
|
|
147
154
|
per file, use `cut -d: -f1,2 | sort -u | cut -d: -f1 | sort | uniq -c`;
|
|
148
|
-
|
|
155
|
+
JSON usage records can count a source line more than once.
|
|
149
156
|
Nothing to list prints nothing and exits 1, grep's
|
|
150
157
|
contract; errors exit 2. Explicit `--top`/`--limit` still apply and disclose
|
|
151
158
|
omissions. `show --lines` accepts only callers/callees sections; target-less
|
|
@@ -191,6 +198,9 @@ target token); Java `pkg.Type.method()` and C# `Ns.Type.Method()` /
|
|
|
191
198
|
same-name types exist. A qualifier the resolver cannot place stays visible as
|
|
192
199
|
`method-ambiguous`, never confirmed by first-definition order.
|
|
193
200
|
|
|
201
|
+
`endpoints` labels test routes and requests with `[test]` (JSON `isTest`).
|
|
202
|
+
Use `--exclude-tests` for a production inventory or `--in=api/` for a directory.
|
|
203
|
+
|
|
194
204
|
`endpoints` recognizes client receivers by evidence (a receiver typed to an
|
|
195
205
|
HTTP client class, or bound to a pytest fixture that constructs one), not only
|
|
196
206
|
by name. Request-shaped calls with a path literal on an unrecognized receiver
|
|
@@ -35,7 +35,7 @@ structural or code-only search.
|
|
|
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
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
|
-
| `endpoints` | Server/client HTTP surface; `--bridge` adds advisory matching. |
|
|
38
|
+
| `endpoints` | Server/client HTTP surface; `--bridge` adds advisory matching. Test sites carry `[test]`; `--exclude-tests` removes them and `--in=DIR` scopes the inventory. |
|
|
39
39
|
|
|
40
40
|
## Focused audits and runtime evidence
|
|
41
41
|
|
|
@@ -55,13 +55,13 @@ Structural `search --param` matches parameter names, types, and defaults; `--ret
|
|
|
55
55
|
|
|
56
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
57
|
|
|
58
|
-
`--lines` writes one record per output line. `usages`
|
|
58
|
+
`--lines` writes one record per output line. `usages --lines` groups occurrences by source line and tags their kinds and counts. JSON retains individual occurrence records.
|
|
59
59
|
|
|
60
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
61
|
|
|
62
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
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.
|
|
64
|
+
`audit-async` checks recognized async producers, including Python coroutine calls discarded in synchronous functions or module scope and 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
65
|
|
|
66
66
|
## Common flags
|
|
67
67
|
|
package/cli/index.js
CHANGED
package/core/accessors.js
CHANGED
|
@@ -2,6 +2,48 @@
|
|
|
2
2
|
|
|
3
3
|
const path = require('path');
|
|
4
4
|
const { codeUnitCompare } = require('./shared');
|
|
5
|
+
const { getParser, safeParse } = require('../languages');
|
|
6
|
+
|
|
7
|
+
function inheritedAccessor(index, enclosing, name, definition) {
|
|
8
|
+
let owner = enclosing?.className;
|
|
9
|
+
let file = enclosing?.file;
|
|
10
|
+
const seen = new Set();
|
|
11
|
+
while (owner && file && !seen.has(`${file}\0${owner}`)) {
|
|
12
|
+
seen.add(`${file}\0${owner}`);
|
|
13
|
+
if (owner === ownerName(definition) && file === definition.file) return true;
|
|
14
|
+
// An override owns this spelling. Multiple inheritance and duplicate
|
|
15
|
+
// declarations need richer lookup evidence, so keep them unverified.
|
|
16
|
+
if ((index.symbols.get(name) || []).some(d => d.className === owner && d.file === file)) return false;
|
|
17
|
+
const definitions = (index.symbols.get(owner) || []).filter(d => d.file === file && ['class', 'struct'].includes(d.type));
|
|
18
|
+
if (definitions.length !== 1) return false;
|
|
19
|
+
const parents = index._getInheritanceParentsAt(owner, file, definitions[0].startLine) || [];
|
|
20
|
+
if (parents.length !== 1) return false;
|
|
21
|
+
const parentName = bareTypeName(parents[0]);
|
|
22
|
+
const candidates = (index.symbols.get(parentName) || []).filter(d => ['class', 'struct'].includes(d.type));
|
|
23
|
+
const local = candidates.filter(d => d.file === file);
|
|
24
|
+
const imported = candidates.filter(d => index.importGraph.get(file)?.has(d.file));
|
|
25
|
+
const targets = local.length ? local : imported;
|
|
26
|
+
if (targets.length !== 1) return false;
|
|
27
|
+
owner = targets[0].name;
|
|
28
|
+
file = targets[0].file;
|
|
29
|
+
}
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function accessKind(tree, usage) {
|
|
34
|
+
if (!tree || !Number.isInteger(usage.column)) return 'access';
|
|
35
|
+
let node = tree.rootNode.descendantForPosition({ row: usage.line - 1, column: usage.column });
|
|
36
|
+
if (['attribute', 'member_expression', 'member_access_expression'].includes(node.parent?.type)) node = node.parent;
|
|
37
|
+
const parent = node.parent;
|
|
38
|
+
if (parent?.type === 'update_expression' || parent?.type === 'delete_statement') return parent.type === 'update_expression' ? 'read/write' : 'delete';
|
|
39
|
+
if (['assignment', 'assignment_expression', 'augmented_assignment', 'augmented_assignment_expression'].includes(parent?.type)) {
|
|
40
|
+
const left = parent.childForFieldName('left');
|
|
41
|
+
if (left && left.startIndex === node.startIndex && left.endIndex === node.endIndex) {
|
|
42
|
+
return parent.type.startsWith('augmented') ? 'read/write' : 'write';
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return 'read';
|
|
46
|
+
}
|
|
5
47
|
|
|
6
48
|
// Descriptors/properties are consumed through reads and writes, not only
|
|
7
49
|
// call syntax. Keep this vocabulary shared by impact and refactoring so the
|
|
@@ -87,11 +129,14 @@ function findAccessorReferences(index, name, definition, options = {}) {
|
|
|
87
129
|
occurrences = index._getCachedUsages(file, name);
|
|
88
130
|
} catch { continue; }
|
|
89
131
|
if (!occurrences) continue;
|
|
132
|
+
const parser = getParser(entry.language);
|
|
133
|
+
const tree = parser ? safeParse(parser, content) : null;
|
|
90
134
|
const lines = content.split('\n');
|
|
91
135
|
for (const usage of occurrences) {
|
|
92
136
|
if (usage.usageType !== 'reference') continue;
|
|
93
137
|
refs.push({
|
|
94
138
|
...usage,
|
|
139
|
+
accessKind: accessKind(tree, usage),
|
|
95
140
|
file,
|
|
96
141
|
relativePath: entry.relativePath,
|
|
97
142
|
content: lines[usage.line - 1] || '',
|
|
@@ -106,6 +151,7 @@ function findAccessorReferences(index, name, definition, options = {}) {
|
|
|
106
151
|
absoluteFile: ref.file,
|
|
107
152
|
line: ref.line,
|
|
108
153
|
expression: (ref.content || '').trim(),
|
|
154
|
+
accessKind: ref.accessKind,
|
|
109
155
|
...(Number.isInteger(ref.column) && { column: ref.column }),
|
|
110
156
|
...(ref.receiver && { receiver: ref.receiver }),
|
|
111
157
|
};
|
|
@@ -122,7 +168,8 @@ function findAccessorReferences(index, name, definition, options = {}) {
|
|
|
122
168
|
const enclosing = index.findEnclosingFunction(ref.file, ref.line, true);
|
|
123
169
|
const receiver = ref.receiver || null;
|
|
124
170
|
if (receiver && ['self', 'cls', 'this'].includes(receiver) &&
|
|
125
|
-
enclosing?.className === owner && enclosing.file === definition.file)
|
|
171
|
+
((enclosing?.className === owner && enclosing.file === definition.file) ||
|
|
172
|
+
inheritedAccessor(index, enclosing, name, definition))) {
|
|
126
173
|
confirmed.push({
|
|
127
174
|
...shaped,
|
|
128
175
|
callerName: enclosing.name,
|
package/core/analysis.js
CHANGED
|
@@ -17,7 +17,7 @@ const { NON_CALLABLE_TYPES, addTestExclusions, countTextBlindspots,
|
|
|
17
17
|
const { isTestFile } = require('./discovery');
|
|
18
18
|
const { computeReachability, symbolKey } = require('./entrypoints');
|
|
19
19
|
const { getLanguageAdapter } = require('../languages');
|
|
20
|
-
const { projectComputedDispatch } = require('./ast-analysis');
|
|
20
|
+
const { projectComputedDispatch, declarationSnapshots } = require('./ast-analysis');
|
|
21
21
|
const { findAccessorReferences } = require('./accessors');
|
|
22
22
|
|
|
23
23
|
// JS/TS test framework helpers — calls to these bracket a test case.
|
|
@@ -1181,6 +1181,7 @@ function impact(index, name, options = {}) {
|
|
|
1181
1181
|
// Convert findCallers results to the format expected by analyzeCallSite
|
|
1182
1182
|
const calls = callerResults.map(c => ({
|
|
1183
1183
|
file: c.file,
|
|
1184
|
+
calledAs: c.calledAs,
|
|
1184
1185
|
relativePath: c.relativePath,
|
|
1185
1186
|
line: c.line,
|
|
1186
1187
|
content: c.content,
|
|
@@ -2085,6 +2086,7 @@ function diffImpact(index, options = {}) {
|
|
|
2085
2086
|
moduleLevelChanges: [],
|
|
2086
2087
|
newFunctions: [],
|
|
2087
2088
|
deletedFunctions: [],
|
|
2089
|
+
symbols: [], newSymbols: [], deletedSymbols: [],
|
|
2088
2090
|
summary: { modifiedFunctions: 0, deletedFunctions: 0, newFunctions: 0, totalCallSites: 0, unverifiedCallSites: 0, affectedFiles: 0 }
|
|
2089
2091
|
};
|
|
2090
2092
|
}
|
|
@@ -2093,6 +2095,7 @@ function diffImpact(index, options = {}) {
|
|
|
2093
2095
|
const moduleLevelChanges = [];
|
|
2094
2096
|
const newFunctions = [];
|
|
2095
2097
|
const deletedFunctions = [];
|
|
2098
|
+
const symbolChanges = { symbols: [], newSymbols: [], deletedSymbols: [] };
|
|
2096
2099
|
const callerFileSet = new Set();
|
|
2097
2100
|
let totalCallSites = 0;
|
|
2098
2101
|
let totalUnverifiedSites = 0;
|
|
@@ -2120,6 +2123,7 @@ function diffImpact(index, options = {}) {
|
|
|
2120
2123
|
{ cwd: index.root, encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] }
|
|
2121
2124
|
);
|
|
2122
2125
|
const oldParsed = parse(oldContent, lang);
|
|
2126
|
+
collectNonCallableChanges(index, change, oldParsed, [], symbolChanges);
|
|
2123
2127
|
for (const oldFn of extractCallableSymbols(oldParsed)) {
|
|
2124
2128
|
deletedFunctions.push({
|
|
2125
2129
|
name: oldFn.name,
|
|
@@ -2144,6 +2148,8 @@ function diffImpact(index, options = {}) {
|
|
|
2144
2148
|
// The identity key is `name\0className` (matches deletion-detection below).
|
|
2145
2149
|
let oldSymbolIdentities = null; // null = unknown (file untracked or git failed)
|
|
2146
2150
|
let oldCallables = null; // old symbols WITH ranges — deleted lines are old-file coordinates
|
|
2151
|
+
let oldParsed = null;
|
|
2152
|
+
let oldLines = null;
|
|
2147
2153
|
if (change.deletedLines.length > 0 || change.addedLines.length > 0) {
|
|
2148
2154
|
const ref = staged ? 'HEAD' : base;
|
|
2149
2155
|
try {
|
|
@@ -2153,7 +2159,8 @@ function diffImpact(index, options = {}) {
|
|
|
2153
2159
|
);
|
|
2154
2160
|
const fileLang = detectLanguage(change.filePath);
|
|
2155
2161
|
if (fileLang) {
|
|
2156
|
-
|
|
2162
|
+
oldParsed = parse(oldContent, fileLang);
|
|
2163
|
+
oldLines = oldContent.split('\n');
|
|
2157
2164
|
oldSymbolIdentities = new Set();
|
|
2158
2165
|
oldCallables = extractCallableSymbols(oldParsed);
|
|
2159
2166
|
for (const oldFn of oldCallables) {
|
|
@@ -2165,8 +2172,13 @@ function diffImpact(index, options = {}) {
|
|
|
2165
2172
|
}
|
|
2166
2173
|
}
|
|
2167
2174
|
|
|
2175
|
+
collectNonCallableChanges(index, change, oldParsed, fileEntry.symbols, symbolChanges, oldLines);
|
|
2176
|
+
|
|
2168
2177
|
for (const line of change.addedLines) {
|
|
2169
|
-
|
|
2178
|
+
// Arrow fields are indexed as fields but their bodies still need
|
|
2179
|
+
// callable impact once declaration-only changes are separated out.
|
|
2180
|
+
const symbol = index.findEnclosingFunction(change.filePath, line, true) ||
|
|
2181
|
+
fileEntry.symbols.find(s => isDiffCallable(s) && s.startLine <= line && s.endLine >= line);
|
|
2170
2182
|
if (symbol) {
|
|
2171
2183
|
const key = `${symbol.name}:${symbol.startLine}`;
|
|
2172
2184
|
if (!affectedSymbols.has(key)) {
|
|
@@ -2193,7 +2205,7 @@ function diffImpact(index, options = {}) {
|
|
|
2193
2205
|
// still exists to its CURRENT definition for modification attribution.
|
|
2194
2206
|
const currentByIdentity = new Map();
|
|
2195
2207
|
for (const s of fileEntry.symbols) {
|
|
2196
|
-
if (
|
|
2208
|
+
if (!isDiffCallable(s)) continue;
|
|
2197
2209
|
const k = `${s.name}\0${s.className || ''}`;
|
|
2198
2210
|
if (!currentByIdentity.has(k)) currentByIdentity.set(k, []);
|
|
2199
2211
|
currentByIdentity.get(k).push(s);
|
|
@@ -2249,7 +2261,7 @@ function diffImpact(index, options = {}) {
|
|
|
2249
2261
|
let bestExact = false;
|
|
2250
2262
|
let bestRange = Infinity;
|
|
2251
2263
|
for (const symbol of fileEntry.symbols) {
|
|
2252
|
-
if (
|
|
2264
|
+
if (!isDiffCallable(symbol)) continue;
|
|
2253
2265
|
const exact = line >= symbol.startLine && line <= symbol.endLine;
|
|
2254
2266
|
const tolerant = line >= symbol.startLine - 2 && line <= symbol.endLine + 2;
|
|
2255
2267
|
if (!exact && !tolerant) continue;
|
|
@@ -2324,7 +2336,7 @@ function diffImpact(index, options = {}) {
|
|
|
2324
2336
|
if (change.deletedLines.length > 0 && oldCallables !== null) {
|
|
2325
2337
|
const currentCounts = new Map();
|
|
2326
2338
|
for (const s of fileEntry.symbols) {
|
|
2327
|
-
if (
|
|
2339
|
+
if (!isDiffCallable(s)) continue;
|
|
2328
2340
|
const key = `${s.name}\0${s.className || ''}`;
|
|
2329
2341
|
currentCounts.set(key, (currentCounts.get(key) || 0) + 1);
|
|
2330
2342
|
}
|
|
@@ -2478,6 +2490,22 @@ function diffImpact(index, options = {}) {
|
|
|
2478
2490
|
}
|
|
2479
2491
|
}
|
|
2480
2492
|
|
|
2493
|
+
const dependencyFiles = new Set();
|
|
2494
|
+
let totalDependencySites = 0;
|
|
2495
|
+
let unverifiedDependencySites = 0;
|
|
2496
|
+
for (const symbol of [...symbolChanges.symbols, ...symbolChanges.newSymbols]) {
|
|
2497
|
+
const evidence = symbol.impact;
|
|
2498
|
+
if (!evidence) continue;
|
|
2499
|
+
totalCallSites += evidence.totalCallSites || 0;
|
|
2500
|
+
totalUnverifiedSites += evidence.unverifiedSites?.length || 0;
|
|
2501
|
+
for (const group of evidence.byFile || []) callerFileSet.add(path.resolve(index.root, group.file));
|
|
2502
|
+
const refs = evidence.typeReferences || evidence.propertyAccesses;
|
|
2503
|
+
totalDependencySites += refs?.confirmedCount || 0;
|
|
2504
|
+
unverifiedDependencySites += refs?.unverifiedCount || 0;
|
|
2505
|
+
for (const group of refs?.byFile || []) dependencyFiles.add(group.file);
|
|
2506
|
+
for (const site of refs?.unverifiedSites || []) dependencyFiles.add(site.file);
|
|
2507
|
+
}
|
|
2508
|
+
|
|
2481
2509
|
return {
|
|
2482
2510
|
base: staged ? '(staged)' : base,
|
|
2483
2511
|
changedPaths: changes.length,
|
|
@@ -2487,10 +2515,17 @@ function diffImpact(index, options = {}) {
|
|
|
2487
2515
|
moduleLevelChanges,
|
|
2488
2516
|
newFunctions,
|
|
2489
2517
|
deletedFunctions,
|
|
2518
|
+
...symbolChanges,
|
|
2490
2519
|
summary: {
|
|
2491
2520
|
modifiedFunctions: functions.length,
|
|
2492
2521
|
deletedFunctions: deletedFunctions.length,
|
|
2493
2522
|
newFunctions: newFunctions.length,
|
|
2523
|
+
modifiedSymbols: symbolChanges.symbols.length,
|
|
2524
|
+
newSymbols: symbolChanges.newSymbols.length,
|
|
2525
|
+
deletedSymbols: symbolChanges.deletedSymbols.length,
|
|
2526
|
+
totalDependencySites,
|
|
2527
|
+
unverifiedDependencySites,
|
|
2528
|
+
dependencyFiles: dependencyFiles.size,
|
|
2494
2529
|
totalCallSites,
|
|
2495
2530
|
unverifiedCallSites: totalUnverifiedSites,
|
|
2496
2531
|
affectedFiles: callerFileSet.size
|
|
@@ -2499,10 +2534,95 @@ function diffImpact(index, options = {}) {
|
|
|
2499
2534
|
} finally { index._endOp(); }
|
|
2500
2535
|
}
|
|
2501
2536
|
|
|
2537
|
+
// Non-callable declarations have change dependencies too. Keep them separate
|
|
2538
|
+
// from functions so type references never inflate call-site accounting.
|
|
2539
|
+
function collectNonCallableChanges(index, change, oldParsed, currentSymbols, output, oldLines = null) {
|
|
2540
|
+
const nonCallable = s => NON_CALLABLE_TYPES.has(s.type) || s.type === 'record';
|
|
2541
|
+
const oldSymbols = [];
|
|
2542
|
+
for (const cls of oldParsed?.classes || []) {
|
|
2543
|
+
oldSymbols.push(cls);
|
|
2544
|
+
for (const member of cls.members || []) {
|
|
2545
|
+
const symbol = { ...member, type: member.memberType, className: cls.name };
|
|
2546
|
+
if (nonCallable(symbol)) oldSymbols.push(symbol);
|
|
2547
|
+
}
|
|
2548
|
+
}
|
|
2549
|
+
oldSymbols.push(...(oldParsed?.stateObjects || []).map(s => ({ ...s, type: 'state' })));
|
|
2550
|
+
const identity = s => `${s.type}\0${s.className || ''}\0${s.name}`;
|
|
2551
|
+
const before = new Map();
|
|
2552
|
+
for (const s of oldSymbols.filter(nonCallable)) {
|
|
2553
|
+
const key = identity(s);
|
|
2554
|
+
if (!before.has(key)) before.set(key, []);
|
|
2555
|
+
before.get(key).push(s);
|
|
2556
|
+
}
|
|
2557
|
+
const touches = (s, lines) => lines.some(line => line >= s.startLine && line <= (s.endLine || s.startLine));
|
|
2558
|
+
const language = detectLanguage(change.filePath);
|
|
2559
|
+
let currentSnapshots = new Map();
|
|
2560
|
+
let oldSnapshots = new Map();
|
|
2561
|
+
// Unsupported/recovered declaration shapes retain conservative range
|
|
2562
|
+
// reporting. Never turn a failed AST projection into a clean diff.
|
|
2563
|
+
try {
|
|
2564
|
+
if (currentSymbols.some(nonCallable)) currentSnapshots = declarationSnapshots(
|
|
2565
|
+
index._getFileLines(change.filePath).join('\n'), language, currentSymbols.filter(nonCallable));
|
|
2566
|
+
} catch (_) { /* fall back to symbol ranges */ }
|
|
2567
|
+
try {
|
|
2568
|
+
if (oldLines) oldSnapshots = declarationSnapshots(oldLines.join('\n'), language, oldSymbols.filter(nonCallable));
|
|
2569
|
+
} catch (_) { /* fall back to symbol ranges */ }
|
|
2570
|
+
const declarationLines = (s, lines, snapshot) => lines.filter(line =>
|
|
2571
|
+
touches(s, [line]) && (!snapshot || snapshot.lines.has(line)));
|
|
2572
|
+
const append = (symbol, kind, old = null, lines = null) => {
|
|
2573
|
+
const item = {
|
|
2574
|
+
name: symbol.name, type: symbol.type, className: symbol.className,
|
|
2575
|
+
filePath: change.filePath, relativePath: change.relativePath,
|
|
2576
|
+
startLine: symbol.startLine, endLine: symbol.endLine,
|
|
2577
|
+
addedLines: kind === 'deletedSymbols' ? [] : lines.added,
|
|
2578
|
+
deletedLines: kind === 'deletedSymbols' ? change.deletedLines.filter(line => touches(old, [line])) : lines.deleted,
|
|
2579
|
+
};
|
|
2580
|
+
if (kind === 'deletedSymbols') {
|
|
2581
|
+
// The old identity no longer exists. Name occurrences are review
|
|
2582
|
+
// candidates, not proven references to the removed declaration.
|
|
2583
|
+
item.remainingReferences = index.usages(symbol.name, { includeTests: true, codeOnly: true })
|
|
2584
|
+
.filter(u => !u.isDefinition).map(u => ({
|
|
2585
|
+
file: u.relativePath, line: u.line, expression: u.content,
|
|
2586
|
+
reason: 'deleted-target-name-match', tier: 'unverified',
|
|
2587
|
+
}));
|
|
2588
|
+
} else {
|
|
2589
|
+
item.impact = impact(index, symbol.name, {
|
|
2590
|
+
file: change.relativePath, line: symbol.startLine, className: symbol.className,
|
|
2591
|
+
});
|
|
2592
|
+
}
|
|
2593
|
+
output[kind].push(item);
|
|
2594
|
+
};
|
|
2595
|
+
for (const symbol of currentSymbols.filter(nonCallable)) {
|
|
2596
|
+
const old = before.get(identity(symbol))?.shift();
|
|
2597
|
+
const currentSnapshot = currentSnapshots.get(symbol);
|
|
2598
|
+
const oldSnapshot = oldSnapshots.get(old);
|
|
2599
|
+
if (old && currentSnapshot && oldSnapshot && currentSnapshot.signature === oldSnapshot.signature) continue;
|
|
2600
|
+
const lines = {
|
|
2601
|
+
added: declarationLines(symbol, change.addedLines, currentSnapshot),
|
|
2602
|
+
deleted: old ? declarationLines(old, change.deletedLines, oldSnapshot) : [],
|
|
2603
|
+
};
|
|
2604
|
+
if (lines.added.length > 0 || lines.deleted.length > 0) {
|
|
2605
|
+
// When the base cannot be read, do not invent a new declaration.
|
|
2606
|
+
append(symbol, old || (!oldParsed && !change.untracked && !change.isNew)
|
|
2607
|
+
? 'symbols' : 'newSymbols', old, lines);
|
|
2608
|
+
}
|
|
2609
|
+
}
|
|
2610
|
+
for (const group of before.values()) {
|
|
2611
|
+
for (const old of group) {
|
|
2612
|
+
if (touches(old, change.deletedLines)) append(old, 'deletedSymbols', old);
|
|
2613
|
+
}
|
|
2614
|
+
}
|
|
2615
|
+
}
|
|
2616
|
+
|
|
2502
2617
|
// ========================================================================
|
|
2503
2618
|
// STANDALONE HELPERS (used by diffImpact and parseDiff)
|
|
2504
2619
|
// ========================================================================
|
|
2505
2620
|
|
|
2621
|
+
function isDiffCallable(symbol) {
|
|
2622
|
+
const type = symbol.type || symbol.memberType || 'method';
|
|
2623
|
+
return !NON_CALLABLE_TYPES.has(type) || (type === 'field' && symbol.isMethod);
|
|
2624
|
+
}
|
|
2625
|
+
|
|
2506
2626
|
/**
|
|
2507
2627
|
* Extract all callable symbols (functions + class methods) from a parse result,
|
|
2508
2628
|
* matching how indexFile builds the symbol list — the identity keys
|
|
@@ -2525,7 +2645,7 @@ function extractCallableSymbols(parsed) {
|
|
|
2525
2645
|
for (const cls of parsed.classes) {
|
|
2526
2646
|
if (cls.members) {
|
|
2527
2647
|
for (const m of cls.members) {
|
|
2528
|
-
if (
|
|
2648
|
+
if (!isDiffCallable(m)) continue;
|
|
2529
2649
|
symbols.push({ name: m.name, className: cls.name, startLine: m.startLine, endLine: m.endLine });
|
|
2530
2650
|
}
|
|
2531
2651
|
}
|
|
@@ -2556,8 +2676,10 @@ function parseDiff(diffText, root) {
|
|
|
2556
2676
|
const changes = [];
|
|
2557
2677
|
let currentFile = null;
|
|
2558
2678
|
let pendingOldPath = null; // Track --- a/ path for deleted files
|
|
2679
|
+
let pendingNewFile = false;
|
|
2559
2680
|
|
|
2560
2681
|
for (const line of diffText.split('\n')) {
|
|
2682
|
+
if (line === '--- /dev/null') pendingNewFile = true;
|
|
2561
2683
|
// Track old file path from --- header for deleted-file detection
|
|
2562
2684
|
// Handles both unquoted (--- a/path) and quoted (--- "a/path") formats
|
|
2563
2685
|
const oldMatch = line.match(/^--- (?:"a\/((?:[^"\\]|\\.)*)"|a\/(.+?))\s*$/);
|
|
@@ -2587,8 +2709,10 @@ function parseDiff(diffText, root) {
|
|
|
2587
2709
|
relativePath,
|
|
2588
2710
|
addedLines: [],
|
|
2589
2711
|
deletedLines: [],
|
|
2712
|
+
...(pendingNewFile && { isNew: true }),
|
|
2590
2713
|
...(isDevNull && { isDeleted: true })
|
|
2591
2714
|
};
|
|
2715
|
+
pendingNewFile = false;
|
|
2592
2716
|
changes.push(currentFile);
|
|
2593
2717
|
continue;
|
|
2594
2718
|
}
|
|
@@ -2896,7 +3020,7 @@ function auditAsync(index, options = {}) {
|
|
|
2896
3020
|
}
|
|
2897
3021
|
}
|
|
2898
3022
|
}
|
|
2899
|
-
if (asyncFns.length === 0) return;
|
|
3023
|
+
if (asyncFns.length === 0 && language !== 'python') return;
|
|
2900
3024
|
|
|
2901
3025
|
// Re-parse file to find awaited-vs-not call sites. We use a fresh
|
|
2902
3026
|
// parse rather than tree cache because we want to walk every
|
|
@@ -2965,6 +3089,12 @@ function auditAsync(index, options = {}) {
|
|
|
2965
3089
|
// immediate enclosing fn-node is async (so callbacks inside an
|
|
2966
3090
|
// async fn aren't misclassified as async themselves).
|
|
2967
3091
|
function nearestAsyncEnclosing(callNode) {
|
|
3092
|
+
// Python creates a dormant coroutine even in a synchronous
|
|
3093
|
+
// scope. A discarded expression never runs; returning or
|
|
3094
|
+
// passing it to an event-loop consumer remains intentional.
|
|
3095
|
+
let expression = callNode;
|
|
3096
|
+
while (expression.parent?.type === 'parenthesized_expression') expression = expression.parent;
|
|
3097
|
+
const discardedPython = language === 'python' && expression.parent?.type === 'expression_statement';
|
|
2968
3098
|
let cur = callNode.parent;
|
|
2969
3099
|
while (cur) {
|
|
2970
3100
|
if (FN_NODE_TYPES.has(cur.type)) {
|
|
@@ -2981,11 +3111,16 @@ function auditAsync(index, options = {}) {
|
|
|
2981
3111
|
endLine: cur.endPosition.row + 1,
|
|
2982
3112
|
};
|
|
2983
3113
|
}
|
|
3114
|
+
if (discardedPython) return {
|
|
3115
|
+
name: cur.childForFieldName('name')?.text || '<anonymous>',
|
|
3116
|
+
startLine: cur.startPosition.row + 1,
|
|
3117
|
+
endLine: cur.endPosition.row + 1,
|
|
3118
|
+
};
|
|
2984
3119
|
return null; // Inner non-async fn — stop, don't leak into outer scope.
|
|
2985
3120
|
}
|
|
2986
3121
|
cur = cur.parent;
|
|
2987
3122
|
}
|
|
2988
|
-
return null;
|
|
3123
|
+
return discardedPython ? { name: '<module>' } : null;
|
|
2989
3124
|
}
|
|
2990
3125
|
|
|
2991
3126
|
function visit(node) {
|
package/core/ast-analysis.js
CHANGED
|
@@ -28,6 +28,101 @@ const CALLABLE_NODES = new Set([
|
|
|
28
28
|
'operator_declaration', 'conversion_operator_declaration',
|
|
29
29
|
]);
|
|
30
30
|
|
|
31
|
+
const DECLARATION_NODES = {
|
|
32
|
+
class: new Set(['class_declaration', 'abstract_class_declaration', 'class_definition', 'class_specifier', 'class']),
|
|
33
|
+
struct: new Set(['struct_item', 'struct_specifier', 'struct_declaration', 'type_spec']),
|
|
34
|
+
interface: new Set(['interface_declaration', 'type_spec']),
|
|
35
|
+
type: new Set(['type_alias_declaration', 'type_definition', 'type_spec', 'type_alias', 'type_item', 'associated_type']),
|
|
36
|
+
enum: new Set(['enum_declaration', 'enum_item', 'enum_specifier']),
|
|
37
|
+
trait: new Set(['trait_item']),
|
|
38
|
+
impl: new Set(['impl_item']),
|
|
39
|
+
record: new Set(['record_declaration']),
|
|
40
|
+
field: new Set(['field_definition', 'public_field_definition', 'property_signature', 'field_declaration', 'variable_declarator']),
|
|
41
|
+
state: new Set(['variable_declarator', 'assignment', 'init_declarator', 'const_item', 'static_item', 'const_spec', 'var_spec']),
|
|
42
|
+
};
|
|
43
|
+
const FUNCTION_EXPRESSIONS = new Set([
|
|
44
|
+
'function_expression', 'generator_function', 'arrow_function', 'lambda',
|
|
45
|
+
'func_literal', 'closure_expression', 'lambda_expression', 'anonymous_method_expression',
|
|
46
|
+
]);
|
|
47
|
+
const COMMENT_NODES = new Set(['comment', 'line_comment', 'block_comment']);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Compare declarations by AST tokens, not lines: a class header and its first
|
|
51
|
+
* method can share a line. Concrete methods belong to the callable diff;
|
|
52
|
+
* bodyless signatures, fields (including unindexed Python assignments), and
|
|
53
|
+
* nested types belong to the declaration. Function-valued fields retain their
|
|
54
|
+
* signature but not their executable body. Nothing here is persisted in the
|
|
55
|
+
* index, so old and current source use the same projection without a cache bump.
|
|
56
|
+
* Missing AST mappings return no snapshot; callers keep conservative reporting.
|
|
57
|
+
*/
|
|
58
|
+
function declarationSnapshots(content, language, symbols) {
|
|
59
|
+
const snapshots = new Map();
|
|
60
|
+
if (content == null || symbols.length === 0) return snapshots;
|
|
61
|
+
const parser = getParser(language);
|
|
62
|
+
if (!parser) return snapshots;
|
|
63
|
+
const root = safeParse(parser, content).rootNode;
|
|
64
|
+
const wantedKinds = new Set(symbols.flatMap(s => [...(DECLARATION_NODES[s.type] || [])]));
|
|
65
|
+
const candidates = new Map();
|
|
66
|
+
walkNamed(root, node => {
|
|
67
|
+
if (!wantedKinds.has(node.type)) return;
|
|
68
|
+
if (!candidates.has(node.type)) candidates.set(node.type, []);
|
|
69
|
+
candidates.get(node.type).push(node);
|
|
70
|
+
});
|
|
71
|
+
const hasName = (node, name) => {
|
|
72
|
+
if (!node) return false;
|
|
73
|
+
if (node.text === name) return true;
|
|
74
|
+
return hasName(node.childForFieldName('name') || node.childForFieldName('declarator') ||
|
|
75
|
+
(node.type === 'generic_type' && node.childForFieldName('type')), name);
|
|
76
|
+
};
|
|
77
|
+
for (const symbol of symbols) {
|
|
78
|
+
const kinds = DECLARATION_NODES[symbol.type];
|
|
79
|
+
if (!kinds) continue;
|
|
80
|
+
let declaration = null;
|
|
81
|
+
for (const kind of kinds) {
|
|
82
|
+
for (const node of candidates.get(kind) || []) {
|
|
83
|
+
if (node.startPosition.row + 1 < symbol.startLine ||
|
|
84
|
+
node.endPosition.row + 1 > (symbol.endLine || symbol.startLine)) continue;
|
|
85
|
+
const name = node.childForFieldName('name') || node.childForFieldName('declarator') ||
|
|
86
|
+
node.childForFieldName('left') || (symbol.type === 'impl' && node.childForFieldName('type'));
|
|
87
|
+
if (!hasName(name, symbol.typeName || symbol.name)) continue;
|
|
88
|
+
if (!declaration || node.endIndex - node.startIndex > declaration.endIndex - declaration.startIndex) {
|
|
89
|
+
declaration = node;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (!declaration || declaration.hasError) continue;
|
|
94
|
+
// Export modifiers and Python decorators live outside the declaration.
|
|
95
|
+
while (['export_statement', 'decorated_definition'].includes(declaration.parent?.type) &&
|
|
96
|
+
declaration.parent.startPosition.row + 1 >= symbol.startLine) declaration = declaration.parent;
|
|
97
|
+
const tokens = [];
|
|
98
|
+
const lines = new Set();
|
|
99
|
+
const visit = node => {
|
|
100
|
+
if (COMMENT_NODES.has(node.type) || node.type === 'pass_statement') return;
|
|
101
|
+
if (node.type === 'decorated_definition' &&
|
|
102
|
+
CALLABLE_NODES.has(node.childForFieldName('definition')?.type)) return;
|
|
103
|
+
// Class docstrings are documentation, not fields or inheritance.
|
|
104
|
+
if (language === 'python' && node.type === 'expression_statement' &&
|
|
105
|
+
node.namedChildCount === 1 && ['string', 'concatenated_string'].includes(node.namedChild(0).type)) return;
|
|
106
|
+
const body = CALLABLE_NODES.has(node.type) ? node.childForFieldName('body') : null;
|
|
107
|
+
if (body && !FUNCTION_EXPRESSIONS.has(node.type)) return;
|
|
108
|
+
if (node.childCount === 0) {
|
|
109
|
+
// Empty anonymous semicolons between members are separators.
|
|
110
|
+
if (node.type === ';' && ['class_body', 'interface_body', 'declaration_list'].includes(node.parent?.type)) return;
|
|
111
|
+
tokens.push([node.type, node.text]);
|
|
112
|
+
for (let line = node.startPosition.row + 1; line <= node.endPosition.row + 1; line++) lines.add(line);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
for (const child of node.children) {
|
|
116
|
+
if (body && child.id === body.id) continue;
|
|
117
|
+
visit(child);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
visit(declaration);
|
|
121
|
+
snapshots.set(symbol, { signature: JSON.stringify(tokens), lines });
|
|
122
|
+
}
|
|
123
|
+
return snapshots;
|
|
124
|
+
}
|
|
125
|
+
|
|
31
126
|
const BRANCH_NODES = new Set([
|
|
32
127
|
'if_statement', 'if_expression', 'elif_clause',
|
|
33
128
|
'for_statement', 'for_in_statement', 'for_expression',
|
|
@@ -375,6 +470,7 @@ function projectComputedDispatch(index) {
|
|
|
375
470
|
}
|
|
376
471
|
|
|
377
472
|
module.exports = {
|
|
473
|
+
declarationSnapshots,
|
|
378
474
|
computeAstComplexity,
|
|
379
475
|
computedDispatchSites,
|
|
380
476
|
projectComputedDispatch,
|
package/core/bridge.js
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
'use strict';
|
|
27
27
|
|
|
28
28
|
const fs = require('fs');
|
|
29
|
-
const { codeUnitCompare } = require('./shared');
|
|
29
|
+
const { codeUnitCompare, isTestPath } = require('./shared');
|
|
30
30
|
const path = require('path');
|
|
31
31
|
const { getCachedCalls } = require('./callers');
|
|
32
32
|
const { getParser, safeParse } = require('../languages');
|
|
@@ -1490,9 +1490,12 @@ function endpoints(index, options = {}) {
|
|
|
1490
1490
|
showUncertain: options.showUncertain !== false,
|
|
1491
1491
|
};
|
|
1492
1492
|
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1493
|
+
const label = r => ({ ...r, isTest: isTestPath(r.file) });
|
|
1494
|
+
const inScope = r => index.matchesFilters(r.file, { in: options.in }) &&
|
|
1495
|
+
!(options.excludeTests && r.isTest);
|
|
1496
|
+
let routes = (opts.clientOnly ? [] : extractServerRoutes(index)).map(label).filter(inScope);
|
|
1497
|
+
let requests = (opts.serverOnly ? [] : extractClientRequests(index)).map(label).filter(inScope);
|
|
1498
|
+
let uncertainRequests = (opts.serverOnly ? [] : (index._endpointsCache?.uncertainRequests || [])).map(label).filter(inScope);
|
|
1496
1499
|
if (uncertainRequests.length > 0) {
|
|
1497
1500
|
// A server route registration (`@app.get("/x")`, `router.get("/x", h)`)
|
|
1498
1501
|
// is request-shaped too; the route inventory already owns those lines.
|
|
@@ -1514,12 +1517,14 @@ function endpoints(index, options = {}) {
|
|
|
1514
1517
|
uncertainRequests = uncertainRequests.filter(r => r.method.toUpperCase() === opts.method || r.method === 'request');
|
|
1515
1518
|
}
|
|
1516
1519
|
|
|
1517
|
-
let bridges = opts.bridge ? bridgeEndpoints(index)
|
|
1520
|
+
let bridges = opts.bridge ? bridgeEndpoints(index).map(b => ({
|
|
1521
|
+
...b, route: label(b.route), request: label(b.request),
|
|
1522
|
+
})) : [];
|
|
1518
1523
|
if (!opts.showUncertain) {
|
|
1519
1524
|
bridges = bridges.filter(b => b.matchType !== 'uncertain');
|
|
1520
1525
|
}
|
|
1521
1526
|
// If user filtered routes/requests, also constrain bridges
|
|
1522
|
-
if (opts.method || opts.prefix) {
|
|
1527
|
+
if (opts.method || opts.prefix || options.in || options.excludeTests) {
|
|
1523
1528
|
const routeKeys = new Set(routes.map(r => `${r.absoluteFile}:${r.line}:${r.method}:${r.path}`));
|
|
1524
1529
|
const reqKeys = new Set(requests.map(r => `${r.absoluteFile}:${r.line}:${r.method}:${r.path}`));
|
|
1525
1530
|
bridges = bridges.filter(b =>
|
package/core/check.js
CHANGED
|
@@ -73,6 +73,11 @@ function check(index, options = {}) {
|
|
|
73
73
|
const modified = (dr && Array.isArray(dr.functions)) ? dr.functions : [];
|
|
74
74
|
const added = (dr && Array.isArray(dr.newFunctions)) ? dr.newFunctions : [];
|
|
75
75
|
const deleted = (dr && Array.isArray(dr.deletedFunctions)) ? dr.deletedFunctions : [];
|
|
76
|
+
const declarations = [
|
|
77
|
+
...(dr?.symbols || []).map(s => ({ ...s, _kind: 'modified', _nonCallable: true })),
|
|
78
|
+
...(dr?.newSymbols || []).map(s => ({ ...s, _kind: 'added', _nonCallable: true })),
|
|
79
|
+
...(dr?.deletedSymbols || []).map(s => ({ ...s, _kind: 'deleted', _nonCallable: true })),
|
|
80
|
+
];
|
|
76
81
|
const pathCounts = {
|
|
77
82
|
changedPaths: dr?.changedPaths || 0,
|
|
78
83
|
nonSourcePaths: dr?.nonSourcePaths || 0,
|
|
@@ -82,9 +87,10 @@ function check(index, options = {}) {
|
|
|
82
87
|
const allChanged = [
|
|
83
88
|
...modified.map(f => ({ ...f, _kind: 'modified' })),
|
|
84
89
|
...added.map(f => ({ ...f, _kind: 'added' })),
|
|
90
|
+
...declarations,
|
|
85
91
|
];
|
|
86
92
|
|
|
87
|
-
if (!dr || (
|
|
93
|
+
if (!dr || (allChanged.length === 0 && deleted.length === 0)) {
|
|
88
94
|
// fix #283: say what the diff actually contained. "no changes
|
|
89
95
|
// detected" with three changed files reads as a false negative in a
|
|
90
96
|
// pre-commit hook — the truth is the changes are outside what the
|
|
@@ -95,7 +101,7 @@ function check(index, options = {}) {
|
|
|
95
101
|
if (changedPaths > 0 && nonSourcePaths === changedPaths) {
|
|
96
102
|
reason = `${changedPaths} changed path(s), all outside supported source files; untracked source files are included`;
|
|
97
103
|
} else if (changedPaths > 0) {
|
|
98
|
-
reason = 'no
|
|
104
|
+
reason = 'no indexed-symbol changes in the diff or untracked source files';
|
|
99
105
|
}
|
|
100
106
|
return {
|
|
101
107
|
base: options.base || 'HEAD',
|
|
@@ -116,9 +122,23 @@ function check(index, options = {}) {
|
|
|
116
122
|
// For each changed function, run verify and gather caller summary
|
|
117
123
|
for (const fn of changed) {
|
|
118
124
|
const filePath = fn.relativePath || fn.file || '';
|
|
125
|
+
if (fn._nonCallable) {
|
|
126
|
+
const dependency = fn.impact?.typeReferences || fn.impact?.propertyAccesses;
|
|
127
|
+
items.push({
|
|
128
|
+
name: fn.name, file: filePath, line: fn.startLine, kind: fn._kind,
|
|
129
|
+
symbolType: fn.type, requiresToolchainValidation: true,
|
|
130
|
+
callerCount: fn.impact?.totalCallSites || 0,
|
|
131
|
+
unverifiedCallerCount: fn.impact?.unverifiedSites?.length || 0,
|
|
132
|
+
dependencyCount: dependency?.confirmedCount || 0,
|
|
133
|
+
unverifiedDependencyCount: (dependency?.unverifiedCount || 0) + (fn.remainingReferences?.length || 0),
|
|
134
|
+
signatureMismatches: 0,
|
|
135
|
+
account: summarizeAccount(fn.impact?.account),
|
|
136
|
+
});
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
119
139
|
let verifyResult;
|
|
120
140
|
try {
|
|
121
|
-
verifyResult = index.verify(fn.name, { file: filePath });
|
|
141
|
+
verifyResult = index.verify(fn.name, { file: filePath, line: fn.startLine });
|
|
122
142
|
} catch (e) {
|
|
123
143
|
verifyResult = null;
|
|
124
144
|
}
|
|
@@ -225,6 +245,13 @@ function check(index, options = {}) {
|
|
|
225
245
|
// Action items
|
|
226
246
|
const actions = [];
|
|
227
247
|
for (const it of items) {
|
|
248
|
+
if (it.requiresToolchainValidation) {
|
|
249
|
+
actions.push({
|
|
250
|
+
severity: it.kind === 'added' ? 'warn' : 'error',
|
|
251
|
+
kind: 'declaration_change',
|
|
252
|
+
message: `${it.name}: ${it.kind} ${it.symbolType}; shape/inheritance compatibility is not checked by arity analysis — review dependency sites and run the compiler/type checker and tests`,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
228
255
|
if (!it.account || !it.account.textComplete) {
|
|
229
256
|
actions.push({
|
|
230
257
|
severity: 'error',
|
|
@@ -272,7 +299,7 @@ function check(index, options = {}) {
|
|
|
272
299
|
actions.push({
|
|
273
300
|
severity: 'error',
|
|
274
301
|
kind: 'truncated_change_set',
|
|
275
|
-
message: `${allChanged.length - limit} changed
|
|
302
|
+
message: `${allChanged.length - limit} changed symbol(s) were not checked; rerun without --limit`,
|
|
276
303
|
});
|
|
277
304
|
}
|
|
278
305
|
if (testFiles.length > 0) {
|
|
@@ -289,9 +316,10 @@ function check(index, options = {}) {
|
|
|
289
316
|
const signatureMismatches = items.reduce((sum, it) => sum + (it.signatureMismatches || 0), 0);
|
|
290
317
|
const filteredEdges = items.reduce((sum, it) => sum + (it.account ? it.account.filtered || 0 : 0), 0);
|
|
291
318
|
const usageReviewSymbols = items.filter(it => it.account && it.account.requiresUsageReview).length;
|
|
319
|
+
const unvalidatedDeclarations = items.filter(it => it.requiresToolchainValidation && it.kind !== 'added').length;
|
|
292
320
|
const reviewRequired = incompleteAccounts > 0 || unverifiedCallSites > 0 ||
|
|
293
321
|
signatureMismatches > 0 || filteredEdges > 0 || usageReviewSymbols > 0 ||
|
|
294
|
-
actions.some(a => a.kind === 'orphan_new') ||
|
|
322
|
+
actions.some(a => a.kind === 'orphan_new' || a.kind === 'declaration_change') ||
|
|
295
323
|
!!(limit && allChanged.length > limit);
|
|
296
324
|
|
|
297
325
|
return {
|
|
@@ -308,7 +336,7 @@ function check(index, options = {}) {
|
|
|
308
336
|
totalTests: testCount,
|
|
309
337
|
actions,
|
|
310
338
|
trust: {
|
|
311
|
-
status: incompleteAccounts > 0 || signatureMismatches > 0
|
|
339
|
+
status: incompleteAccounts > 0 || signatureMismatches > 0 || unvalidatedDeclarations > 0
|
|
312
340
|
? 'BLOCKED'
|
|
313
341
|
: reviewRequired ? 'REVIEW_REQUIRED' : 'READY_FOR_TOOLCHAIN',
|
|
314
342
|
accountsChecked: items.filter(it => it.account && it.account.available).length,
|
|
@@ -317,6 +345,7 @@ function check(index, options = {}) {
|
|
|
317
345
|
signatureMismatches,
|
|
318
346
|
filteredEdges,
|
|
319
347
|
usageReviewSymbols,
|
|
348
|
+
unvalidatedDeclarations,
|
|
320
349
|
semanticComplete: false,
|
|
321
350
|
safeToDelete: false,
|
|
322
351
|
requiresCompilerAndTests: true,
|
|
@@ -301,7 +301,7 @@ const COMMAND_CONTRACTS = Object.freeze({
|
|
|
301
301
|
{ name: 'bridge', when: '`bridge=true`.', answer: 'Exact/parameterized route-request matches plus unmatched sides.' },
|
|
302
302
|
{ name: 'unmatched', when: '`unmatched=true`.', answer: 'Only unmatched supported boundaries.' },
|
|
303
303
|
],
|
|
304
|
-
defaults: ['Framework-specific static extraction.', 'Interpolated-path uncertainty remains visible unless hidden explicitly.'],
|
|
304
|
+
defaults: ['Framework-specific static extraction.', 'Test routes/requests are labeled; excludeTests=true hides them and in scopes a directory.', 'Interpolated-path uncertainty remains visible unless hidden explicitly.'],
|
|
305
305
|
truth: 'Findings are static matches for supported framework call/decorator shapes; bridge confidence is match quality, not runtime probability.',
|
|
306
306
|
nonGoals: ['Network discovery.', 'Frameworks not represented by an endpoint adapter.'],
|
|
307
307
|
invalidCombinations: ['`serverOnly` and `clientOnly` cannot both describe a useful result.'],
|
package/core/execute.js
CHANGED
|
@@ -1796,6 +1796,8 @@ const HANDLERS = {
|
|
|
1796
1796
|
method: normMethod,
|
|
1797
1797
|
prefix: p.prefix || null,
|
|
1798
1798
|
showUncertain: !p.hideUncertain,
|
|
1799
|
+
in: p.in,
|
|
1800
|
+
excludeTests: !!p.excludeTests,
|
|
1799
1801
|
});
|
|
1800
1802
|
if (p.framework != null && String(p.framework).trim() !== '') {
|
|
1801
1803
|
const framework = String(p.framework).trim().toLowerCase();
|
|
@@ -1859,6 +1861,7 @@ const HANDLERS = {
|
|
|
1859
1861
|
// Recompute meta after filtering
|
|
1860
1862
|
result.meta = {
|
|
1861
1863
|
totalRoutes: result.routes.length,
|
|
1864
|
+
testRoutes: result.routes.filter(r => r.isTest).length,
|
|
1862
1865
|
totalRequests: result.requests.length,
|
|
1863
1866
|
uncertainRequests: (result.uncertainRequests || []).length,
|
|
1864
1867
|
totalBridges: result.bridges.length,
|
|
@@ -2286,7 +2289,7 @@ const HANDLERS = {
|
|
|
2286
2289
|
const limit = num(p.limit, undefined);
|
|
2287
2290
|
let note;
|
|
2288
2291
|
if (limit && limit > 0 && result) {
|
|
2289
|
-
const groups = ['functions', 'moduleLevelChanges', 'newFunctions', 'deletedFunctions'];
|
|
2292
|
+
const groups = ['functions', 'symbols', 'newSymbols', 'deletedSymbols', 'moduleLevelChanges', 'newFunctions', 'deletedFunctions'];
|
|
2290
2293
|
const total = groups.reduce((sum, key) => sum + (result[key]?.length || 0), 0);
|
|
2291
2294
|
if (total > limit) {
|
|
2292
2295
|
let remaining = limit;
|
|
@@ -207,8 +207,14 @@ function formatDiffImpact(result, options = {}) {
|
|
|
207
207
|
if (s.modifiedFunctions > 0) parts.push(`${s.modifiedFunctions} modified`);
|
|
208
208
|
if (s.deletedFunctions > 0) parts.push(`${s.deletedFunctions} deleted`);
|
|
209
209
|
if (s.newFunctions > 0) parts.push(`${s.newFunctions} new`);
|
|
210
|
+
if (s.modifiedSymbols || s.newSymbols || s.deletedSymbols) {
|
|
211
|
+
parts.push(`${s.modifiedSymbols || 0} modified, ${s.newSymbols || 0} new, ${s.deletedSymbols || 0} deleted non-callable declarations`);
|
|
212
|
+
}
|
|
210
213
|
parts.push(`${s.totalCallSites || 0} call sites across ${s.affectedFiles || 0} files`);
|
|
211
214
|
if (s.unverifiedCallSites > 0) parts.push(`${s.unverifiedCallSites} unverified`);
|
|
215
|
+
if (s.totalDependencySites || s.unverifiedDependencySites) {
|
|
216
|
+
parts.push(`${s.totalDependencySites || 0} confirmed + ${s.unverifiedDependencySites || 0} unverified non-call dependency sites across ${s.dependencyFiles || 0} files`);
|
|
217
|
+
}
|
|
212
218
|
lines.push(parts.join(', '));
|
|
213
219
|
// fix #283: changed paths outside supported source are invisible to the
|
|
214
220
|
// symbol analysis — disclose instead of silently narrowing the diff.
|
|
@@ -229,10 +235,10 @@ function formatDiffImpact(result, options = {}) {
|
|
|
229
235
|
lines.push(` ${fn.relativePath}:${fn.startLine}`);
|
|
230
236
|
lines.push(` ${fn.signature}`);
|
|
231
237
|
if (fn.addedLines.length > 0) {
|
|
232
|
-
lines.push(`
|
|
238
|
+
lines.push(` Added at lines: ${formatLineRanges(fn.addedLines)}`);
|
|
233
239
|
}
|
|
234
240
|
if (fn.deletedLines.length > 0) {
|
|
235
|
-
lines.push(`
|
|
241
|
+
lines.push(` Deleted at old lines: ${formatLineRanges(fn.deletedLines)}`);
|
|
236
242
|
}
|
|
237
243
|
|
|
238
244
|
if (fn.callers.length > 0) {
|
|
@@ -302,6 +308,24 @@ function formatDiffImpact(result, options = {}) {
|
|
|
302
308
|
}
|
|
303
309
|
}
|
|
304
310
|
|
|
311
|
+
for (const [key, title] of [['symbols', 'MODIFIED'], ['newSymbols', 'NEW'], ['deletedSymbols', 'DELETED']]) {
|
|
312
|
+
if (!result[key]?.length) continue;
|
|
313
|
+
lines.push(`\n${title} DECLARATIONS:`);
|
|
314
|
+
for (const symbol of result[key]) {
|
|
315
|
+
lines.push(` ${symbol.type} ${symbol.name} — ${symbol.relativePath}:${symbol.startLine}`);
|
|
316
|
+
if (symbol.impact) {
|
|
317
|
+
lines.push(require('./analysis').formatImpact(symbol.impact, { compact: true }));
|
|
318
|
+
} else {
|
|
319
|
+
const refs = symbol.remainingReferences || [];
|
|
320
|
+
lines.push(` Remaining name references: ${refs.length} unverified (deleted target)`);
|
|
321
|
+
for (const site of refs.slice(0, MAX_CALLERS_PER_FN)) {
|
|
322
|
+
lines.push(` ${site.file}:${site.line}: ${site.expression}`);
|
|
323
|
+
}
|
|
324
|
+
if (refs.length > MAX_CALLERS_PER_FN) lines.push(` (+${refs.length - MAX_CALLERS_PER_FN} more)`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
305
329
|
// Module-level changes
|
|
306
330
|
if (result.moduleLevelChanges.length > 0) {
|
|
307
331
|
lines.push('\nMODULE-LEVEL CHANGES:');
|
package/core/output/analysis.js
CHANGED
|
@@ -837,8 +837,10 @@ function formatImpact(impact, options = {}) {
|
|
|
837
837
|
}
|
|
838
838
|
|
|
839
839
|
// By file (confirmed tier)
|
|
840
|
-
if (
|
|
841
|
-
|
|
840
|
+
if (impact.byFile.length > 0) {
|
|
841
|
+
if (!compact) lines.push('');
|
|
842
|
+
lines.push('BY FILE:');
|
|
843
|
+
}
|
|
842
844
|
|
|
843
845
|
// Evidence aggregate over ALL sites (replaces per-edge confidence lines)
|
|
844
846
|
const allSites = impact.byFile.flatMap(g => g.sites);
|
|
@@ -881,7 +883,7 @@ function formatImpact(impact, options = {}) {
|
|
|
881
883
|
for (const site of group.sites) {
|
|
882
884
|
const caller = site.callerName ? ` [${site.callerName}]` : '';
|
|
883
885
|
const expr = site.expression ? `: ${site.expression.replace(/\s+/g, ' ').slice(0, 100)}` : '';
|
|
884
|
-
lines.push(` ${group.file}:${site.line}${caller}${expr}`);
|
|
886
|
+
lines.push(` ${group.file}:${site.line}${caller} [${site.accessKind || 'access'}${Number.isInteger(site.column) ? `, column ${site.column + 1}` : ''}]${expr}`);
|
|
885
887
|
}
|
|
886
888
|
}
|
|
887
889
|
if (access.unverifiedSites.length > 0) {
|
|
@@ -889,7 +891,7 @@ function formatImpact(impact, options = {}) {
|
|
|
889
891
|
for (const site of access.unverifiedSites.slice(0, 10)) {
|
|
890
892
|
const caller = site.callerName ? ` [${site.callerName}]` : '';
|
|
891
893
|
const expr = site.expression ? `: ${site.expression.replace(/\s+/g, ' ').slice(0, 100)}` : '';
|
|
892
|
-
lines.push(` ${site.file}:${site.line}${caller}${expr}`);
|
|
894
|
+
lines.push(` ${site.file}:${site.line}${caller} [${site.accessKind || 'access'}${Number.isInteger(site.column) ? `, column ${site.column + 1}` : ''}]${expr}`);
|
|
893
895
|
}
|
|
894
896
|
if (access.unverifiedSites.length > 10) {
|
|
895
897
|
lines.push(` (+${access.unverifiedSites.length - 10} more unverified)`);
|
package/core/output/check.js
CHANGED
|
@@ -29,15 +29,17 @@ function formatCheck(result) {
|
|
|
29
29
|
if (result.trust.signatureMismatches) trustDetails.push(`${result.trust.signatureMismatches} signature mismatch(es)`);
|
|
30
30
|
if (result.trust.filteredEdges) trustDetails.push(`${result.trust.filteredEdges} filtered edge(s)`);
|
|
31
31
|
if (result.trust.usageReviewSymbols) trustDetails.push(`${result.trust.usageReviewSymbols} symbol(s) need usages review`);
|
|
32
|
+
if (result.trust.unvalidatedDeclarations) trustDetails.push(`${result.trust.unvalidatedDeclarations} declaration change(s) need toolchain validation`);
|
|
32
33
|
if (trustDetails.length > 0) lines.push(` ${trustDetails.join(' · ')}`);
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
// Changed functions section
|
|
36
37
|
const items = result.changed || [];
|
|
38
|
+
const noun = items.some(it => it.symbolType) ? 'symbol' : 'function';
|
|
37
39
|
if (result.truncated) {
|
|
38
|
-
lines.push(`Changed: ${items.length} of ${result.totalChanged}
|
|
40
|
+
lines.push(`Changed: ${items.length} of ${result.totalChanged} ${noun}s`);
|
|
39
41
|
} else {
|
|
40
|
-
lines.push(`Changed: ${items.length}
|
|
42
|
+
lines.push(`Changed: ${items.length} ${noun}${items.length === 1 ? '' : 's'}`);
|
|
41
43
|
}
|
|
42
44
|
if (items.length === 0) {
|
|
43
45
|
lines.push(' (none — only non-function changes)');
|
|
@@ -47,6 +49,7 @@ function formatCheck(result) {
|
|
|
47
49
|
if (it.kind && it.kind !== 'changed') tags.push(it.kind.toUpperCase());
|
|
48
50
|
if (it.signatureMismatches > 0) tags.push(`SIG-DRIFT(${it.signatureMismatches})`);
|
|
49
51
|
if (it.orphan) tags.push('ORPHAN');
|
|
52
|
+
if (it.symbolType) tags.push(it.symbolType);
|
|
50
53
|
if (it.account && !it.account.textComplete) tags.push('ACCOUNT-INCOMPLETE');
|
|
51
54
|
const tagStr = tags.length ? ' [' + tags.join(', ') + ']' : '';
|
|
52
55
|
let callers = it.callerCount != null ? `${it.callerCount} caller${it.callerCount === 1 ? '' : 's'}` : '';
|
|
@@ -54,6 +57,7 @@ function formatCheck(result) {
|
|
|
54
57
|
callers += ` (+${it.unverifiedCallerCount} unverified)`;
|
|
55
58
|
}
|
|
56
59
|
lines.push(` ${it.name} (${it.file}:${it.line})${tagStr} ${callers}`);
|
|
60
|
+
if (it.symbolType) lines.push(` Dependencies: ${it.dependencyCount || 0} confirmed, ${it.unverifiedDependencyCount || 0} unverified`);
|
|
57
61
|
if (it.mismatches && it.mismatches.length > 0) {
|
|
58
62
|
for (const m of it.mismatches.slice(0, 3)) {
|
|
59
63
|
const where = m.file ? ` at ${m.file}:${m.line}` : '';
|
package/core/output/endpoints.js
CHANGED
|
@@ -55,7 +55,7 @@ function formatUncertainRequests(result, options) {
|
|
|
55
55
|
lines.push(`Possible client requests (${list.length}) — request-shaped call with a path literal, receiver not recognized as an HTTP client:`);
|
|
56
56
|
const cap = options.all ? Infinity : 10;
|
|
57
57
|
for (const r of list.slice(0, cap)) {
|
|
58
|
-
lines.push(` ${r.file}:${r.line} ${r.receiver}.${r.method}(${JSON.stringify(r.path)}) in ${r.callerName}`);
|
|
58
|
+
lines.push(` ${r.file}:${r.line} ${r.receiver}.${r.method}(${JSON.stringify(r.path)}) in ${r.callerName}${r.isTest ? ' [test]' : ''}`);
|
|
59
59
|
}
|
|
60
60
|
if (list.length > cap) lines.push(` (+${list.length - cap} more — use --all)`);
|
|
61
61
|
return lines;
|
|
@@ -88,7 +88,7 @@ function formatRoutesAndRequests(routes, requests, meta, options, advisory = nul
|
|
|
88
88
|
for (const r of list) {
|
|
89
89
|
const handler = r.handler || '<anonymous>';
|
|
90
90
|
const fw = r.framework ? `[${r.framework}]` : '';
|
|
91
|
-
lines.push(` ${pad(r.method, 7)} ${pad(r.path, 40)} → ${handler} ${fw} :${r.line}`);
|
|
91
|
+
lines.push(` ${pad(r.method, 7)} ${pad(r.path, 40)} → ${handler} ${fw}${r.isTest ? ' [test]' : ''} :${r.line}`);
|
|
92
92
|
}
|
|
93
93
|
lines.push('');
|
|
94
94
|
}
|
|
@@ -115,7 +115,7 @@ function formatRoutesAndRequests(routes, requests, meta, options, advisory = nul
|
|
|
115
115
|
const inferred = r.methodInferred ? '?' : '';
|
|
116
116
|
const interp = r.interp ? ' (interp)' : '';
|
|
117
117
|
const fw = r.framework ? `[${r.framework}]` : '';
|
|
118
|
-
lines.push(` ${pad(r.method + inferred, 7)} ${pad(r.path + interp, 40)} from ${r.callerName} ${fw} :${r.line}`);
|
|
118
|
+
lines.push(` ${pad(r.method + inferred, 7)} ${pad(r.path + interp, 40)} from ${r.callerName} ${fw}${r.isTest ? ' [test]' : ''} :${r.line}`);
|
|
119
119
|
}
|
|
120
120
|
lines.push('');
|
|
121
121
|
}
|
|
@@ -166,12 +166,12 @@ function formatBridges(bridges, unmatchedRoutes, unmatchedRequests, meta, option
|
|
|
166
166
|
|
|
167
167
|
lines.push(`Matched (${sorted.length} routes):`);
|
|
168
168
|
for (const { route, clients } of sorted) {
|
|
169
|
-
lines.push(` ${pad(route.method, 7)} ${route.path} [${route.framework}] ${route.file}:${route.line}`);
|
|
169
|
+
lines.push(` ${pad(route.method, 7)} ${route.path} [${route.framework}]${route.isTest ? ' [test]' : ''} ${route.file}:${route.line}`);
|
|
170
170
|
for (const b of clients) {
|
|
171
171
|
const conf = b.confidence.toFixed(2);
|
|
172
172
|
const tier = b.matchType.toUpperCase();
|
|
173
173
|
const inf = b.methodInferred ? ' method?' : '';
|
|
174
|
-
lines.push(` ↔ ${pad(b.request.method + inf, 9)} ${pad(b.request.path, 30)} ${tier} (${conf}) from ${b.request.callerName} ${b.request.file}:${b.request.line}`);
|
|
174
|
+
lines.push(` ↔ ${pad(b.request.method + inf, 9)} ${pad(b.request.path, 30)} ${tier} (${conf}) from ${b.request.callerName}${b.request.isTest ? ' [test]' : ''} ${b.request.file}:${b.request.line}`);
|
|
175
175
|
}
|
|
176
176
|
lines.push('');
|
|
177
177
|
}
|
|
@@ -180,7 +180,7 @@ function formatBridges(bridges, unmatchedRoutes, unmatchedRequests, meta, option
|
|
|
180
180
|
if (unmatchedRoutes.length > 0) {
|
|
181
181
|
lines.push(`Unmatched server routes (${unmatchedRoutes.length}):`);
|
|
182
182
|
for (const r of unmatchedRoutes) {
|
|
183
|
-
lines.push(` ${pad(r.method, 7)} ${pad(r.path, 40)} → ${r.handler} [${r.framework}] ${r.file}:${r.line}`);
|
|
183
|
+
lines.push(` ${pad(r.method, 7)} ${pad(r.path, 40)} → ${r.handler} [${r.framework}]${r.isTest ? ' [test]' : ''} ${r.file}:${r.line}`);
|
|
184
184
|
}
|
|
185
185
|
lines.push('');
|
|
186
186
|
}
|
|
@@ -190,7 +190,7 @@ function formatBridges(bridges, unmatchedRoutes, unmatchedRequests, meta, option
|
|
|
190
190
|
for (const r of unmatchedRequests) {
|
|
191
191
|
const inferred = r.methodInferred ? '?' : '';
|
|
192
192
|
const interp = r.interp ? ' (interp)' : '';
|
|
193
|
-
lines.push(` ${pad(r.method + inferred, 7)} ${pad(r.path + interp, 40)} from ${r.callerName} [${r.framework}] ${r.file}:${r.line}`);
|
|
193
|
+
lines.push(` ${pad(r.method + inferred, 7)} ${pad(r.path + interp, 40)} from ${r.callerName} [${r.framework}]${r.isTest ? ' [test]' : ''} ${r.file}:${r.line}`);
|
|
194
194
|
}
|
|
195
195
|
}
|
|
196
196
|
|
|
@@ -213,6 +213,7 @@ function formatEndpointsJson(result, options = {}) {
|
|
|
213
213
|
file: r.file,
|
|
214
214
|
line: r.line,
|
|
215
215
|
framework: r.framework,
|
|
216
|
+
isTest: !!r.isTest,
|
|
216
217
|
...(r.classPrefix && { classPrefix: r.classPrefix }),
|
|
217
218
|
});
|
|
218
219
|
const trimReq = (r) => ({
|
|
@@ -225,6 +226,7 @@ function formatEndpointsJson(result, options = {}) {
|
|
|
225
226
|
callerName: r.callerName,
|
|
226
227
|
...(r.callerStartLine && { callerStartLine: r.callerStartLine }),
|
|
227
228
|
framework: r.framework,
|
|
229
|
+
isTest: !!r.isTest,
|
|
228
230
|
...(r.methodInferred && { methodInferred: true }),
|
|
229
231
|
});
|
|
230
232
|
const trimBridge = (b) => ({
|
|
@@ -250,6 +252,7 @@ function formatEndpointsJson(result, options = {}) {
|
|
|
250
252
|
uncertainRequests: (result.uncertainRequests || []).map(r => ({
|
|
251
253
|
receiver: r.receiver, method: r.method, path: r.path,
|
|
252
254
|
file: r.file, line: r.line, callerName: r.callerName, reason: r.reason,
|
|
255
|
+
isTest: !!r.isTest,
|
|
253
256
|
})),
|
|
254
257
|
// In unmatched-only mode, the matched bridges array is suppressed
|
|
255
258
|
// — consumers that want both should not pass --unmatched.
|
package/core/output/lines.js
CHANGED
|
@@ -77,9 +77,20 @@ function findRecords(result) {
|
|
|
77
77
|
function usagesRecords(result) {
|
|
78
78
|
const out = [];
|
|
79
79
|
const notes = [];
|
|
80
|
+
const byLine = new Map();
|
|
80
81
|
for (const usage of Array.isArray(result) ? result : []) {
|
|
81
82
|
const kind = usage.isDefinition ? 'definition' : (usage.usageType || 'reference');
|
|
82
|
-
|
|
83
|
+
const key = `${pathOf(usage)}\0${usage.line}`;
|
|
84
|
+
if (!byLine.has(key)) byLine.set(key, { usage, kinds: new Set(), count: 0 });
|
|
85
|
+
const row = byLine.get(key);
|
|
86
|
+
row.kinds.add(kind);
|
|
87
|
+
row.count++;
|
|
88
|
+
}
|
|
89
|
+
for (const { usage, kinds, count } of byLine.values()) {
|
|
90
|
+
const tags = [...kinds].filter(kind => kind !== 'call');
|
|
91
|
+
if (kinds.has('call') && tags.length) tags.unshift('call');
|
|
92
|
+
if (count > 1) tags.push(`${count} occurrences`);
|
|
93
|
+
out.push(record(pathOf(usage), usage.line, usage.content, tags.join('; ')));
|
|
83
94
|
}
|
|
84
95
|
const counts = result && result.summaryCounts;
|
|
85
96
|
if (counts && counts.hiddenTestUsages > 0) {
|
|
@@ -187,6 +198,17 @@ function impactRecords(result) {
|
|
|
187
198
|
out.push(record(pathOf(site), site.line, site.content, 'unverified: deleted-target-name-match'));
|
|
188
199
|
}
|
|
189
200
|
}
|
|
201
|
+
for (const symbol of [...(result.symbols || []), ...(result.newSymbols || []), ...(result.deletedSymbols || [])]) {
|
|
202
|
+
out.push(record(symbol.relativePath, symbol.startLine, symbol.name, `${symbol.type} declaration change`));
|
|
203
|
+
if (symbol.impact) {
|
|
204
|
+
const nested = impactRecords(symbol.impact);
|
|
205
|
+
out.push(...nested.records);
|
|
206
|
+
notes.push(...nested.notes);
|
|
207
|
+
}
|
|
208
|
+
for (const site of symbol.remainingReferences || []) {
|
|
209
|
+
out.push(record(site.file, site.line, site.expression, 'unverified: deleted-target-name-match'));
|
|
210
|
+
}
|
|
211
|
+
}
|
|
190
212
|
const summary = result.summary || {};
|
|
191
213
|
notes.push(`# Diff: ${summary.modifiedFunctions || 0} modified, ${summary.newFunctions || 0} new, ${summary.deletedFunctions || 0} deleted functions; ${(result.moduleLevelChanges || []).length} file(s) with module-level changes.`);
|
|
192
214
|
if (result.nonSourcePaths) notes.push(`# ${result.nonSourcePaths} changed path(s) outside supported source files not analyzed.`);
|
|
@@ -206,11 +228,11 @@ function impactRecords(result) {
|
|
|
206
228
|
const accesses = result.propertyAccesses;
|
|
207
229
|
for (const group of accesses.byFile || []) {
|
|
208
230
|
for (const access of group.sites || []) {
|
|
209
|
-
out.push(record(group.file, access.line, access.expression,
|
|
231
|
+
out.push(record(group.file, access.line, access.expression, `property-access: ${access.accessKind || 'access'}${Number.isInteger(access.column) ? `, column ${access.column + 1}` : ''}`));
|
|
210
232
|
}
|
|
211
233
|
}
|
|
212
234
|
for (const access of accesses.unverifiedSites || []) {
|
|
213
|
-
out.push(record(pathOf(access), access.line, access.expression, `${unverifiedTag(access)}; property-access`));
|
|
235
|
+
out.push(record(pathOf(access), access.line, access.expression, `${unverifiedTag(access)}; property-access: ${access.accessKind || 'access'}${Number.isInteger(access.column) ? `, column ${access.column + 1}` : ''}`));
|
|
214
236
|
}
|
|
215
237
|
notes.push(`# PROPERTY ACCESS SITES: ${accesses.confirmedCount} confirmed, ${accesses.unverifiedCount} unverified, ${accesses.excluded?.total || 0} other-target (separate from caller ACCOUNT).`);
|
|
216
238
|
}
|
package/core/output/reporting.js
CHANGED
|
@@ -284,7 +284,7 @@ function formatDeadcode(results, options = {}) {
|
|
|
284
284
|
lines.push(`\n${extHint}`);
|
|
285
285
|
}
|
|
286
286
|
if (results.excludedRuntimeContract > 0) {
|
|
287
|
-
lines.push(`\n${results.excludedRuntimeContract}
|
|
287
|
+
lines.push(`\n${results.excludedRuntimeContract} runtime callback(s) hidden (language/runtime registrations, not dead).`);
|
|
288
288
|
}
|
|
289
289
|
if (results.pythonImplicitExportFiles > 0) {
|
|
290
290
|
lines.push(`\nPython public-surface rule active in ${results.pythonImplicitExportFiles} file(s) without __all__: top-level non-underscore names are treated as externally reachable.`);
|
package/core/output-budget.js
CHANGED
|
@@ -132,6 +132,7 @@ function applyOutputBudget(text, {
|
|
|
132
132
|
all = false,
|
|
133
133
|
surface = 'cli',
|
|
134
134
|
params = {},
|
|
135
|
+
trailingChars = 0,
|
|
135
136
|
} = {}) {
|
|
136
137
|
if (!text) {
|
|
137
138
|
return {
|
|
@@ -148,13 +149,14 @@ function applyOutputBudget(text, {
|
|
|
148
149
|
? BROAD_OUTPUT_CHARS
|
|
149
150
|
: DEFAULT_OUTPUT_CHARS;
|
|
150
151
|
const requested = maxChars || (all ? MAX_OUTPUT_CHARS : defaultLimit);
|
|
151
|
-
const
|
|
152
|
+
const hardLimit = Math.min(requested, MAX_OUTPUT_CHARS);
|
|
153
|
+
const limit = Math.max(0, hardLimit - trailingChars);
|
|
152
154
|
if (text.length <= limit) {
|
|
153
155
|
return {
|
|
154
156
|
text,
|
|
155
157
|
truncated: false,
|
|
156
158
|
fullChars: text.length,
|
|
157
|
-
requestedLimit:
|
|
159
|
+
requestedLimit: hardLimit,
|
|
158
160
|
contractMetadata: [],
|
|
159
161
|
contractMetadataComplete: true,
|
|
160
162
|
};
|
|
@@ -178,8 +180,8 @@ function applyOutputBudget(text, {
|
|
|
178
180
|
? `Raise ${raiseHint}.`
|
|
179
181
|
: `Narrow with ${compactScope || raiseHint}; raise ${raiseHint}.`;
|
|
180
182
|
let notice = compactBudget
|
|
181
|
-
? `... OUTPUT TRUNCATED (${text.length}→${
|
|
182
|
-
: `... OUTPUT TRUNCATED: ${text.length} chars total; hard limit ${
|
|
183
|
+
? `... OUTPUT TRUNCATED (${text.length}→${hardLimit}). ${compactGuidance}`
|
|
184
|
+
: `... OUTPUT TRUNCATED: ${text.length} chars total; hard limit ${hardLimit}. ` +
|
|
183
185
|
`${narrowingHint(command, surface, params)} ${allHint}`;
|
|
184
186
|
if (compactBudget && notice.length > limit) {
|
|
185
187
|
const emergency = supportsAll
|
|
@@ -225,7 +227,7 @@ function applyOutputBudget(text, {
|
|
|
225
227
|
text: pieces.join('\n').slice(0, limit),
|
|
226
228
|
truncated: true,
|
|
227
229
|
fullChars: text.length,
|
|
228
|
-
requestedLimit:
|
|
230
|
+
requestedLimit: hardLimit,
|
|
229
231
|
contractMetadata: contractMetadata.lines,
|
|
230
232
|
contractMetadataComplete: contractMetadata.complete,
|
|
231
233
|
};
|
|
@@ -283,7 +285,7 @@ function applyOutputBudget(text, {
|
|
|
283
285
|
text: rendered,
|
|
284
286
|
truncated: true,
|
|
285
287
|
fullChars: text.length,
|
|
286
|
-
requestedLimit:
|
|
288
|
+
requestedLimit: hardLimit,
|
|
287
289
|
contractMetadata: contractMetadata.lines,
|
|
288
290
|
contractMetadataComplete: contractMetadata.complete,
|
|
289
291
|
};
|
package/core/registry.js
CHANGED
|
@@ -149,7 +149,7 @@ const FLAG_APPLICABILITY = {
|
|
|
149
149
|
repo: ['file', 'exclude', 'top', 'limit', 'all', 'detailed', 'topLevel', 'in', 'functions', 'hot', 'deep', 'sections'],
|
|
150
150
|
deadcode: ['file', 'exclude', 'includeTests', 'includeExported', 'includeDecorated', 'limit', 'in'],
|
|
151
151
|
entrypoints: ['file', 'exclude', 'includeTests', 'excludeTests', 'limit', 'type', 'framework'],
|
|
152
|
-
endpoints: ['file', 'exclude', 'limit', 'framework', 'bridge', 'serverOnly', 'clientOnly', 'unmatched', 'method', 'prefix', 'hideUncertain'],
|
|
152
|
+
endpoints: ['file', 'in', 'exclude', 'excludeTests', 'limit', 'framework', 'bridge', 'serverOnly', 'clientOnly', 'unmatched', 'method', 'prefix', 'hideUncertain'],
|
|
153
153
|
stacktrace: ['stack'],
|
|
154
154
|
auditAsync: ['file', 'exclude', 'limit'],
|
|
155
155
|
};
|
package/core/verify.js
CHANGED
|
@@ -247,14 +247,26 @@ function classifyCallContext(callNode, language) {
|
|
|
247
247
|
/**
|
|
248
248
|
* Find a call expression node at the target line matching funcName
|
|
249
249
|
*/
|
|
250
|
-
function findCallNode(node, callTypes, targetRow, funcName, occurrence = 0) {
|
|
250
|
+
function findCallNode(node, callTypes, targetRow, funcName, occurrence = 0, site = null) {
|
|
251
251
|
// Several same-name calls can share one line (`greet("a") + greet("b")`,
|
|
252
252
|
// f-strings) — fix #231: callers pass the site's per-line ordinal so each
|
|
253
253
|
// record is arg-checked against ITS OWN node, not the line's first.
|
|
254
254
|
// Records and this walk are both pre-order, so ordinals align; an
|
|
255
255
|
// out-of-range ordinal falls back to the first match (never worse than
|
|
256
256
|
// the pre-fix behavior when a parse shape hides a node).
|
|
257
|
-
const matches = _collectCallNodes(node, callTypes, targetRow, funcName, occurrence + 1);
|
|
257
|
+
const matches = _collectCallNodes(node, callTypes, targetRow, funcName, site ? Infinity : occurrence + 1);
|
|
258
|
+
if (Number.isInteger(site?.start)) {
|
|
259
|
+
// The resolver already identified the callee token. Preserve that
|
|
260
|
+
// identity through argument extraction, including aliases and several
|
|
261
|
+
// calls on one line (some of which may target another declaration).
|
|
262
|
+
const exact = matches.filter(candidate => {
|
|
263
|
+
const callee = candidate.childForFieldName('function') ||
|
|
264
|
+
candidate.childForFieldName('name') ||
|
|
265
|
+
candidate.childForFieldName('constructor') || candidate.childForFieldName('type');
|
|
266
|
+
return callee && callee.startIndex <= site.start && callee.endIndex >= site.end;
|
|
267
|
+
});
|
|
268
|
+
if (exact.length === 1) return exact[0];
|
|
269
|
+
}
|
|
258
270
|
return matches[occurrence] || matches[0] || null;
|
|
259
271
|
}
|
|
260
272
|
|
|
@@ -795,8 +807,10 @@ function computePlanCallSites(index, name, def) {
|
|
|
795
807
|
content: c.content,
|
|
796
808
|
usageType: 'call',
|
|
797
809
|
receiver: c.receiver,
|
|
810
|
+
calledAs: c.calledAs,
|
|
811
|
+
callSite: c.provenance?.facts?.site,
|
|
798
812
|
};
|
|
799
|
-
const siteKey = `${c.file}:${c.line}`;
|
|
813
|
+
const siteKey = `${c.file}:${c.line}:${c.calledAs || name}`;
|
|
800
814
|
const occurrence = planLineSeen.get(siteKey) || 0;
|
|
801
815
|
planLineSeen.set(siteKey, occurrence + 1);
|
|
802
816
|
const analysis = analyzeCallSite(index, call, name, occurrence);
|
|
@@ -898,7 +912,9 @@ function analyzeCallSite(index, call, funcName, occurrence = 0) {
|
|
|
898
912
|
const targetRow = call.line - 1; // tree-sitter is 0-indexed
|
|
899
913
|
|
|
900
914
|
// Find the call expression at the target line matching funcName
|
|
901
|
-
const
|
|
915
|
+
const spelling = call.calledAs && call.calledAs !== 'bound' ? call.calledAs : funcName;
|
|
916
|
+
const callNode = findCallNode(tree.rootNode, callTypes, targetRow, spelling, occurrence,
|
|
917
|
+
call.callSite || call.provenance?.facts?.site);
|
|
902
918
|
if (!callNode) return { args: null, argCount: 0 };
|
|
903
919
|
|
|
904
920
|
// Check if this is a method call (obj.func()) vs a direct call (func())
|
|
@@ -1353,6 +1369,8 @@ function verify(index, name, options = {}) {
|
|
|
1353
1369
|
content: c.content,
|
|
1354
1370
|
usageType: 'call',
|
|
1355
1371
|
receiver: c.receiver,
|
|
1372
|
+
calledAs: c.calledAs,
|
|
1373
|
+
callSite: c.provenance?.facts?.site,
|
|
1356
1374
|
// Preserve receiver identity through the usage-shaped adapter. Go
|
|
1357
1375
|
// permits a local value to have the same spelling as its type; only
|
|
1358
1376
|
// a type-qualified call is a method expression with an explicit
|
|
@@ -1394,7 +1412,7 @@ function verify(index, name, options = {}) {
|
|
|
1394
1412
|
|
|
1395
1413
|
const verifyLineSeen = new Map(); // 'file:line' -> per-line ordinal (fix #231)
|
|
1396
1414
|
for (const call of calls) {
|
|
1397
|
-
const siteKey = `${call.file}:${call.line}`;
|
|
1415
|
+
const siteKey = `${call.file}:${call.line}:${call.calledAs || name}`;
|
|
1398
1416
|
const occurrence = verifyLineSeen.get(siteKey) || 0;
|
|
1399
1417
|
verifyLineSeen.set(siteKey, occurrence + 1);
|
|
1400
1418
|
const analysis = analyzeCallSite(index, call, name, occurrence);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ucn",
|
|
3
|
-
"version": "5.4.
|
|
3
|
+
"version": "5.4.1",
|
|
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.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",
|
|
13
|
+
"test": "node --test test/audit-5.4.0.test.js 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",
|