ucn 5.4.0 → 5.4.2
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 +3 -2
- 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 +30 -6
- package/core/output/analysis.js +6 -4
- package/core/output/check.js +6 -2
- package/core/output/endpoints.js +10 -7
- package/core/output/find.js +4 -0
- package/core/output/lines.js +33 -5
- package/core/output/reporting.js +1 -1
- package/core/output-budget.js +30 -17
- package/core/registry.js +1 -1
- package/core/verify.js +23 -5
- package/mcp/server.js +3 -3
- 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
|
@@ -485,6 +485,7 @@ function formatCliText(command, result, params, execution, displayFlags) {
|
|
|
485
485
|
all: !!displayFlags?.all,
|
|
486
486
|
surface: 'cli',
|
|
487
487
|
params,
|
|
488
|
+
trailingChars: 1, // emitCliText appends the terminal newline.
|
|
488
489
|
}).text;
|
|
489
490
|
}
|
|
490
491
|
|
|
@@ -932,9 +933,9 @@ Common flags:
|
|
|
932
933
|
--all --compact --no-compact --json --include-tests --class-name=X --line=N
|
|
933
934
|
--range=N-M (source with --file=PATH)
|
|
934
935
|
--base=REF --staged --no-cache --clear-cache [--all] --max-files=N --workers=N
|
|
935
|
-
--max-chars=N (text
|
|
936
|
+
--max-chars=N (text characters, not UTF-8 bytes; default 10K targeted / 3K broad, ceiling 100K)
|
|
936
937
|
--lines find/usages/search/show/impact: grep -n shape, one path:line:text
|
|
937
|
-
record per output line; usages
|
|
938
|
+
record per output line; usages combines occurrences on each source line
|
|
938
939
|
(tags after a tab: # unverified: <reason>, # import,
|
|
939
940
|
# callee); accounting and notes go to stderr as "# " lines; exit 1
|
|
940
941
|
when nothing matched; exit 2 on errors. No default result cap.
|
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 =>
|