weavatrix 0.2.4 → 0.2.6
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/README.md +53 -11
- package/package.json +1 -1
- package/skill/SKILL.md +18 -7
- package/src/analysis/dead-check.js +48 -2
- package/src/analysis/dead-code-review.js +9 -5
- package/src/analysis/duplicates.compute.js +11 -6
- package/src/analysis/graph-analysis.aggregate.js +3 -1
- package/src/analysis/graph-analysis.refs.js +19 -11
- package/src/analysis/hot-path-review.js +228 -0
- package/src/analysis/internal-audit.run.js +36 -0
- package/src/analysis/source-complexity.report.js +5 -0
- package/src/analysis/source-complexity.walk.js +64 -10
- package/src/bounds.js +4 -0
- package/src/graph/builder/lang-js.js +71 -14
- package/src/graph/builder/lang-python.js +161 -4
- package/src/graph/freshness-probe.js +5 -3
- package/src/graph/incremental-refresh.js +4 -2
- package/src/graph/internal-builder.barrels.js +71 -5
- package/src/graph/internal-builder.build.js +77 -16
- package/src/mcp/catalog.mjs +12 -7
- package/src/mcp/graph-context.mjs +4 -0
- package/src/mcp/graph-diff.mjs +1 -1
- package/src/mcp/sync-payload.mjs +3 -2
- package/src/mcp/tools-actions.mjs +3 -1
- package/src/mcp/tools-context.mjs +164 -0
- package/src/mcp/tools-graph.mjs +2 -0
- package/src/mcp/tools-health.mjs +66 -5
- package/src/mcp/tools-impact.mjs +3 -3
- package/src/mcp/tools-source.mjs +238 -0
- package/src/precision/lsp-client.js +1 -1
- package/src/precision/lsp-overlay.js +29 -8
- package/src/precision/symbol-query.js +133 -0
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
Grep sees text. Weavatrix sees structure. It builds a dependency graph of any local repository —
|
|
6
6
|
files, symbols, and the imports/calls/inheritance connecting them — and serves it to Claude Code,
|
|
7
7
|
Codex, or any MCP client: change impact, transitive dependents, health audit, clone detection,
|
|
8
|
-
coverage mapping. **
|
|
8
|
+
coverage mapping. **35 tools available; 32 enabled by the default offline profile. Local-first: with
|
|
9
9
|
the defaults, no repository data leaves your machine.**
|
|
10
10
|
|
|
11
11
|
- Website: [weavatrix.com](https://weavatrix.com)
|
|
@@ -21,8 +21,10 @@ answers grep can't produce:
|
|
|
21
21
|
untracked included), maps the changed files and symbols onto the graph, and lists everything that
|
|
22
22
|
depends on them — with test coverage attached, so the **untested part of the blast radius** stands
|
|
23
23
|
out before you ship.
|
|
24
|
-
- *"Who calls this function?"* → `
|
|
25
|
-
|
|
24
|
+
- *"Who calls this function?"* → `inspect_symbol` returns exact bounded TS/JS occurrences plus
|
|
25
|
+
source context; `context_bundle` adds grouped graph relations, exact re-export sites, and a
|
|
26
|
+
smaller source workset. `get_dependents` walks the symbol-level reverse graph transitively. Opt into
|
|
27
|
+
`include_container_importers:true` only when a broader module-import radius is intended.
|
|
26
28
|
- *"Did my refactor actually decouple anything?"* → `graph_diff base_ref=HEAD~1` builds an immutable
|
|
27
29
|
baseline graph without checking it out, then reports the structural delta: new module
|
|
28
30
|
dependencies, broken or introduced import cycles, and symbols that lost their last caller.
|
|
@@ -109,7 +111,8 @@ An agent skill with recipes ships in [skill/SKILL.md](skill/SKILL.md) — instal
|
|
|
109
111
|
`change_impact`, `git_history`, `graph_diff`, `get_architecture_contract`,
|
|
110
112
|
`prepare_change`. Runtime dependencies, TypeScript type-only coupling and language
|
|
111
113
|
compile-only edges (Rust module/use, Java imports) are reported separately where that distinction
|
|
112
|
-
changes the result.
|
|
114
|
+
changes the result. TypeScript type-space and value-space declarations keep distinct identities;
|
|
115
|
+
classes and enums that inhabit both spaces are labelled `both`.
|
|
113
116
|
|
|
114
117
|
Every current edge carries versioned provenance. The parser emits `EXTRACTED`, `RESOLVED`, and
|
|
115
118
|
`INFERRED`; the built-in bounded TypeScript/JavaScript precision overlay upgrades only references
|
|
@@ -117,7 +120,7 @@ confirmed by its bundled `typescript-language-server` + TypeScript runtime to `E
|
|
|
117
120
|
`CONFLICT` means evidence disagrees. `graph_stats` reports the provenance breakdown and the semantic
|
|
118
121
|
provider's `COMPLETE`, `PARTIAL`, `UNAVAILABLE`, or `OFF` state; `OFF` means precision was explicitly
|
|
119
122
|
disabled and only static evidence is active. Java and Rust language-server providers are not bundled
|
|
120
|
-
in 0.2.
|
|
123
|
+
in 0.2.6: their edges never become `EXACT_LSP`, even when a mixed repository reports a complete
|
|
121
124
|
TypeScript/JavaScript overlay.
|
|
122
125
|
|
|
123
126
|
The bounded JS/TS provider is enabled by default for new graphs. Set `WEAVATRIX_PRECISION=off`
|
|
@@ -126,19 +129,28 @@ before starting the MCP server for parser-only operation from the first build, o
|
|
|
126
129
|
choice as **TypeScript/JavaScript semantic precision**.
|
|
127
130
|
|
|
128
131
|
**search / source** — `search_code` (ripgrep-backed, pure-Node fallback), `read_source` (a
|
|
129
|
-
symbol's actual code in one hop), `
|
|
132
|
+
symbol's actual code in one hop), `context_bundle` (definition plus grouped inbound/outbound
|
|
133
|
+
containers, exact re-export sites and a few bounded source excerpts), `inspect_symbol` (one exact
|
|
134
|
+
bounded TS/JS reference query, logical containers, impact and source excerpts), `list_endpoints` (HTTP route inventory:
|
|
130
135
|
Express/Fastify/Nest/Flask/FastAPI/Go mux/Rust axum and actix-web …)
|
|
131
136
|
|
|
132
|
-
**health** — `find_dead_code` (bounded review queue for statically unreferenced files,
|
|
133
|
-
methods, and symbols, with confidence/evidence and explicit public/framework/dynamic caveats),
|
|
134
|
-
`run_audit` (unused files/exports/dependencies
|
|
137
|
+
**health** — `find_dead_code` (bounded review queue for statically unreferenced and test-only files,
|
|
138
|
+
functions, methods, and symbols, with confidence/evidence and explicit public/framework/dynamic caveats),
|
|
139
|
+
`run_audit` (unused files/exports/dependencies with an explicit manifest-check summary,
|
|
140
|
+
missing npm/Go/Python deps, runtime
|
|
135
141
|
cycles, type-only/compile-only coupling, orphans, boundary rules, offline OSV vulnerabilities + typosquat +
|
|
136
142
|
lockfile drift; accepts an immutable `base_ref`, `changed_files`, and `debt: new|existing|all` for
|
|
137
143
|
review-scoped results), `find_duplicates` (MOSS winnowing over method bodies — catches copy-paste even
|
|
138
|
-
after renames), `coverage_map` (existing coverage reports mapped onto the graph; untested hotspots
|
|
139
|
-
ranked by connectivity — tests are never executed), `
|
|
144
|
+
after renames and can inspect strict small clones down to 12 tokens), `coverage_map` (existing coverage reports mapped onto the graph; untested hotspots
|
|
145
|
+
ranked by connectivity — tests are never executed), `hot_path_review` (bounded local-cost evidence
|
|
146
|
+
with separate graph/test risk), `verify_architecture`,
|
|
140
147
|
`explain_architecture_violation`, `propose_architecture_exception`
|
|
141
148
|
|
|
149
|
+
`hot_path_review` ranks production symbols using parser-derived local time, memory, cyclomatic and
|
|
150
|
+
call-site facts plus exact inside-loop allocation, copy, scan, sort and recursion evidence. Graph
|
|
151
|
+
fan-in/fan-out and measured coverage (or explicitly labelled static test reachability) remain
|
|
152
|
+
separate from local syntax cost. The ranking is not profiler data or interprocedural Big-O.
|
|
153
|
+
|
|
142
154
|
**build** — `rebuild_graph` (reports the structural delta, keeps the prior state as
|
|
143
155
|
`graph.prev.json`)
|
|
144
156
|
|
|
@@ -184,6 +196,36 @@ disclosed instead of silently guessed; and the server **hot-reloads its watched
|
|
|
184
196
|
modules and catalog** when those files change — other MCP helpers and analysis engines require a
|
|
185
197
|
reconnect.
|
|
186
198
|
|
|
199
|
+
### 0.2.6 compact-context and TypeScript identity patch
|
|
200
|
+
|
|
201
|
+
- New `context_bundle` turns a symbol into a bounded workset: definition, grouped inbound/outbound
|
|
202
|
+
containers, reference evidence, exact re-export locations and a small number of source excerpts.
|
|
203
|
+
- Re-export records retain their concrete file, line, alias, specifier, type-only flag and resolved
|
|
204
|
+
origin. Barrel propagation through `export *` stays exact and private declarations are not exposed.
|
|
205
|
+
- TypeScript interfaces and type aliases are first-class type-space nodes. Same-named runtime values
|
|
206
|
+
keep separate identities, while classes and enums are explicitly marked as inhabiting both spaces.
|
|
207
|
+
- Graph schema v5 and freshness gates rebuild older caches before these precision-sensitive results
|
|
208
|
+
are used. The changes remain local-only and add no runtime dependency.
|
|
209
|
+
|
|
210
|
+
Full patch notes: [docs/releases/v0.2.6.md](docs/releases/v0.2.6.md).
|
|
211
|
+
|
|
212
|
+
### 0.2.5 exact-symbol and graph-fidelity patch
|
|
213
|
+
|
|
214
|
+
- New `inspect_symbol` spends a bounded LSP query on one requested TS/JS declaration, groups exact
|
|
215
|
+
occurrences by logical container, and returns definition/caller source context. Its separate LRU
|
|
216
|
+
cache is revision/config fingerprinted and never replaces the broad precision overlay.
|
|
217
|
+
- Default-object service facades resolve to their underlying helpers, while `get_dependents` no
|
|
218
|
+
longer silently expands every symbol query to all importers of its file.
|
|
219
|
+
- Python receiver-aware method dispatch and unambiguous wildcard imports distinguish same-named
|
|
220
|
+
methods across classes without promoting ambiguous dynamic evidence.
|
|
221
|
+
- `run_audit` makes unused-dependency checking visible even when clean; dead-code review identifies
|
|
222
|
+
production symbols referenced only by tests; small-clone scanning can be lowered to 12 tokens with
|
|
223
|
+
stricter evidence rules.
|
|
224
|
+
- `hot_path_review` adds a bounded offline performance-review queue with line-addressable local cost
|
|
225
|
+
evidence, separate graph risk, and honest measured/unavailable coverage states.
|
|
226
|
+
|
|
227
|
+
Full patch notes: [docs/releases/v0.2.5.md](docs/releases/v0.2.5.md).
|
|
228
|
+
|
|
187
229
|
### 0.2.4 graph-mode correctness and semantic precision patch
|
|
188
230
|
|
|
189
231
|
- `graph_diff base_ref=...` builds the immutable baseline in the current graph mode, so a
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "weavatrix",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.6",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Weavatrix — code graph & blast-radius MCP server for AI coding agents: change impact, transitive dependents, health audit, duplicate detection, and coverage mapping over any local repository.",
|
|
6
6
|
"author": "Sergii Ziborov <sergii.ziborov@gmail.com>",
|
package/skill/SKILL.md
CHANGED
|
@@ -40,12 +40,14 @@ Start from the task, not from the complete tool list:
|
|
|
40
40
|
`get_dependents` and `read_source`.
|
|
41
41
|
- **Trace an API across repositories**: `list_known_repos` -> `trace_api_contract` with an explicit
|
|
42
42
|
backend and client list; inspect each `graphReconciliation.buildMode` before using the verdict.
|
|
43
|
-
- **Inspect exact symbol references**: start with `graph_stats
|
|
44
|
-
|
|
45
|
-
|
|
43
|
+
- **Inspect exact symbol references**: start with `graph_stats`, then call `context_bundle` with an
|
|
44
|
+
exact node ID (or an unambiguous label) for a compact definition, grouped relations, exact
|
|
45
|
+
re-export sites and source workset. Use `inspect_symbol` when the raw bounded point query is needed;
|
|
46
|
+
it spends evidence beyond the broad overlay cap. Use `get_dependents` for transitive
|
|
47
|
+
graph impact and `find_dead_code` for bounded zero-reference
|
|
46
48
|
candidates. Treat `PARTIAL`, `UNAVAILABLE`, `OFF`, or zero exact edges as incomplete evidence, not
|
|
47
49
|
a compiler-exact result; `OFF` means the caller explicitly selected static-only mode. Java and Rust
|
|
48
|
-
providers are not bundled in 0.2.
|
|
50
|
+
providers are not bundled in 0.2.6, so their edges never become `EXACT_LSP` even when a mixed
|
|
49
51
|
repository has a complete TypeScript/JavaScript overlay.
|
|
50
52
|
- **Explore an architectural question**: use broad `query_graph` when entry points are unknown; its
|
|
51
53
|
bootstrap/tool-execution ranking prefers production executables over docs, sites and fixtures.
|
|
@@ -89,7 +91,13 @@ Start from the task, not from the complete tool list:
|
|
|
89
91
|
`seed_files` to `query_graph` when the intended entry points are already known.
|
|
90
92
|
- **Coverage**: `coverage_map` reads an existing report. `unavailable` means no supported report was
|
|
91
93
|
found, not 0% coverage; do not rank testing risk from that state.
|
|
92
|
-
- **
|
|
94
|
+
- **Local hot paths**: `hot_path_review` ranks parser-derived local syntax cost and reports graph
|
|
95
|
+
fan-in/fan-out plus test evidence separately. `actualCoverage: NOT_AVAILABLE` is not zero coverage,
|
|
96
|
+
and the score is not profiler data or an interprocedural Big-O proof. Inspect its line evidence and
|
|
97
|
+
measure runtime before scheduling a performance rewrite.
|
|
98
|
+
- **Audit completeness**: read `dependencyReport.status` plus its unused/missing counts, then
|
|
99
|
+
`checks.osv.status` and `checks.malware.status`. A dependency result from a partial graph is not a
|
|
100
|
+
repository-wide clean zero. For OSV, `OK` is
|
|
93
101
|
complete for the recorded dependency fingerprint; `PARTIAL` is incomplete or stale,
|
|
94
102
|
`NOT_CHECKED` has no repository-specific result, and `ERROR` means the local check failed. Treat
|
|
95
103
|
the same non-`OK` states as incomplete for malware scanning. Refresh OSV only when the user has
|
|
@@ -118,8 +126,10 @@ Start from the task, not from the complete tool list:
|
|
|
118
126
|
- **Orient in the configured repo**: `module_map` → `list_communities` → `god_nodes`. Hub ranking
|
|
119
127
|
is production-only by default; use `include_classified:true` only when tests/generated/build
|
|
120
128
|
surfaces are deliberately part of the question.
|
|
121
|
-
- **Refactor safety for one symbol**: `get_dependents` → `coverage_map` (low coverage × many
|
|
129
|
+
- **Refactor safety for one symbol**: `inspect_symbol` → `get_dependents` → `coverage_map` (low coverage × many
|
|
122
130
|
dependents ⇒ write tests first) → `read_source`.
|
|
131
|
+
- **Performance review**: `hot_path_review` → inspect its local evidence with `read_source` → use
|
|
132
|
+
`get_dependents` for change risk → confirm with the repository's profiler/benchmark before editing.
|
|
123
133
|
- **Pre-PR review of your current changes**: `change_impact` (auto merge-base; includes uncommitted
|
|
124
134
|
and untracked work, coverage attached, untested hotspots called out) → drill with `get_dependents`.
|
|
125
135
|
- **Impact of a PR that is NOT checked out**: pass its changed-file list explicitly —
|
|
@@ -131,7 +141,8 @@ Start from the task, not from the complete tool list:
|
|
|
131
141
|
orphan drift rather than raw edge counts.
|
|
132
142
|
- **Health sweep**: for a branch/PR review prefer
|
|
133
143
|
`run_audit base_ref=<merge-base> debt=new changed_files=[…]`; for repository maintenance use
|
|
134
|
-
`run_audit debt=all`. Then call `find_dead_code`, `find_duplicates
|
|
144
|
+
`run_audit debt=all`. Then call `find_dead_code`, `find_duplicates`, `coverage_map` and
|
|
145
|
+
`hot_path_review`. A changed-file-only audit is
|
|
135
146
|
scope, not proof of new debt. Inspect the source behind shortlisted findings before proposing edits.
|
|
136
147
|
- **Dead-code and dead-method review**: call `find_dead_code` for a bounded production-code queue of
|
|
137
148
|
files, functions, methods and symbols. Narrow with `path` or `kinds=["method"]`; keep the default
|
|
@@ -100,7 +100,16 @@ export function computeDead(graph, sources, { entrySet = new Set() } = {}) {
|
|
|
100
100
|
const nodes = graph.nodes || [], links = graph.links || [];
|
|
101
101
|
const ep = (v) => (v && typeof v === "object" ? v.id : v);
|
|
102
102
|
const inbound = new Set();
|
|
103
|
-
|
|
103
|
+
const inboundSources = new Map();
|
|
104
|
+
const nodesById = new Map(nodes.map((node) => [String(node.id), node]));
|
|
105
|
+
for (const l of links) if (!isStructuralRelation(l.relation)) {
|
|
106
|
+
const target = String(ep(l.target));
|
|
107
|
+
const source = String(ep(l.source));
|
|
108
|
+
inbound.add(target);
|
|
109
|
+
const values = inboundSources.get(target) || [];
|
|
110
|
+
values.push(source);
|
|
111
|
+
inboundSources.set(target, values);
|
|
112
|
+
}
|
|
104
113
|
|
|
105
114
|
// whole-repo identifier frequency: a symbol whose name appears MORE than once total (its definition + at least
|
|
106
115
|
// one use, same-file OR cross-file) is referenced. Errs toward "alive" (common-named symbols never flagged).
|
|
@@ -115,6 +124,16 @@ export function computeDead(graph, sources, { entrySet = new Set() } = {}) {
|
|
|
115
124
|
(symsByFile.get(n.source_file) || symsByFile.set(n.source_file, []).get(n.source_file)).push(n);
|
|
116
125
|
}
|
|
117
126
|
|
|
127
|
+
const symbolNames = new Set([...symById.values()].map((node) => bareName(node.label)).filter(Boolean));
|
|
128
|
+
const occurrenceFiles = new Map();
|
|
129
|
+
for (const [file, text] of sources) for (const match of String(text || "").matchAll(IDENT_RE)) {
|
|
130
|
+
const name = match[0];
|
|
131
|
+
if (!symbolNames.has(name)) continue;
|
|
132
|
+
const files = occurrenceFiles.get(name) || new Set();
|
|
133
|
+
files.add(file);
|
|
134
|
+
occurrenceFiles.set(name, files);
|
|
135
|
+
}
|
|
136
|
+
|
|
118
137
|
// decorated defs (@app.route/@app.event/@pytest.fixture…) are entered by the framework: trust the
|
|
119
138
|
// builder's flag when present, else walk the source line(s) above the definition (graph-builder graphs).
|
|
120
139
|
const lineCache = new Map();
|
|
@@ -151,6 +170,32 @@ export function computeDead(graph, sources, { entrySet = new Set() } = {}) {
|
|
|
151
170
|
}
|
|
152
171
|
const deadSet = new Set(deadSymbols.map((s) => s.id));
|
|
153
172
|
|
|
173
|
+
// A production declaration consumed only by tests is live to the raw graph but dead to production.
|
|
174
|
+
// Keep it in a separate review class: this is useful evidence, never an automatic delete verdict.
|
|
175
|
+
const testOnlySymbols = [];
|
|
176
|
+
for (const n of symById.values()) {
|
|
177
|
+
if (deadSet.has(n.id) || isTestFile(n.source_file) || isDecorated(n)) continue;
|
|
178
|
+
const sourcesForSymbol = inboundSources.get(String(n.id)) || [];
|
|
179
|
+
const sourceFiles = sourcesForSymbol.map((id) => nodesById.get(id)?.source_file || (id.includes("#") ? id.split("#")[0] : id));
|
|
180
|
+
const hasTestInbound = sourceFiles.some((file) => isTestFile(file));
|
|
181
|
+
const hasProductionInbound = sourceFiles.some((file) => file && !isTestFile(file));
|
|
182
|
+
const name = bareName(n.label);
|
|
183
|
+
const occurrenceSet = occurrenceFiles.get(name) || new Set();
|
|
184
|
+
const externalOccurrences = [...occurrenceSet].filter((file) => file !== n.source_file);
|
|
185
|
+
const lexicalTestOnly = externalOccurrences.length > 0 && externalOccurrences.every((file) => isTestFile(file));
|
|
186
|
+
if (hasProductionInbound || (!hasTestInbound && !lexicalTestOnly)) continue;
|
|
187
|
+
testOnlySymbols.push({
|
|
188
|
+
id: n.id,
|
|
189
|
+
file: n.source_file,
|
|
190
|
+
label: n.label,
|
|
191
|
+
test: false,
|
|
192
|
+
reason: "referenced only from test/e2e code; no production consumer was found",
|
|
193
|
+
testConsumerFiles: [...new Set(sourceFiles.filter((file) => file && isTestFile(file)))].sort(),
|
|
194
|
+
evidence: hasTestInbound ? "graph" : "lexical",
|
|
195
|
+
publicApi: n.exported === true || ["public", "protected"].includes(String(n.visibility || "").toLowerCase()),
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
154
199
|
const deadFiles = [];
|
|
155
200
|
for (const n of nodes) {
|
|
156
201
|
if (String(n.id).includes("#")) continue; // file nodes only
|
|
@@ -161,9 +206,10 @@ export function computeDead(graph, sources, { entrySet = new Set() } = {}) {
|
|
|
161
206
|
|
|
162
207
|
return {
|
|
163
208
|
deadSymbols,
|
|
209
|
+
testOnlySymbols,
|
|
164
210
|
deadFiles,
|
|
165
211
|
referencedIds: [...symById.values()].filter(isReferenced).map((n) => n.id),
|
|
166
|
-
stats: { symbols: symById.size, deadSymbols: deadSymbols.length, files: nodes.filter((n) => !String(n.id).includes("#")).length, deadFiles: deadFiles.length },
|
|
212
|
+
stats: { symbols: symById.size, deadSymbols: deadSymbols.length, testOnlySymbols: testOnlySymbols.length, files: nodes.filter((n) => !String(n.id).includes("#")).length, deadFiles: deadFiles.length },
|
|
167
213
|
};
|
|
168
214
|
}
|
|
169
215
|
|
|
@@ -45,6 +45,7 @@ function symbolCandidate(item, node, context) {
|
|
|
45
45
|
const source = String(context.sources.get(file) || "");
|
|
46
46
|
const pathInfo = context.classify(file, source);
|
|
47
47
|
const publicSurface = isPublicSurface(node);
|
|
48
|
+
const testOnly = item.testOnly === true;
|
|
48
49
|
const externalEntry = context.entrySet.has(file) || isFrameworkEntryFile(file);
|
|
49
50
|
const framework = context.frameworkByFile.get(file) || null;
|
|
50
51
|
const dynamicFile = context.dynamicTargets.has(file) || DYNAMIC_RE.test(source);
|
|
@@ -86,7 +87,7 @@ function symbolCandidate(item, node, context) {
|
|
|
86
87
|
return {
|
|
87
88
|
id: String(item.id),
|
|
88
89
|
kind,
|
|
89
|
-
classification: publicSurface ? `public-${kind}` : kind === "method" ? "internal-method" : kind === "function" ? "internal-function" : "unreferenced-symbol",
|
|
90
|
+
classification: testOnly ? `test-only-${kind}` : publicSurface ? `public-${kind}` : kind === "method" ? "internal-method" : kind === "function" ? "internal-function" : "unreferenced-symbol",
|
|
90
91
|
file,
|
|
91
92
|
line: lineOf(node),
|
|
92
93
|
symbol: bareLabel(node?.label || item.label),
|
|
@@ -96,8 +97,8 @@ function symbolCandidate(item, node, context) {
|
|
|
96
97
|
confidence,
|
|
97
98
|
reason: item.reason,
|
|
98
99
|
evidence: [
|
|
99
|
-
{
|
|
100
|
-
|
|
100
|
+
...(testOnly ? [{kind: item.evidence || "graph", fact: `Only test/e2e consumers were found${item.testConsumerFiles?.length ? `: ${item.testConsumerFiles.join(", ")}` : "."}`}]
|
|
101
|
+
: [{ kind: "graph", fact: "No inbound non-structural graph edge targets this symbol." }, { kind: "source-index", fact: "Its identifier has no second indexed occurrence that establishes a caller." }]),
|
|
101
102
|
...(exactNoReference ? [{kind: "exact-lsp", fact: "The active language server returned no in-workspace references for this exact declaration."}] : []),
|
|
102
103
|
],
|
|
103
104
|
caveats,
|
|
@@ -105,7 +106,9 @@ function symbolCandidate(item, node, context) {
|
|
|
105
106
|
externallyEnteredFile: externalEntry,
|
|
106
107
|
pathClasses: pathInfo.classes || [],
|
|
107
108
|
matchedPathRule: pathInfo.matchedRule || null,
|
|
108
|
-
reviewAction:
|
|
109
|
+
reviewAction: testOnly
|
|
110
|
+
? "Confirm that no production/config/framework consumer exists; decide whether the declaration is intentional test support or removable with its tests. Never auto-delete."
|
|
111
|
+
: "Confirm with read_source, get_dependents, exact search, framework/config inspection and tests; never auto-delete.",
|
|
109
112
|
};
|
|
110
113
|
}
|
|
111
114
|
|
|
@@ -182,7 +185,7 @@ export function computeDeadCodeReview(graph, sources, options = {}) {
|
|
|
182
185
|
const context = { sources, entrySet, dynamicTargets, frameworkByFile, classify, repoSignals, exactNoReferenceIds };
|
|
183
186
|
const dead = computeDead(graph, sources, { entrySet });
|
|
184
187
|
const nodesById = new Map((graph.nodes || []).map((node) => [String(node.id), node]));
|
|
185
|
-
const rawSymbols = dead.deadSymbols
|
|
188
|
+
const rawSymbols = [...dead.deadSymbols, ...(dead.testOnlySymbols || []).map((item) => ({...item, testOnly: true}))]
|
|
186
189
|
.map((item) => ({ item, node: nodesById.get(String(item.id)) }))
|
|
187
190
|
.filter((entry) => entry.node)
|
|
188
191
|
.map(({ item, node }) => symbolCandidate(item, node, context));
|
|
@@ -236,6 +239,7 @@ export function computeDeadCodeReview(graph, sources, options = {}) {
|
|
|
236
239
|
indexedSymbols: dead.stats.symbols,
|
|
237
240
|
indexedFiles: dead.stats.files,
|
|
238
241
|
rawDeadSymbols: rawSymbols.length,
|
|
242
|
+
rawTestOnlySymbols: (dead.testOnlySymbols || []).length,
|
|
239
243
|
rawDeadFiles: rawFiles.length,
|
|
240
244
|
reviewCandidates: candidates.length,
|
|
241
245
|
byConfidence: {
|
|
@@ -6,7 +6,7 @@ import { createPathClassifier, hasPathClass } from "../path-classification.js";
|
|
|
6
6
|
import { listRepoFiles } from "./internal-audit.collect.js";
|
|
7
7
|
import { stripNonCode, bodyEndLineCount, tokenize, fingerprints, extractLargeStrings } from "./duplicates.tokenize.js";
|
|
8
8
|
|
|
9
|
-
const FLOOR_TOKENS =
|
|
9
|
+
const FLOOR_TOKENS = 12; // absolute safety floor; normal scans still default to 30+ tokens
|
|
10
10
|
const FLOOR_SIM = 0.5; // pairs below this are not reported (UI slider min)
|
|
11
11
|
const MAX_BODY_LINES = 400;
|
|
12
12
|
// File types with no code SYMBOLS to fragment (stylesheets, markup, docs, single-file components). The
|
|
@@ -93,7 +93,11 @@ function pairsForMode(frags, mode) {
|
|
|
93
93
|
const i = Math.floor(key / 1000000), j = key % 1000000;
|
|
94
94
|
const union = eff[i] + eff[j] - count;
|
|
95
95
|
const sim = union > 0 ? count / union : 0;
|
|
96
|
-
|
|
96
|
+
const smallFragment = Math.min(frags[i].n, frags[j].n) < 30;
|
|
97
|
+
const enoughEvidence = smallFragment
|
|
98
|
+
? sim >= 0.95 && count >= 2
|
|
99
|
+
: count >= MIN_SHARED_FP;
|
|
100
|
+
if (sim >= FLOOR_SIM && enoughEvidence) pairs.push([i, j, Math.round(sim * 100)]);
|
|
97
101
|
}
|
|
98
102
|
return pairs;
|
|
99
103
|
}
|
|
@@ -125,6 +129,7 @@ function loadGraph(graphInput) {
|
|
|
125
129
|
|
|
126
130
|
export function computeDuplicates(repoPath, graphJsonPath, opts = {}) {
|
|
127
131
|
const includeStrings = !!opts.includeStrings;
|
|
132
|
+
const scanTokenFloor = Math.max(FLOOR_TOKENS, Math.min(400, Number(opts.minTokens) || 30));
|
|
128
133
|
const graph = loadGraph(graphJsonPath);
|
|
129
134
|
const byFile = symbolRanges(graph);
|
|
130
135
|
// total symbol nodes in the graph — 0 means a file-only graph (built by an older builder before
|
|
@@ -169,7 +174,7 @@ export function computeDuplicates(repoPath, graphJsonPath, opts = {}) {
|
|
|
169
174
|
const end = start + body.length - 1;
|
|
170
175
|
if (end - start < 2) continue;
|
|
171
176
|
const toks = tokenize(stripNonCode(body.join("\n"), py));
|
|
172
|
-
if (toks.strict.length <
|
|
177
|
+
if (toks.strict.length < scanTokenFloor) continue;
|
|
173
178
|
frags.push({
|
|
174
179
|
id: syms[i].id, label: syms[i].label, file, start, end,
|
|
175
180
|
n: toks.strict.length,
|
|
@@ -184,7 +189,7 @@ export function computeDuplicates(repoPath, graphJsonPath, opts = {}) {
|
|
|
184
189
|
const base = file.split("/").pop();
|
|
185
190
|
const pushStr = (startLine, endLine, content) => {
|
|
186
191
|
const toks = tokenize(content);
|
|
187
|
-
if (toks.strict.length <
|
|
192
|
+
if (toks.strict.length < scanTokenFloor) return;
|
|
188
193
|
frags.push({
|
|
189
194
|
id: `${file}#str@${startLine}`, label: `${base}:${startLine} ~${endLine - startLine + 1}-line string`,
|
|
190
195
|
file, start: startLine, end: endLine, n: toks.strict.length, ...classificationFields(pathInfo), kind: "string",
|
|
@@ -224,7 +229,7 @@ export function computeDuplicates(repoPath, graphJsonPath, opts = {}) {
|
|
|
224
229
|
const end = Math.min(start + WINDOW_LINES - 1, lines.length);
|
|
225
230
|
if (end - start < 2) continue;
|
|
226
231
|
const toks = tokenize(stripNonCode(lines.slice(start - 1, end).join("\n"), false));
|
|
227
|
-
if (toks.strict.length <
|
|
232
|
+
if (toks.strict.length < scanTokenFloor) continue;
|
|
228
233
|
frags.push({
|
|
229
234
|
id: `${file}#win@${start}`, label: `${file.split("/").pop()}:${start}-${end}`, file, start, end,
|
|
230
235
|
n: toks.strict.length, ...classificationFields(pathInfo),
|
|
@@ -250,6 +255,6 @@ export function computeDuplicates(repoPath, graphJsonPath, opts = {}) {
|
|
|
250
255
|
},
|
|
251
256
|
nameTwinsTruncated: !!nameTwins && nameTwins.length >= 200,
|
|
252
257
|
},
|
|
253
|
-
floors: { tokens: FLOOR_TOKENS, sim: FLOOR_SIM * 100 },
|
|
258
|
+
floors: { tokens: scanTokenFloor, absoluteTokens: FLOOR_TOKENS, sim: FLOOR_SIM * 100 },
|
|
254
259
|
};
|
|
255
260
|
}
|
|
@@ -63,7 +63,9 @@ export function aggregateGraph(graph, repoRoot) {
|
|
|
63
63
|
line: String(node.source_location || "").replace(/^L/, ""),
|
|
64
64
|
endLine: String(node.source_end || "").replace(/^L/, ""),
|
|
65
65
|
...(node.complexity ? { complexity: node.complexity } : {}),
|
|
66
|
-
...(node.decorated ? { decorated: true } : {})
|
|
66
|
+
...(node.decorated ? { decorated: true } : {}),
|
|
67
|
+
...(node.symbol_kind ? {symbolKind: node.symbol_kind} : {}),
|
|
68
|
+
...(node.symbol_space ? {symbolSpace: node.symbol_space} : {})
|
|
67
69
|
});
|
|
68
70
|
fileSymbols.set(fid, list);
|
|
69
71
|
}
|
|
@@ -63,7 +63,7 @@ function importCandidates(fromFile, spec) {
|
|
|
63
63
|
function stripModuleStatements(text) {
|
|
64
64
|
return String(text || "")
|
|
65
65
|
.replace(/\bimport\s+[\s\S]*?\s+from\s*['"][^'"]+['"]\s*;?/g, "")
|
|
66
|
-
.replace(/\bexport\s
|
|
66
|
+
.replace(/\bexport\s+(?:type\s+)?\{[\s\S]*?\}\s+from\s*['"][^'"]+['"]\s*;?/g, "")
|
|
67
67
|
.replace(/\b(?:const|let|var)\s+\{[\s\S]*?\}\s*=\s*require\(\s*['"][^'"]+['"]\s*\)\s*;?/g, "");
|
|
68
68
|
}
|
|
69
69
|
|
|
@@ -73,8 +73,10 @@ function parseNamedSpecifiers(raw) {
|
|
|
73
73
|
.map((part) => part.trim())
|
|
74
74
|
.filter(Boolean)
|
|
75
75
|
.map((part) => {
|
|
76
|
-
const
|
|
77
|
-
|
|
76
|
+
const typeOnly = /^type\s+/.test(part);
|
|
77
|
+
const clean = part.replace(/^type\s+/, "").trim();
|
|
78
|
+
const m = clean.match(/^([A-Za-z_$][\w$]*)(?:\s+as\s+([A-Za-z_$][\w$]*))?$/);
|
|
79
|
+
return m ? { imported: m[1], local: m[2] || m[1], typeOnly } : null;
|
|
78
80
|
})
|
|
79
81
|
.filter(Boolean);
|
|
80
82
|
}
|
|
@@ -93,17 +95,20 @@ export function computeSymbolExternalRefs(filePath, fileSymbols, fileText) {
|
|
|
93
95
|
const name = bareSymbolName(sym.label);
|
|
94
96
|
if (!isIdentifierName(name)) continue;
|
|
95
97
|
const ids = byName.get(name) || [];
|
|
96
|
-
ids.push(sym.id);
|
|
98
|
+
ids.push({id: sym.id, space: sym.symbolSpace || "value"});
|
|
97
99
|
byName.set(name, ids);
|
|
98
100
|
}
|
|
99
101
|
symbolIdsByFileAndName.set(fid, byName);
|
|
100
102
|
}
|
|
101
103
|
|
|
102
104
|
const symbolExternalRefs = new Map();
|
|
103
|
-
const addExternalRefs = (targetFid, importedName, refs) => {
|
|
105
|
+
const addExternalRefs = (targetFid, importedName, refs, typeOnly = false) => {
|
|
104
106
|
if (!targetFid || refs <= 0 || !isIdentifierName(importedName)) return;
|
|
105
107
|
const ids = symbolIdsByFileAndName.get(targetFid)?.get(importedName) || [];
|
|
106
|
-
for (const
|
|
108
|
+
for (const entry of ids) {
|
|
109
|
+
const matches = entry.space === "both" || (typeOnly ? entry.space === "type" : entry.space !== "type");
|
|
110
|
+
if (matches) symbolExternalRefs.set(entry.id, (symbolExternalRefs.get(entry.id) || 0) + refs);
|
|
111
|
+
}
|
|
107
112
|
};
|
|
108
113
|
const resolveImportedFid = (fromPath, spec) => {
|
|
109
114
|
for (const candidate of importCandidates(fromPath, spec)) {
|
|
@@ -123,18 +128,21 @@ export function computeSymbolExternalRefs(filePath, fileSymbols, fileText) {
|
|
|
123
128
|
if (!targetFid) continue;
|
|
124
129
|
const named = String(m[1] || "").match(/\{([\s\S]*?)\}/);
|
|
125
130
|
if (named) {
|
|
126
|
-
|
|
131
|
+
const statementTypeOnly = /^\s*type\b/.test(String(m[1] || ""));
|
|
132
|
+
for (const spec of parseNamedSpecifiers(named[1])) addExternalRefs(targetFid, spec.imported, countIdentifierInText(scrubbed, spec.local), statementTypeOnly || spec.typeOnly);
|
|
127
133
|
}
|
|
128
134
|
const ns = String(m[1] || "").match(/\*\s+as\s+([A-Za-z_$][\w$]*)/);
|
|
129
135
|
if (ns) {
|
|
130
136
|
const byName = symbolIdsByFileAndName.get(targetFid) || new Map();
|
|
131
|
-
|
|
137
|
+
const statementTypeOnly = /^\s*type\b/.test(String(m[1] || ""));
|
|
138
|
+
for (const name of byName.keys()) addExternalRefs(targetFid, name, countMemberAccess(scrubbed, ns[1], name), statementTypeOnly);
|
|
132
139
|
}
|
|
133
140
|
}
|
|
134
|
-
for (const m of String(txt).matchAll(/\bexport\s
|
|
135
|
-
const targetFid = resolveImportedFid(importerPath, m[
|
|
141
|
+
for (const m of String(txt).matchAll(/\bexport\s+(type\s+)?\{([\s\S]*?)\}\s+from\s*['"]([^'"]+)['"]\s*;?/g)) {
|
|
142
|
+
const targetFid = resolveImportedFid(importerPath, m[3]);
|
|
136
143
|
if (!targetFid) continue;
|
|
137
|
-
|
|
144
|
+
const statementTypeOnly = Boolean(m[1]);
|
|
145
|
+
for (const spec of parseNamedSpecifiers(m[2])) addExternalRefs(targetFid, spec.imported, 1, statementTypeOnly || spec.typeOnly);
|
|
138
146
|
}
|
|
139
147
|
for (const m of String(txt).matchAll(/\b(?:const|let|var)\s+\{([\s\S]*?)\}\s*=\s*require\(\s*['"]([^'"]+)['"]\s*\)\s*;?/g)) {
|
|
140
148
|
const targetFid = resolveImportedFid(importerPath, m[2]);
|