ucn 5.3.8 → 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 +41 -5
- package/.claude/skills/ucn/references/commands.md +8 -2
- package/cli/index.js +1 -0
- package/core/accessors.js +48 -1
- package/core/account.js +4 -0
- package/core/analysis.js +224 -10
- package/core/ast-analysis.js +96 -0
- package/core/bridge.js +11 -6
- package/core/cache.js +2 -1
- package/core/callers.js +21 -2
- package/core/check.js +35 -6
- package/core/command-contracts.js +1 -1
- package/core/execute.js +22 -3
- package/core/output/analysis-ext.js +26 -2
- package/core/output/analysis.js +11 -9
- package/core/output/check.js +6 -2
- package/core/output/endpoints.js +10 -7
- package/core/output/lines.js +25 -3
- package/core/output/public.js +13 -4
- package/core/output/refactoring.js +4 -1
- package/core/output/reporting.js +1 -1
- package/core/output-budget.js +8 -6
- package/core/project.js +5 -0
- package/core/registry.js +1 -1
- package/core/search.js +9 -1
- package/core/shared.js +13 -4
- package/core/verify.js +29 -6
- package/languages/index.js +4 -0
- package/languages/python.js +8 -1
- 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,15 +142,18 @@ 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
|
|
144
151
|
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
|
-
`grep -v '# unverified'` for the confirmed tier
|
|
147
|
-
|
|
153
|
+
`grep -v '# unverified'` for the confirmed tier. To count distinct source lines
|
|
154
|
+
per file, use `cut -d: -f1,2 | sort -u | cut -d: -f1 | sort | uniq -c`;
|
|
155
|
+
JSON usage records can count a source line more than once.
|
|
156
|
+
Nothing to list prints nothing and exits 1, grep's
|
|
148
157
|
contract; errors exit 2. Explicit `--top`/`--limit` still apply and disclose
|
|
149
158
|
omissions. `show --lines` accepts only callers/callees sections; target-less
|
|
150
159
|
`impact --lines` lists Git-diff callers with per-target accounting. Closing a
|
|
@@ -189,6 +198,9 @@ target token); Java `pkg.Type.method()` and C# `Ns.Type.Method()` /
|
|
|
189
198
|
same-name types exist. A qualifier the resolver cannot place stays visible as
|
|
190
199
|
`method-ambiguous`, never confirmed by first-definition order.
|
|
191
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
|
+
|
|
192
204
|
`endpoints` recognizes client receivers by evidence (a receiver typed to an
|
|
193
205
|
HTTP client class, or bound to a pytest fixture that constructs one), not only
|
|
194
206
|
by name. Request-shaped calls with a path literal on an unrecognized receiver
|
|
@@ -208,6 +220,30 @@ The selection note discloses that approximation. `find`, text `search`,
|
|
|
208
220
|
(structural `search`: 50). Use an explicit `--limit=N` to request more;
|
|
209
221
|
`usages` and `--lines` have no default row cap.
|
|
210
222
|
|
|
223
|
+
Automatic test filtering follows the language's conventions: Python `spec.py`
|
|
224
|
+
and `chart_spec.py` are production paths; `test_*.py`, `*_test.py`, and test
|
|
225
|
+
directories remain test paths. Structural search discloses hidden test files,
|
|
226
|
+
including empty results; `--include-tests` gives the full indexed inventory.
|
|
227
|
+
Explicit `--exclude=spec` still means the requested path exclusion.
|
|
228
|
+
|
|
229
|
+
Public JSON source `file` fields are relative to `meta.pathBase` (the absolute
|
|
230
|
+
project root). Dependency edge paths use the same base. Both absolute and
|
|
231
|
+
relative indexed-file handles are accepted. Definition handles retain their
|
|
232
|
+
decorator span; use `nameLine` for the name token rather than joining usages
|
|
233
|
+
to a handle's start line.
|
|
234
|
+
|
|
235
|
+
Callable references passed to another function remain visible when a project
|
|
236
|
+
method or function could be their target. An ordinary attribute read with no
|
|
237
|
+
callable member candidate stays a non-call reference in ACCOUNT and `usages`.
|
|
238
|
+
The caller model includes callback dependencies; it does not prove that the
|
|
239
|
+
receiving function invokes every passed callable.
|
|
240
|
+
|
|
241
|
+
`audit-async` checks recognized async producers. In JS/TS/HTML it also checks
|
|
242
|
+
captured promises used in arithmetic, conditions, or resolved-value member
|
|
243
|
+
access within the same lexical scope. Awaiting, returning, promise handlers,
|
|
244
|
+
reassignment, and shadowed bindings are distinguished. It is a bounded AST
|
|
245
|
+
audit, not a compiler-wide proof that every missing await has been found.
|
|
246
|
+
|
|
211
247
|
For `plan --rename-to`, the selected declaration is only the starting point.
|
|
212
248
|
When the index proves the relationship, the rename unit closes over
|
|
213
249
|
overload/signature groups, base and override declarations, Rust trait slots,
|
|
@@ -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,7 +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
|
+
|
|
60
|
+
Public JSON source paths (`file`, caller files, dependency roots and edges) are project-relative, with the absolute base in `meta.pathBase`. Project roots and external paths remain absolute. Indexed absolute handles are accepted as well as relative handles.
|
|
61
|
+
|
|
62
|
+
Default test exclusions follow language conventions. Python `spec.py` and `*_spec.py` are included; `test_*.py`, `*_test.py`, and test directories are excluded. Structural search reports hidden test-file counts, including on empty results. `--include-tests` disables these defaults; explicit `--exclude` patterns still apply.
|
|
63
|
+
|
|
64
|
+
`audit-async` checks recognized async producers, including 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.
|
|
59
65
|
|
|
60
66
|
## Common flags
|
|
61
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/account.js
CHANGED
|
@@ -301,6 +301,10 @@ function classifyGroundLines(index, name, groundSet, claimedKeys) {
|
|
|
301
301
|
const callLines = new Set();
|
|
302
302
|
if (Array.isArray(cachedCalls)) {
|
|
303
303
|
for (const c of cachedCalls) {
|
|
304
|
+
// Unclaimed callback/type references have no invocation
|
|
305
|
+
// syntax. Let the usage AST classify them as references;
|
|
306
|
+
// merely entering the candidate cache is not a call fact.
|
|
307
|
+
if (c.isFunctionReference || c.isTypeReference) continue;
|
|
304
308
|
if (c.name === name || c.resolvedName === name ||
|
|
305
309
|
(c.resolvedNames && c.resolvedNames.includes(name))) {
|
|
306
310
|
callLines.add(c.line);
|
package/core/analysis.js
CHANGED
|
@@ -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
|
}
|
|
@@ -2656,6 +2780,78 @@ const _ASYNCIO_CONSUMER_FNS = new Set([
|
|
|
2656
2780
|
'gather', 'create_task', 'ensure_future', 'wait', 'as_completed',
|
|
2657
2781
|
]);
|
|
2658
2782
|
|
|
2783
|
+
// Follow a captured JS promise within its lexical scope, looking only for
|
|
2784
|
+
// operations that require its resolved value. Passing/returning/awaiting the
|
|
2785
|
+
// promise and its own methods are valid. Reassignment stops the inference;
|
|
2786
|
+
// nested functions and shadowing blocks cannot borrow the outer binding.
|
|
2787
|
+
function storedPromiseMisuse(call, functionNodes) {
|
|
2788
|
+
const { sameNode } = require('../languages/utils');
|
|
2789
|
+
let value = call;
|
|
2790
|
+
while (value.parent?.type === 'parenthesized_expression') value = value.parent;
|
|
2791
|
+
const assignment = value.parent;
|
|
2792
|
+
if (!assignment || !['variable_declarator', 'assignment_expression'].includes(assignment.type)) return null;
|
|
2793
|
+
const binding = assignment.childForFieldName(assignment.type === 'variable_declarator' ? 'name' : 'left');
|
|
2794
|
+
if (binding?.type !== 'identifier') return null;
|
|
2795
|
+
let scope = assignment.parent;
|
|
2796
|
+
while (scope && scope.type !== 'statement_block' && !functionNodes.has(scope.type)) scope = scope.parent;
|
|
2797
|
+
if (!scope) return null;
|
|
2798
|
+
const name = binding.text;
|
|
2799
|
+
const promiseMembers = new Set(['then', 'catch', 'finally', 'constructor',
|
|
2800
|
+
'toString', 'toLocaleString', 'valueOf', 'hasOwnProperty', 'isPrototypeOf', 'propertyIsEnumerable']);
|
|
2801
|
+
let stopped = false;
|
|
2802
|
+
let misuse = null;
|
|
2803
|
+
const namesBinding = node => !!node && ((['identifier', 'shorthand_property_identifier_pattern'].includes(node.type) && node.text === name) ||
|
|
2804
|
+
node.namedChildren.some(namesBinding));
|
|
2805
|
+
const shadows = block => (block.namedChildren || []).some(statement =>
|
|
2806
|
+
['lexical_declaration', 'variable_declaration'].includes(statement.type) &&
|
|
2807
|
+
statement.namedChildren.some(decl => namesBinding(decl.childForFieldName('name'))));
|
|
2808
|
+
const visit = node => {
|
|
2809
|
+
if (stopped || misuse || node.endIndex <= call.endIndex) return;
|
|
2810
|
+
if (functionNodes.has(node.type)) return;
|
|
2811
|
+
if (!sameNode(node, scope) && node.type === 'statement_block' && shadows(node)) return;
|
|
2812
|
+
if (node.type === 'catch_clause' && namesBinding(node.childForFieldName('parameter'))) return;
|
|
2813
|
+
if (node.type === 'for_statement' && shadows(node)) return;
|
|
2814
|
+
if (node.type === 'for_in_statement' && namesBinding(node.childForFieldName('left'))) {
|
|
2815
|
+
// A declared loop variable shadows; an undeclared one overwrites
|
|
2816
|
+
// the promise, so later uses cannot inherit its earlier type.
|
|
2817
|
+
if (!node.children.some(child => ['let', 'const'].includes(child.type))) stopped = true;
|
|
2818
|
+
return;
|
|
2819
|
+
}
|
|
2820
|
+
if (node.type === 'assignment_expression' && !sameNode(node, assignment) && node.childForFieldName('left')?.text === name) {
|
|
2821
|
+
const right = node.childForFieldName('right');
|
|
2822
|
+
if (right) visit(right);
|
|
2823
|
+
stopped = true;
|
|
2824
|
+
return;
|
|
2825
|
+
}
|
|
2826
|
+
if (node.type === 'identifier' && node.text === name && node.startIndex >= call.endIndex) {
|
|
2827
|
+
let use = node;
|
|
2828
|
+
while (use.parent?.type === 'parenthesized_expression') use = use.parent;
|
|
2829
|
+
const parent = use.parent;
|
|
2830
|
+
if (parent?.type === 'member_expression' && sameNode(parent.childForFieldName('object'), use)) {
|
|
2831
|
+
const property = parent.childForFieldName('property');
|
|
2832
|
+
if (property && !promiseMembers.has(property.text)) misuse = node;
|
|
2833
|
+
} else if (parent?.type === 'subscript_expression' && sameNode(parent.childForFieldName('object'), use)) {
|
|
2834
|
+
misuse = node;
|
|
2835
|
+
} else if (parent?.type === 'binary_expression') {
|
|
2836
|
+
const operator = parent.childForFieldName('operator')?.text;
|
|
2837
|
+
if (['+', '-', '*', '/', '%', '**', '<', '>', '<=', '>=', '|', '&', '^', '<<', '>>', '>>>'].includes(operator)) misuse = node;
|
|
2838
|
+
} else if (parent?.type === 'unary_expression' && ['+', '-', '~'].includes(parent.childForFieldName('operator')?.text)) {
|
|
2839
|
+
misuse = node;
|
|
2840
|
+
} else if (parent && ['update_expression', 'augmented_assignment_expression'].includes(parent.type)) {
|
|
2841
|
+
misuse = node;
|
|
2842
|
+
} else if (parent && ['if_statement', 'while_statement', 'do_statement', 'ternary_expression'].includes(parent.type) &&
|
|
2843
|
+
sameNode(parent.childForFieldName('condition'), use)) {
|
|
2844
|
+
misuse = node;
|
|
2845
|
+
}
|
|
2846
|
+
}
|
|
2847
|
+
for (const child of node.namedChildren || []) visit(child);
|
|
2848
|
+
};
|
|
2849
|
+
// A function root's body is the scan scope, never its parameters.
|
|
2850
|
+
visit(scope.type === 'statement_block' ? scope : scope.childForFieldName('body') || scope);
|
|
2851
|
+
return misuse ? { line: misuse.startPosition.row + 1, variable: name,
|
|
2852
|
+
originLine: call.startPosition.row + 1, reason: 'stored-promise-used-as-value' } : null;
|
|
2853
|
+
}
|
|
2854
|
+
|
|
2659
2855
|
/**
|
|
2660
2856
|
* Run an async/await audit across the project.
|
|
2661
2857
|
*
|
|
@@ -2824,7 +3020,7 @@ function auditAsync(index, options = {}) {
|
|
|
2824
3020
|
}
|
|
2825
3021
|
}
|
|
2826
3022
|
}
|
|
2827
|
-
if (asyncFns.length === 0) return;
|
|
3023
|
+
if (asyncFns.length === 0 && language !== 'python') return;
|
|
2828
3024
|
|
|
2829
3025
|
// Re-parse file to find awaited-vs-not call sites. We use a fresh
|
|
2830
3026
|
// parse rather than tree cache because we want to walk every
|
|
@@ -2893,6 +3089,12 @@ function auditAsync(index, options = {}) {
|
|
|
2893
3089
|
// immediate enclosing fn-node is async (so callbacks inside an
|
|
2894
3090
|
// async fn aren't misclassified as async themselves).
|
|
2895
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';
|
|
2896
3098
|
let cur = callNode.parent;
|
|
2897
3099
|
while (cur) {
|
|
2898
3100
|
if (FN_NODE_TYPES.has(cur.type)) {
|
|
@@ -2909,11 +3111,16 @@ function auditAsync(index, options = {}) {
|
|
|
2909
3111
|
endLine: cur.endPosition.row + 1,
|
|
2910
3112
|
};
|
|
2911
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
|
+
};
|
|
2912
3119
|
return null; // Inner non-async fn — stop, don't leak into outer scope.
|
|
2913
3120
|
}
|
|
2914
3121
|
cur = cur.parent;
|
|
2915
3122
|
}
|
|
2916
|
-
return null;
|
|
3123
|
+
return discardedPython ? { name: '<module>' } : null;
|
|
2917
3124
|
}
|
|
2918
3125
|
|
|
2919
3126
|
function visit(node) {
|
|
@@ -3012,6 +3219,10 @@ function auditAsync(index, options = {}) {
|
|
|
3012
3219
|
let current = node.parent;
|
|
3013
3220
|
let awaitDepth = 0;
|
|
3014
3221
|
while (current && awaitDepth++ < 5) {
|
|
3222
|
+
if (current.type === 'parenthesized_expression') {
|
|
3223
|
+
current = current.parent;
|
|
3224
|
+
continue;
|
|
3225
|
+
}
|
|
3015
3226
|
if (current.type === 'await_expression' ||
|
|
3016
3227
|
current.type === 'await') {
|
|
3017
3228
|
awaited = true;
|
|
@@ -3034,12 +3245,15 @@ function auditAsync(index, options = {}) {
|
|
|
3034
3245
|
}
|
|
3035
3246
|
break;
|
|
3036
3247
|
}
|
|
3037
|
-
|
|
3248
|
+
const storedMisuse = !awaited && langTraits(language)?.storedPromises
|
|
3249
|
+
? storedPromiseMisuse(node, FN_NODE_TYPES) : null;
|
|
3250
|
+
if (storedMisuse || (!awaited && !isFireAndForget(node, language))) {
|
|
3038
3251
|
issues.push({
|
|
3039
3252
|
file: fileEntry.relativePath || filePath,
|
|
3040
3253
|
line,
|
|
3041
3254
|
callerName: enclosing.name,
|
|
3042
3255
|
calleeName,
|
|
3256
|
+
...(storedMisuse || {}),
|
|
3043
3257
|
});
|
|
3044
3258
|
}
|
|
3045
3259
|
}
|