ucn 5.2.0 → 5.2.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 +17 -3
- package/.claude/skills/ucn/references/commands.md +4 -4
- package/README.md +17 -5
- package/cli/index.js +3 -0
- package/core/accessors.js +183 -0
- package/core/account.js +36 -8
- package/core/analysis.js +46 -0
- package/core/ast-analysis.js +104 -0
- package/core/cache.js +150 -9
- package/core/callers.js +1196 -122
- package/core/command-contracts.js +13 -13
- package/core/deadcode.js +41 -2
- package/core/execute.js +4 -1
- package/core/graph.js +72 -5
- package/core/index-ir.js +15 -3
- package/core/ir.js +58 -9
- package/core/output/analysis.js +30 -1
- package/core/output/graph.js +28 -8
- package/core/output/public.js +4 -0
- package/core/output/refactoring.js +31 -2
- package/core/output/reporting.js +7 -0
- package/core/project.js +88 -3
- package/core/verify.js +85 -3
- package/languages/c-family.js +27 -34
- package/languages/csharp.js +26 -1
- package/languages/go.js +170 -42
- package/languages/javascript.js +258 -10
- package/languages/python.js +591 -32
- package/languages/rust.js +1 -0
- package/package.json +1 -1
|
@@ -59,6 +59,12 @@ For caller-bearing `show`, `impact`, `trace`, `tests`, and `check` views:
|
|
|
59
59
|
- `WARNING` identifies unreadable, unparsed, or partially indexed files, and lists unsupported-language occurrence sites (file:line plus the line text) so nothing grep would show is hidden. `usages` and `tests` carry the same disclosure as a note.
|
|
60
60
|
- `FILTERED` means query options hid evidence.
|
|
61
61
|
|
|
62
|
+
When the selected definition is a property/getter/setter, `impact` adds a
|
|
63
|
+
separate `PROPERTY ACCESS SITES` band. Confirmed reads/writes have receiver
|
|
64
|
+
identity; matching attribute syntax with an unresolved receiver stays
|
|
65
|
+
unverified. These are change dependencies, not fabricated caller edges, so
|
|
66
|
+
the caller `ACCOUNT` remains a call-shaped partition.
|
|
67
|
+
|
|
62
68
|
An observed-text zero is not semantic zero or safe-delete proof. Numeric evidence values are ordinal ranking weights, not probabilities.
|
|
63
69
|
|
|
64
70
|
When a plain name selects more than one definition, action-oriented commands
|
|
@@ -101,7 +107,7 @@ definition as an implicit repository-wide target.
|
|
|
101
107
|
|
|
102
108
|
## Deletion protocol
|
|
103
109
|
|
|
104
|
-
Treat `deadcode` as a candidate generator. Before deletion, inspect `usages`, `impact`, `entrypoints`, `api`, and `repo --sections=health --deep`; then corroborate with the compiler/type checker and tests. Computed dispatch such as `handlers[key]()` is a reported blind spot; registry members reached by a modeled computed receiver are withheld. Unknown decorators/annotations and member-assigned event handlers are also withheld by default because they can be the registration itself. All remaining candidates are still review-only. Never delete solely from `deadcode` or an observed-text-zero result.
|
|
110
|
+
Treat `deadcode` as a candidate generator. Before deletion, inspect `usages`, `impact`, `entrypoints`, `api`, and `repo --sections=health --deep`; then corroborate with the compiler/type checker and tests. Computed dispatch such as `handlers[key]()` is a reported blind spot; registry members reached by a modeled computed receiver are withheld. Statically named reflection such as `getattr(obj, "run")` is positive liveness evidence, so every matching member spelling is withheld; recognized dynamic reflection is counted and warned because it cannot be attributed to one member. Unknown decorators/annotations and member-assigned event handlers are also withheld by default because they can be the registration itself. All remaining candidates are still review-only. Never delete solely from `deadcode` or an observed-text-zero result.
|
|
105
111
|
|
|
106
112
|
`usages` includes comment/string/docstring occurrences in an `OTHER TEXT` section unless
|
|
107
113
|
`--code-only` is set. This is a literal-name inventory, not exact target
|
|
@@ -122,8 +128,16 @@ imports/exports, Python `__all__` strings, and module-attribute references.
|
|
|
122
128
|
Exact token/expression spans keep a foreign same-named occurrence on the same
|
|
123
129
|
line unchanged. Open external interfaces, incomplete ownership, unresolved
|
|
124
130
|
dispatch, or an inexact token route to `needsReview` instead of a synthesized
|
|
125
|
-
edit.
|
|
126
|
-
|
|
131
|
+
edit. Comment/string occurrences in indexed source appear in `reviewItems` and
|
|
132
|
+
are never rewritten automatically; the result also tells you to search
|
|
133
|
+
documentation, configuration, generated files, and unsupported languages for
|
|
134
|
+
the old spelling. Read `changeSummary` and every review item. `plan` previews
|
|
135
|
+
only: it does not modify files, run a compiler, or prove runtime compatibility.
|
|
136
|
+
|
|
137
|
+
For `deps --cycles`, `eager` means every edge executes at module scope.
|
|
138
|
+
`deferred` means at least one Python edge is function-local, so the chain is
|
|
139
|
+
not an unconditional import-time cycle; it remains visible because invoking
|
|
140
|
+
that function during initialization can still matter.
|
|
127
141
|
|
|
128
142
|
## Efficient use
|
|
129
143
|
|
|
@@ -22,17 +22,17 @@ structural or code-only search.
|
|
|
22
22
|
|
|
23
23
|
| Command | Purpose |
|
|
24
24
|
|---|---|
|
|
25
|
-
| `impact [handle]` | Show direct symbol impact when given a handle;
|
|
25
|
+
| `impact [handle]` | Show direct symbol impact when given a handle; accessor targets include receiver-tiered property reads/writes as a separate dependency band. Without a handle, analyze the Git diff. |
|
|
26
26
|
| `tests <handle\|file>` | Find statically linked direct tests. Set `--depth=N` for transitive affected tests. Empty results are not runtime coverage proof. |
|
|
27
27
|
| `check [handle]` | Validate confirmed call-site arity for a symbol; without one, run the composed pre-commit diagnostic. |
|
|
28
|
-
| `plan <handle>` | Preview `--rename-to`, `--add-param`, or `--remove-param` edits. For renames, the proven closure can include overload/signature groups, inheritance/trait/Go-interface slots, exact call/reference tokens, imports/exports, Python `__all__` strings, and module-attribute references. Open ownership or method sets stay `needsReview`; the command never applies or compiles edits. |
|
|
28
|
+
| `plan <handle>` | Preview `--rename-to`, `--add-param`, or `--remove-param` edits. For renames, the proven closure can include overload/signature groups, inheritance/trait/Go-interface slots, accessor reads/writes, exact call/reference tokens, imports/exports, Python `__all__` strings, and module-attribute references. Source comments/strings are explicit `reviewItems`; non-source files get a search handoff. Open ownership or method sets stay `needsReview`; the command never applies or compiles edits. |
|
|
29
29
|
|
|
30
30
|
## Repository and architecture
|
|
31
31
|
|
|
32
32
|
| Command | Purpose |
|
|
33
33
|
|---|---|
|
|
34
34
|
| `repo` | Repository orientation. Select `summary,files,stats,health` with `--sections`; `--deep` includes readiness evidence. Skipped unsupported source is listed with a grep/language-tool handoff. |
|
|
35
|
-
| `deps <file>` | File dependency graph. Use `--direction=imports\|importers\|both`, `--detailed`, or `--cycles`. |
|
|
35
|
+
| `deps <file>` | File dependency graph. Use `--direction=imports\|importers\|both`, `--detailed`, or `--cycles`. Python cycles are classified as eager or function-local/deferred without dropping either kind. |
|
|
36
36
|
| `api [file]` | Static exported/public surface for a project or file. |
|
|
37
37
|
| `entrypoints` | Framework, route, task, test, and runtime entry points. |
|
|
38
38
|
| `endpoints` | Server/client HTTP surface; `--bridge` adds advisory matching. |
|
|
@@ -41,7 +41,7 @@ structural or code-only search.
|
|
|
41
41
|
|
|
42
42
|
| Command | Purpose |
|
|
43
43
|
|---|---|
|
|
44
|
-
| `deadcode` | Conservative unreferenced-symbol candidates for review.
|
|
44
|
+
| `deadcode` | Conservative unreferenced-symbol candidates for review. Statically named reflection targets, modeled computed-dispatch members, unknown decorated/annotated callables, and member-assigned event handlers are withheld by default. Recognized dynamic reflection is counted and warned because it cannot be attributed. |
|
|
45
45
|
| `audit-async` | Potential missing-await sites in JavaScript/TypeScript/Python and C#. MCP spelling: `audit_async`. |
|
|
46
46
|
| `stacktrace <text>` | Advisory stack-frame parsing and source lookup. |
|
|
47
47
|
|
package/README.md
CHANGED
|
@@ -287,7 +287,7 @@ SIGNATURE CHANGE:
|
|
|
287
287
|
|
|
288
288
|
CHANGES NEEDED: 12
|
|
289
289
|
Files affected: 5
|
|
290
|
-
Definition 1, calls
|
|
290
|
+
Definition 1, calls 7, references 0, text dependencies 0, imports 4, exports 0; manual review items 0
|
|
291
291
|
|
|
292
292
|
BY FILE:
|
|
293
293
|
|
|
@@ -306,12 +306,16 @@ For a rename, `plan` closes the change over every relationship the index can
|
|
|
306
306
|
prove: overload/signature groups, base and override declarations, Rust trait
|
|
307
307
|
slots, Go interface slots and their satisfiers, exact call and value-reference
|
|
308
308
|
tokens, imports/exports, Python `__all__` strings, and module-attribute
|
|
309
|
-
references.
|
|
310
|
-
|
|
309
|
+
references. Accessor renames also follow receiver-proven property reads and
|
|
310
|
+
writes. It edits exact token or expression spans, so another same-named call
|
|
311
|
+
on the same line is not swept up accidentally.
|
|
311
312
|
|
|
312
313
|
Open external interfaces, incomplete ownership, unresolved dispatch, or an
|
|
313
314
|
inexact token are marked `needsReview` instead of receiving a synthesized
|
|
314
|
-
edit.
|
|
315
|
+
edit. Comments and strings in indexed source appear as separate review items
|
|
316
|
+
and are never rewritten automatically; documentation, configuration,
|
|
317
|
+
generated files, and unsupported languages get an explicit exact-text search
|
|
318
|
+
handoff. `plan` previews changes; it does not modify files or replace the
|
|
315
319
|
compiler and test suite. Before committing, point the same machinery at your
|
|
316
320
|
Git diff:
|
|
317
321
|
|
|
@@ -408,7 +412,10 @@ Three claims, and every one is re-checked against rust-analyzer in CI: a
|
|
|
408
412
|
default-audit claim with an oracle-visible reference fails the build. Notice
|
|
409
413
|
what it *didn't* claim: exported API that external code may call,
|
|
410
414
|
framework-registered symbols, and anything whose name appears in files UCN
|
|
411
|
-
couldn't parse.
|
|
415
|
+
couldn't parse. A literal reflection target such as `getattr(obj, "run")`
|
|
416
|
+
also withholds matching member names from deletion candidates; recognized
|
|
417
|
+
dynamic reflection is counted and warned because it cannot be attributed.
|
|
418
|
+
`deadcode` is deliberately a candidate generator. Before
|
|
412
419
|
deleting, corroborate with `usages`, `impact`, `api`, and your compiler and
|
|
413
420
|
tests.
|
|
414
421
|
|
|
@@ -426,6 +433,11 @@ ucn entrypoints --type=http # runtime and framework roots
|
|
|
426
433
|
ucn endpoints --bridge --unmatched # server routes with no client, and vice versa
|
|
427
434
|
```
|
|
428
435
|
|
|
436
|
+
Cycle output separates eager import-time loops from Python chains containing
|
|
437
|
+
a function-local/deferred edge. Deferred chains stay visible—they are not
|
|
438
|
+
unconditional import-time cycles, but can still fail if invoked while modules
|
|
439
|
+
are initializing.
|
|
440
|
+
|
|
429
441
|
`endpoints --bridge` matches server routes to client requests across
|
|
430
442
|
languages: Express/Fastify/Koa/NestJS/Next.js, Flask/FastAPI, Spring/JAX-RS,
|
|
431
443
|
Go net/http (Gin/Echo/Chi/Fiber), axum/actix-web, and ASP.NET on the server
|
package/cli/index.js
CHANGED
|
@@ -760,6 +760,9 @@ function runProjectCommand(rootDir, command, arg) {
|
|
|
760
760
|
if (flags.cache && (needsCacheSave || index.callsCacheDirty || index.reachabilityDirty || index.computedDispatchDirty)) {
|
|
761
761
|
try { index.saveCache(); } catch (e) { /* best-effort */ }
|
|
762
762
|
}
|
|
763
|
+
if (flags.cache && index.usageCacheDirty) {
|
|
764
|
+
try { index.saveUsageCache(); } catch (e) { /* best-effort */ }
|
|
765
|
+
}
|
|
763
766
|
}
|
|
764
767
|
}
|
|
765
768
|
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { codeUnitCompare } = require('./shared');
|
|
5
|
+
|
|
6
|
+
// Descriptors/properties are consumed through reads and writes, not only
|
|
7
|
+
// call syntax. Keep this vocabulary shared by impact and refactoring so the
|
|
8
|
+
// two commands cannot disagree about whether a selected symbol is an accessor.
|
|
9
|
+
const ACCESSOR_KINDS = new Set([
|
|
10
|
+
'property', 'setter', 'deleter', 'get', 'set',
|
|
11
|
+
'static get', 'static set', 'override get', 'override set',
|
|
12
|
+
'static override get', 'static override set',
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
function isAccessorDefinition(definition) {
|
|
16
|
+
return !!definition && (ACCESSOR_KINDS.has(definition.type) ||
|
|
17
|
+
ACCESSOR_KINDS.has(definition.memberType));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function ownerName(definition) {
|
|
21
|
+
if (definition.className) return definition.className;
|
|
22
|
+
if (!definition.receiver) return null;
|
|
23
|
+
return String(definition.receiver)
|
|
24
|
+
.replace(/^[*&]\s*/, '')
|
|
25
|
+
.replace(/^mut\s+/, '')
|
|
26
|
+
.replace(/<.*$/, '')
|
|
27
|
+
.trim() || null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function bareTypeName(value) {
|
|
31
|
+
if (!value) return null;
|
|
32
|
+
return String(value)
|
|
33
|
+
.replace(/^(?:typing\.)?(?:Optional|Annotated|Final)\s*\[/, '')
|
|
34
|
+
.replace(/[<[(].*$/s, '')
|
|
35
|
+
.split(/\.|::/).pop()
|
|
36
|
+
.replace(/[?*&\s]/g, '') || null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function typeMatchesOwner(index, typeName, contextFile, owner, ownerFile) {
|
|
40
|
+
const bare = bareTypeName(typeName);
|
|
41
|
+
if (!bare || !owner || bare !== owner) return false;
|
|
42
|
+
const resolved = index._resolveClassFile?.(bare, contextFile);
|
|
43
|
+
if (resolved) return path.resolve(resolved) === path.resolve(ownerFile);
|
|
44
|
+
const ownerDefs = (index.symbols.get(owner) || []).filter(symbol =>
|
|
45
|
+
['class', 'struct', 'interface', 'trait', 'record'].includes(symbol.type));
|
|
46
|
+
return ownerDefs.length === 1 &&
|
|
47
|
+
path.resolve(ownerDefs[0].file) === path.resolve(ownerFile);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function insideSelectedAccessorBody(index, name, definition, file, line) {
|
|
51
|
+
return (index.symbols.get(name) || []).some(candidate =>
|
|
52
|
+
isAccessorDefinition(candidate) &&
|
|
53
|
+
candidate.file === definition.file &&
|
|
54
|
+
candidate.className === definition.className &&
|
|
55
|
+
file === candidate.file &&
|
|
56
|
+
line >= candidate.startLine &&
|
|
57
|
+
line <= (candidate.endLine || candidate.startLine));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Find reads/writes that may consume a selected accessor. Confirmed entries
|
|
62
|
+
* require receiver identity (`self`/`this` in the owner or a Python instance
|
|
63
|
+
* field whose constructor assignment proves the owner type). Everything else
|
|
64
|
+
* remains visible in the unverified tier; accessor identity is never guessed
|
|
65
|
+
* from the spelling alone.
|
|
66
|
+
*/
|
|
67
|
+
function findAccessorReferences(index, name, definition, options = {}) {
|
|
68
|
+
if (!isAccessorDefinition(definition)) return null;
|
|
69
|
+
const owner = ownerName(definition);
|
|
70
|
+
if (!owner) return null;
|
|
71
|
+
|
|
72
|
+
const confirmed = [];
|
|
73
|
+
const unverified = [];
|
|
74
|
+
const excluded = [];
|
|
75
|
+
// Use the parser's raw occurrence records rather than usages()' public
|
|
76
|
+
// line-oriented inventory. A line can contain two same-spelled member
|
|
77
|
+
// accesses with different receivers; collapsing them by file+line would
|
|
78
|
+
// make a rename edit one token while silently losing the other.
|
|
79
|
+
const refs = [];
|
|
80
|
+
for (const [file, entry] of index.files) {
|
|
81
|
+
if (!index.matchesFilters(entry.relativePath, options)) continue;
|
|
82
|
+
let content;
|
|
83
|
+
let occurrences;
|
|
84
|
+
try {
|
|
85
|
+
content = index._readFile(file);
|
|
86
|
+
if (!content.includes(name)) continue;
|
|
87
|
+
occurrences = index._getCachedUsages(file, name);
|
|
88
|
+
} catch { continue; }
|
|
89
|
+
if (!occurrences) continue;
|
|
90
|
+
const lines = content.split('\n');
|
|
91
|
+
for (const usage of occurrences) {
|
|
92
|
+
if (usage.usageType !== 'reference') continue;
|
|
93
|
+
refs.push({
|
|
94
|
+
...usage,
|
|
95
|
+
file,
|
|
96
|
+
relativePath: entry.relativePath,
|
|
97
|
+
content: lines[usage.line - 1] || '',
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
for (const ref of refs) {
|
|
103
|
+
const rel = ref.relativePath || path.relative(index.root, ref.file);
|
|
104
|
+
const shaped = {
|
|
105
|
+
file: rel,
|
|
106
|
+
absoluteFile: ref.file,
|
|
107
|
+
line: ref.line,
|
|
108
|
+
expression: (ref.content || '').trim(),
|
|
109
|
+
...(Number.isInteger(ref.column) && { column: ref.column }),
|
|
110
|
+
...(ref.receiver && { receiver: ref.receiver }),
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// A backing-store attribute inside the selected getter/setter body
|
|
114
|
+
// often has the same spelling (`self._local.value`). It is not a
|
|
115
|
+
// consumption of the descriptor being queried.
|
|
116
|
+
if (insideSelectedAccessorBody(index, name, definition, ref.file, ref.line) &&
|
|
117
|
+
!['self', 'cls', 'this'].includes(ref.receiver)) {
|
|
118
|
+
excluded.push({ ...shaped, reason: 'accessor-definition-body' });
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const enclosing = index.findEnclosingFunction(ref.file, ref.line, true);
|
|
123
|
+
const receiver = ref.receiver || null;
|
|
124
|
+
if (receiver && ['self', 'cls', 'this'].includes(receiver) &&
|
|
125
|
+
enclosing?.className === owner && enclosing.file === definition.file) {
|
|
126
|
+
confirmed.push({
|
|
127
|
+
...shaped,
|
|
128
|
+
callerName: enclosing.name,
|
|
129
|
+
resolution: 'same-class-accessor',
|
|
130
|
+
tier: 'confirmed',
|
|
131
|
+
});
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
let receiverType = ref.receiverType || null;
|
|
136
|
+
if (!receiverType && receiver && enclosing?.className) {
|
|
137
|
+
receiverType = index.getInstanceAttributeTypes(
|
|
138
|
+
ref.file, enclosing.className)?.get(receiver) || null;
|
|
139
|
+
}
|
|
140
|
+
if (receiverType && typeMatchesOwner(
|
|
141
|
+
index, receiverType, ref.file, owner, definition.file)) {
|
|
142
|
+
confirmed.push({
|
|
143
|
+
...shaped,
|
|
144
|
+
callerName: enclosing?.name || null,
|
|
145
|
+
receiverType,
|
|
146
|
+
resolution: 'receiver-field-type',
|
|
147
|
+
tier: 'confirmed',
|
|
148
|
+
});
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (receiver && typeMatchesOwner(index, receiver, ref.file, owner, definition.file)) {
|
|
152
|
+
confirmed.push({
|
|
153
|
+
...shaped,
|
|
154
|
+
callerName: enclosing?.name || null,
|
|
155
|
+
receiverType: receiver,
|
|
156
|
+
resolution: 'type-qualified-accessor',
|
|
157
|
+
tier: 'confirmed',
|
|
158
|
+
});
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
unverified.push({
|
|
163
|
+
...shaped,
|
|
164
|
+
callerName: enclosing?.name || null,
|
|
165
|
+
...(receiverType && { receiverType }),
|
|
166
|
+
reason: receiverType ? 'receiver-type-mismatch' :
|
|
167
|
+
receiver ? 'receiver-type-unresolved' : 'nested-receiver-unresolved',
|
|
168
|
+
tier: 'unverified',
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const sort = (a, b) => codeUnitCompare(a.file, b.file) || a.line - b.line;
|
|
173
|
+
confirmed.sort(sort);
|
|
174
|
+
unverified.sort(sort);
|
|
175
|
+
excluded.sort(sort);
|
|
176
|
+
return { owner, confirmed, unverified, excluded };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
module.exports = {
|
|
180
|
+
ACCESSOR_KINDS,
|
|
181
|
+
isAccessorDefinition,
|
|
182
|
+
findAccessorReferences,
|
|
183
|
+
};
|
package/core/account.js
CHANGED
|
@@ -36,14 +36,16 @@
|
|
|
36
36
|
* Ground-set semantics are grep `-n -w`: unit is the (file, line) pair, each
|
|
37
37
|
* line with >= 1 word-boundary match counts once, case-sensitive.
|
|
38
38
|
*
|
|
39
|
-
* Performance: the ground scan is one `includes()`-gated read per project
|
|
40
|
-
*
|
|
41
|
-
*
|
|
39
|
+
* Performance: the first ground scan is one `includes()`-gated read per project
|
|
40
|
+
* file — the same I/O profile as the existing `search`/`usages` commands.
|
|
41
|
+
* Exact results are retained in a small LRU for the lifetime of one built index
|
|
42
|
+
* so context/about/impact projections of the same symbol do not rescan the
|
|
43
|
+
* project. Deriving counts from callsCache (zero reads) was rejected because
|
|
42
44
|
* comments/strings/references are not in the calls cache and the contract's
|
|
43
45
|
* ground set is text-defined. AST parsing (the expensive part) is restricted
|
|
44
|
-
* to files containing UNCLAIMED ground lines, via the
|
|
45
|
-
* `index._getCachedUsages`.
|
|
46
|
-
*
|
|
46
|
+
* to files containing UNCLAIMED ground lines, via the content-hash-keyed
|
|
47
|
+
* `index._getCachedUsages`. Build invalidation and cache bounds preserve the
|
|
48
|
+
* same answer without retaining an unbounded repository mirror.
|
|
47
49
|
*/
|
|
48
50
|
|
|
49
51
|
'use strict';
|
|
@@ -75,6 +77,12 @@ const UNSUPPORTED_SITE_TEXT_MAX = 160;
|
|
|
75
77
|
* }}
|
|
76
78
|
*/
|
|
77
79
|
function computeGroundSet(index, name) {
|
|
80
|
+
if (index._groundSetCache?.has(name)) {
|
|
81
|
+
const cached = index._groundSetCache.get(name);
|
|
82
|
+
index._groundSetCache.delete(name);
|
|
83
|
+
index._groundSetCache.set(name, cached);
|
|
84
|
+
return cached.result;
|
|
85
|
+
}
|
|
78
86
|
const wordRe = new RegExp('\\b' + escapeRegExp(name) + '\\b');
|
|
79
87
|
const perFile = new Map();
|
|
80
88
|
let total = 0;
|
|
@@ -117,7 +125,7 @@ function computeGroundSet(index, name) {
|
|
|
117
125
|
? index.discoveryIssues.map(issue => ({ ...issue })) : [];
|
|
118
126
|
unreadableFiles.sort();
|
|
119
127
|
|
|
120
|
-
|
|
128
|
+
const result = {
|
|
121
129
|
total: total + unparsed.lines + unsupported.lines,
|
|
122
130
|
fileCount: fileCount + unparsed.fileCount + unsupported.fileCount,
|
|
123
131
|
perFile,
|
|
@@ -126,6 +134,21 @@ function computeGroundSet(index, name) {
|
|
|
126
134
|
unreadableFiles,
|
|
127
135
|
skippedSources,
|
|
128
136
|
};
|
|
137
|
+
if (index._groundSetCache) {
|
|
138
|
+
const weight = result.total + result.fileCount;
|
|
139
|
+
index._groundSetCache.set(name, { result, weight });
|
|
140
|
+
index._groundSetCacheLines = (index._groundSetCacheLines || 0) + weight;
|
|
141
|
+
const maxNames = 64;
|
|
142
|
+
const maxLines = 100000;
|
|
143
|
+
while (index._groundSetCache.size > maxNames ||
|
|
144
|
+
index._groundSetCacheLines > maxLines) {
|
|
145
|
+
const oldest = index._groundSetCache.entries().next().value;
|
|
146
|
+
if (!oldest) break;
|
|
147
|
+
index._groundSetCache.delete(oldest[0]);
|
|
148
|
+
index._groundSetCacheLines -= oldest[1].weight;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return result;
|
|
129
152
|
}
|
|
130
153
|
|
|
131
154
|
/** Scan only files the parser/index could not ingest. */
|
|
@@ -300,7 +323,12 @@ function classifyGroundLines(index, name, groundSet, claimedKeys) {
|
|
|
300
323
|
|
|
301
324
|
// Remainder: AST usage scan distinguishes import/definition/reference
|
|
302
325
|
// from comment/string/skipped-token lines.
|
|
303
|
-
|
|
326
|
+
// Call lines were classified from the complete calls cache above.
|
|
327
|
+
// Language adapters may skip usage-only call recovery here; notably,
|
|
328
|
+
// C/C++ avoids reparsing every matching macro replacement list.
|
|
329
|
+
const usages = index._getCachedUsages(filePath, name, {
|
|
330
|
+
skipCallRecovery: true,
|
|
331
|
+
});
|
|
304
332
|
const byLine = new Map();
|
|
305
333
|
if (Array.isArray(usages)) {
|
|
306
334
|
for (const u of usages) {
|
package/core/analysis.js
CHANGED
|
@@ -18,6 +18,7 @@ const { isTestFile } = require('./discovery');
|
|
|
18
18
|
const { computeReachability, symbolKey } = require('./entrypoints');
|
|
19
19
|
const { getLanguageAdapter } = require('../languages');
|
|
20
20
|
const { projectComputedDispatch } = require('./ast-analysis');
|
|
21
|
+
const { findAccessorReferences } = require('./accessors');
|
|
21
22
|
|
|
22
23
|
// JS/TS test framework helpers — calls to these bracket a test case.
|
|
23
24
|
// Used to flag call sites whose enclosing function is an arrow callback
|
|
@@ -1235,6 +1236,40 @@ function impact(index, name, options = {}) {
|
|
|
1235
1236
|
});
|
|
1236
1237
|
}
|
|
1237
1238
|
|
|
1239
|
+
// Accessors are consumed through reads/writes. They are intentionally a
|
|
1240
|
+
// separate dependency band rather than fake caller edges: the caller
|
|
1241
|
+
// oracle and conservation account remain call-shaped, while impact no
|
|
1242
|
+
// longer hides the normal use form of a property/getter/setter.
|
|
1243
|
+
let propertyAccesses = findAccessorReferences(index, name, def, {
|
|
1244
|
+
includeTests: true,
|
|
1245
|
+
exclude: options.exclude,
|
|
1246
|
+
});
|
|
1247
|
+
if (propertyAccesses) {
|
|
1248
|
+
const accessByFile = new Map();
|
|
1249
|
+
for (const site of propertyAccesses.confirmed) {
|
|
1250
|
+
if (!accessByFile.has(site.file)) accessByFile.set(site.file, []);
|
|
1251
|
+
accessByFile.get(site.file).push(site);
|
|
1252
|
+
}
|
|
1253
|
+
propertyAccesses = {
|
|
1254
|
+
owner: propertyAccesses.owner,
|
|
1255
|
+
confirmedCount: propertyAccesses.confirmed.length,
|
|
1256
|
+
unverifiedCount: propertyAccesses.unverified.length,
|
|
1257
|
+
totalCandidates: propertyAccesses.confirmed.length +
|
|
1258
|
+
propertyAccesses.unverified.length,
|
|
1259
|
+
byFile: [...accessByFile.entries()]
|
|
1260
|
+
.sort((a, b) => codeUnitCompare(a[0], b[0]))
|
|
1261
|
+
.map(([file, sites]) => ({ file, count: sites.length, sites })),
|
|
1262
|
+
unverifiedSites: propertyAccesses.unverified,
|
|
1263
|
+
excluded: {
|
|
1264
|
+
total: propertyAccesses.excluded.length,
|
|
1265
|
+
byReason: propertyAccesses.excluded.reduce((out, site) => {
|
|
1266
|
+
out[site.reason] = (out[site.reason] || 0) + 1;
|
|
1267
|
+
return out;
|
|
1268
|
+
}, {}),
|
|
1269
|
+
},
|
|
1270
|
+
};
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1238
1273
|
// Apply top limit if specified (limits total call sites shown)
|
|
1239
1274
|
const totalBeforeLimit = filteredSites.length;
|
|
1240
1275
|
if (options.top && options.top > 0 && filteredSites.length > options.top) {
|
|
@@ -1276,6 +1311,12 @@ function impact(index, name, options = {}) {
|
|
|
1276
1311
|
}
|
|
1277
1312
|
}
|
|
1278
1313
|
|
|
1314
|
+
const affectedFiles = new Set([
|
|
1315
|
+
...Array.from(byFile.keys()),
|
|
1316
|
+
...(propertyAccesses?.byFile || []).map(group => group.file),
|
|
1317
|
+
...(propertyAccesses?.unverifiedSites || []).map(site => site.file),
|
|
1318
|
+
]);
|
|
1319
|
+
|
|
1279
1320
|
return {
|
|
1280
1321
|
function: name,
|
|
1281
1322
|
file: def.relativePath,
|
|
@@ -1286,6 +1327,11 @@ function impact(index, name, options = {}) {
|
|
|
1286
1327
|
totalCallSites: totalBeforeLimit,
|
|
1287
1328
|
shownCallSites: filteredSites.length,
|
|
1288
1329
|
unverifiedSites,
|
|
1330
|
+
...(propertyAccesses && {
|
|
1331
|
+
propertyAccesses,
|
|
1332
|
+
totalDependencySites: totalBeforeLimit + propertyAccesses.confirmedCount,
|
|
1333
|
+
affectedFiles: affectedFiles.size,
|
|
1334
|
+
}),
|
|
1289
1335
|
account: impactAccount,
|
|
1290
1336
|
hasEntrypoints: !!impactReachable && impactReachable.size > 0,
|
|
1291
1337
|
callerHistogram,
|
package/core/ast-analysis.js
CHANGED
|
@@ -181,6 +181,108 @@ function computedReceiver(callee) {
|
|
|
181
181
|
return node?.type === 'identifier' ? node.text : null;
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
+
const STRING_LITERAL_NODES = new Set([
|
|
185
|
+
'string', 'string_literal', 'raw_string_literal',
|
|
186
|
+
'interpreted_string_literal', 'verbatim_string_literal',
|
|
187
|
+
]);
|
|
188
|
+
|
|
189
|
+
function literalStringValue(node) {
|
|
190
|
+
if (!node || !STRING_LITERAL_NODES.has(node.type)) return null;
|
|
191
|
+
const content = (node.namedChildren || []).find(child =>
|
|
192
|
+
child.type === 'string_content' || child.type === 'interpreted_string_literal_content');
|
|
193
|
+
if (content) return content.text;
|
|
194
|
+
const raw = String(node.text || '');
|
|
195
|
+
const first = raw.search(/["']/);
|
|
196
|
+
if (first < 0) return null;
|
|
197
|
+
const quote = raw[first];
|
|
198
|
+
const triple = raw.slice(first, first + 3) === quote.repeat(3);
|
|
199
|
+
const width = triple ? 3 : 1;
|
|
200
|
+
if (!raw.endsWith(quote.repeat(width))) return null;
|
|
201
|
+
const value = raw.slice(first + width, -width);
|
|
202
|
+
// Escaped/interpolated member names are not a stable static spelling.
|
|
203
|
+
if (value.includes('\\') || value.includes('{') || value.includes('$')) return null;
|
|
204
|
+
return value;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function callShape(node) {
|
|
208
|
+
if (!['call', 'call_expression', 'invocation_expression',
|
|
209
|
+
'method_invocation'].includes(node.type)) return null;
|
|
210
|
+
const callee = node.childForFieldName('function') ||
|
|
211
|
+
node.childForFieldName('name') || node.namedChild(0);
|
|
212
|
+
const args = node.childForFieldName('arguments') ||
|
|
213
|
+
(node.namedChildren || []).find(child =>
|
|
214
|
+
['argument_list', 'arguments', 'bracketed_argument_list'].includes(child.type));
|
|
215
|
+
return callee && args ? { callee, args: args.namedChildren || [] } : null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Extract recognized reflection operations and classify whether their member
|
|
220
|
+
* target is a stable literal. Literal targets are positive liveness evidence;
|
|
221
|
+
* dynamic targets cannot identify one member but must still be disclosed by
|
|
222
|
+
* deletion-oriented commands.
|
|
223
|
+
*/
|
|
224
|
+
function reflectionSites(content, language) {
|
|
225
|
+
try {
|
|
226
|
+
const syntaxHints = {
|
|
227
|
+
python: ['getattr', 'setattr', 'hasattr', 'delattr'],
|
|
228
|
+
javascript: ['Reflect.'],
|
|
229
|
+
typescript: ['Reflect.'],
|
|
230
|
+
tsx: ['Reflect.'],
|
|
231
|
+
go: ['MethodByName', 'FieldByName'],
|
|
232
|
+
java: ['getMethod', 'getDeclaredMethod', 'getField', 'getDeclaredField'],
|
|
233
|
+
csharp: ['GetMethod', 'GetProperty', 'GetField'],
|
|
234
|
+
}[language];
|
|
235
|
+
if (!syntaxHints || !syntaxHints.some(hint => content.includes(hint))) {
|
|
236
|
+
return [];
|
|
237
|
+
}
|
|
238
|
+
const parser = getParser(language);
|
|
239
|
+
if (!parser) return [];
|
|
240
|
+
const tree = safeParse(parser, content);
|
|
241
|
+
const sites = [];
|
|
242
|
+
walkNamed(tree.rootNode, node => {
|
|
243
|
+
const call = callShape(node);
|
|
244
|
+
if (!call) return true;
|
|
245
|
+
const callee = String(call.callee.text || '');
|
|
246
|
+
let argIndex = null;
|
|
247
|
+
let kind = null;
|
|
248
|
+
if (language === 'python' &&
|
|
249
|
+
['getattr', 'setattr', 'hasattr', 'delattr'].includes(callee)) {
|
|
250
|
+
argIndex = 1;
|
|
251
|
+
kind = callee;
|
|
252
|
+
} else if (['javascript', 'typescript', 'tsx'].includes(language) &&
|
|
253
|
+
/^(?:globalThis\.)?Reflect\.(?:get|set|has|deleteProperty)$/.test(callee)) {
|
|
254
|
+
argIndex = 1;
|
|
255
|
+
kind = callee.split('.').pop();
|
|
256
|
+
} else if (language === 'go' &&
|
|
257
|
+
/\.(?:MethodByName|FieldByName)$/.test(callee)) {
|
|
258
|
+
argIndex = 0;
|
|
259
|
+
kind = callee.split('.').pop();
|
|
260
|
+
} else if (['java', 'csharp'].includes(language) &&
|
|
261
|
+
/(?:^|\.)(?:getMethod|getDeclaredMethod|getField|getDeclaredField|GetMethod|GetProperty|GetField)$/.test(callee)) {
|
|
262
|
+
argIndex = 0;
|
|
263
|
+
kind = callee.split('.').pop();
|
|
264
|
+
}
|
|
265
|
+
if (argIndex == null || !call.args[argIndex]) return true;
|
|
266
|
+
const name = literalStringValue(call.args[argIndex]);
|
|
267
|
+
sites.push({
|
|
268
|
+
...(name && /^[A-Za-z_$][\w$]*$/.test(name) && { name }),
|
|
269
|
+
kind,
|
|
270
|
+
line: node.startPosition.row + 1,
|
|
271
|
+
expression: node.text,
|
|
272
|
+
dynamic: !name || !/^[A-Za-z_$][\w$]*$/.test(name),
|
|
273
|
+
});
|
|
274
|
+
return true;
|
|
275
|
+
});
|
|
276
|
+
return sites;
|
|
277
|
+
} catch (_) {
|
|
278
|
+
return [];
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function literalReflectionSites(content, language) {
|
|
283
|
+
return reflectionSites(content, language).filter(site => !site.dynamic);
|
|
284
|
+
}
|
|
285
|
+
|
|
184
286
|
/**
|
|
185
287
|
* Find direct computed dispatch calls such as handlers[name](). Literal keys
|
|
186
288
|
* are excluded because they retain a statically visible member name.
|
|
@@ -276,4 +378,6 @@ module.exports = {
|
|
|
276
378
|
computeAstComplexity,
|
|
277
379
|
computedDispatchSites,
|
|
278
380
|
projectComputedDispatch,
|
|
381
|
+
reflectionSites,
|
|
382
|
+
literalReflectionSites,
|
|
279
383
|
};
|