weavatrix 0.2.4 → 0.2.5
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 +36 -10
- package/package.json +1 -1
- package/skill/SKILL.md +17 -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/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/graph/builder/lang-js.js +22 -0
- package/src/graph/builder/lang-python.js +161 -4
- package/src/graph/freshness-probe.js +3 -3
- package/src/graph/incremental-refresh.js +1 -1
- package/src/graph/internal-builder.barrels.js +33 -4
- package/src/graph/internal-builder.build.js +20 -5
- package/src/mcp/catalog.mjs +10 -7
- package/src/mcp/sync-payload.mjs +2 -1
- package/src/mcp/tools-actions.mjs +1 -1
- package/src/mcp/tools-health.mjs +66 -5
- package/src/mcp/tools-impact.mjs +3 -3
- package/src/mcp/tools-source.mjs +237 -0
- package/src/precision/lsp-client.js +1 -1
- package/src/precision/lsp-overlay.js +28 -3
- package/src/precision/symbol-query.js +137 -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. **34 tools available; 31 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,9 @@ 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; `get_dependents` walks the symbol-level reverse graph transitively. Opt into
|
|
26
|
+
`include_container_importers:true` only when a broader module-import radius is intended.
|
|
26
27
|
- *"Did my refactor actually decouple anything?"* → `graph_diff base_ref=HEAD~1` builds an immutable
|
|
27
28
|
baseline graph without checking it out, then reports the structural delta: new module
|
|
28
29
|
dependencies, broken or introduced import cycles, and symbols that lost their last caller.
|
|
@@ -117,7 +118,7 @@ confirmed by its bundled `typescript-language-server` + TypeScript runtime to `E
|
|
|
117
118
|
`CONFLICT` means evidence disagrees. `graph_stats` reports the provenance breakdown and the semantic
|
|
118
119
|
provider's `COMPLETE`, `PARTIAL`, `UNAVAILABLE`, or `OFF` state; `OFF` means precision was explicitly
|
|
119
120
|
disabled and only static evidence is active. Java and Rust language-server providers are not bundled
|
|
120
|
-
in 0.2.
|
|
121
|
+
in 0.2.5: their edges never become `EXACT_LSP`, even when a mixed repository reports a complete
|
|
121
122
|
TypeScript/JavaScript overlay.
|
|
122
123
|
|
|
123
124
|
The bounded JS/TS provider is enabled by default for new graphs. Set `WEAVATRIX_PRECISION=off`
|
|
@@ -126,19 +127,27 @@ before starting the MCP server for parser-only operation from the first build, o
|
|
|
126
127
|
choice as **TypeScript/JavaScript semantic precision**.
|
|
127
128
|
|
|
128
129
|
**search / source** — `search_code` (ripgrep-backed, pure-Node fallback), `read_source` (a
|
|
129
|
-
symbol's actual code in one hop), `
|
|
130
|
+
symbol's actual code in one hop), `inspect_symbol` (one exact bounded TS/JS reference query,
|
|
131
|
+
logical containers, impact and source excerpts), `list_endpoints` (HTTP route inventory:
|
|
130
132
|
Express/Fastify/Nest/Flask/FastAPI/Go mux/Rust axum and actix-web …)
|
|
131
133
|
|
|
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
|
|
134
|
+
**health** — `find_dead_code` (bounded review queue for statically unreferenced and test-only files,
|
|
135
|
+
functions, methods, and symbols, with confidence/evidence and explicit public/framework/dynamic caveats),
|
|
136
|
+
`run_audit` (unused files/exports/dependencies with an explicit manifest-check summary,
|
|
137
|
+
missing npm/Go/Python deps, runtime
|
|
135
138
|
cycles, type-only/compile-only coupling, orphans, boundary rules, offline OSV vulnerabilities + typosquat +
|
|
136
139
|
lockfile drift; accepts an immutable `base_ref`, `changed_files`, and `debt: new|existing|all` for
|
|
137
140
|
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), `
|
|
141
|
+
after renames and can inspect strict small clones down to 12 tokens), `coverage_map` (existing coverage reports mapped onto the graph; untested hotspots
|
|
142
|
+
ranked by connectivity — tests are never executed), `hot_path_review` (bounded local-cost evidence
|
|
143
|
+
with separate graph/test risk), `verify_architecture`,
|
|
140
144
|
`explain_architecture_violation`, `propose_architecture_exception`
|
|
141
145
|
|
|
146
|
+
`hot_path_review` ranks production symbols using parser-derived local time, memory, cyclomatic and
|
|
147
|
+
call-site facts plus exact inside-loop allocation, copy, scan, sort and recursion evidence. Graph
|
|
148
|
+
fan-in/fan-out and measured coverage (or explicitly labelled static test reachability) remain
|
|
149
|
+
separate from local syntax cost. The ranking is not profiler data or interprocedural Big-O.
|
|
150
|
+
|
|
142
151
|
**build** — `rebuild_graph` (reports the structural delta, keeps the prior state as
|
|
143
152
|
`graph.prev.json`)
|
|
144
153
|
|
|
@@ -184,6 +193,23 @@ disclosed instead of silently guessed; and the server **hot-reloads its watched
|
|
|
184
193
|
modules and catalog** when those files change — other MCP helpers and analysis engines require a
|
|
185
194
|
reconnect.
|
|
186
195
|
|
|
196
|
+
### 0.2.5 exact-symbol and graph-fidelity patch
|
|
197
|
+
|
|
198
|
+
- New `inspect_symbol` spends a bounded LSP query on one requested TS/JS declaration, groups exact
|
|
199
|
+
occurrences by logical container, and returns definition/caller source context. Its separate LRU
|
|
200
|
+
cache is revision/config fingerprinted and never replaces the broad precision overlay.
|
|
201
|
+
- Default-object service facades resolve to their underlying helpers, while `get_dependents` no
|
|
202
|
+
longer silently expands every symbol query to all importers of its file.
|
|
203
|
+
- Python receiver-aware method dispatch and unambiguous wildcard imports distinguish same-named
|
|
204
|
+
methods across classes without promoting ambiguous dynamic evidence.
|
|
205
|
+
- `run_audit` makes unused-dependency checking visible even when clean; dead-code review identifies
|
|
206
|
+
production symbols referenced only by tests; small-clone scanning can be lowered to 12 tokens with
|
|
207
|
+
stricter evidence rules.
|
|
208
|
+
- `hot_path_review` adds a bounded offline performance-review queue with line-addressable local cost
|
|
209
|
+
evidence, separate graph risk, and honest measured/unavailable coverage states.
|
|
210
|
+
|
|
211
|
+
Full patch notes: [docs/releases/v0.2.5.md](docs/releases/v0.2.5.md).
|
|
212
|
+
|
|
187
213
|
### 0.2.4 graph-mode correctness and semantic precision patch
|
|
188
214
|
|
|
189
215
|
- `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.5",
|
|
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,13 @@ 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 `inspect_symbol` with an
|
|
44
|
+
exact node ID (or an unambiguous label). It spends a bounded point query beyond the broad overlay
|
|
45
|
+
cap and returns occurrence containers plus source context. Use `get_dependents` for transitive
|
|
46
|
+
graph impact and `find_dead_code` for bounded zero-reference
|
|
46
47
|
candidates. Treat `PARTIAL`, `UNAVAILABLE`, `OFF`, or zero exact edges as incomplete evidence, not
|
|
47
48
|
a compiler-exact result; `OFF` means the caller explicitly selected static-only mode. Java and Rust
|
|
48
|
-
providers are not bundled in 0.2.
|
|
49
|
+
providers are not bundled in 0.2.5, so their edges never become `EXACT_LSP` even when a mixed
|
|
49
50
|
repository has a complete TypeScript/JavaScript overlay.
|
|
50
51
|
- **Explore an architectural question**: use broad `query_graph` when entry points are unknown; its
|
|
51
52
|
bootstrap/tool-execution ranking prefers production executables over docs, sites and fixtures.
|
|
@@ -89,7 +90,13 @@ Start from the task, not from the complete tool list:
|
|
|
89
90
|
`seed_files` to `query_graph` when the intended entry points are already known.
|
|
90
91
|
- **Coverage**: `coverage_map` reads an existing report. `unavailable` means no supported report was
|
|
91
92
|
found, not 0% coverage; do not rank testing risk from that state.
|
|
92
|
-
- **
|
|
93
|
+
- **Local hot paths**: `hot_path_review` ranks parser-derived local syntax cost and reports graph
|
|
94
|
+
fan-in/fan-out plus test evidence separately. `actualCoverage: NOT_AVAILABLE` is not zero coverage,
|
|
95
|
+
and the score is not profiler data or an interprocedural Big-O proof. Inspect its line evidence and
|
|
96
|
+
measure runtime before scheduling a performance rewrite.
|
|
97
|
+
- **Audit completeness**: read `dependencyReport.status` plus its unused/missing counts, then
|
|
98
|
+
`checks.osv.status` and `checks.malware.status`. A dependency result from a partial graph is not a
|
|
99
|
+
repository-wide clean zero. For OSV, `OK` is
|
|
93
100
|
complete for the recorded dependency fingerprint; `PARTIAL` is incomplete or stale,
|
|
94
101
|
`NOT_CHECKED` has no repository-specific result, and `ERROR` means the local check failed. Treat
|
|
95
102
|
the same non-`OK` states as incomplete for malware scanning. Refresh OSV only when the user has
|
|
@@ -118,8 +125,10 @@ Start from the task, not from the complete tool list:
|
|
|
118
125
|
- **Orient in the configured repo**: `module_map` → `list_communities` → `god_nodes`. Hub ranking
|
|
119
126
|
is production-only by default; use `include_classified:true` only when tests/generated/build
|
|
120
127
|
surfaces are deliberately part of the question.
|
|
121
|
-
- **Refactor safety for one symbol**: `get_dependents` → `coverage_map` (low coverage × many
|
|
128
|
+
- **Refactor safety for one symbol**: `inspect_symbol` → `get_dependents` → `coverage_map` (low coverage × many
|
|
122
129
|
dependents ⇒ write tests first) → `read_source`.
|
|
130
|
+
- **Performance review**: `hot_path_review` → inspect its local evidence with `read_source` → use
|
|
131
|
+
`get_dependents` for change risk → confirm with the repository's profiler/benchmark before editing.
|
|
123
132
|
- **Pre-PR review of your current changes**: `change_impact` (auto merge-base; includes uncommitted
|
|
124
133
|
and untracked work, coverage attached, untested hotspots called out) → drill with `get_dependents`.
|
|
125
134
|
- **Impact of a PR that is NOT checked out**: pass its changed-file list explicitly —
|
|
@@ -131,7 +140,8 @@ Start from the task, not from the complete tool list:
|
|
|
131
140
|
orphan drift rather than raw edge counts.
|
|
132
141
|
- **Health sweep**: for a branch/PR review prefer
|
|
133
142
|
`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
|
|
143
|
+
`run_audit debt=all`. Then call `find_dead_code`, `find_duplicates`, `coverage_map` and
|
|
144
|
+
`hot_path_review`. A changed-file-only audit is
|
|
135
145
|
scope, not proof of new debt. Inspect the source behind shortlisted findings before proposing edits.
|
|
136
146
|
- **Dead-code and dead-method review**: call `find_dead_code` for a bounded production-code queue of
|
|
137
147
|
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
|
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// Bounded local hot-path review over parser-derived complexity facts. This deliberately keeps
|
|
2
|
+
// local syntax cost, graph coupling and test evidence separate: it is a review queue, not a runtime
|
|
3
|
+
// profiler or an interprocedural Big-O proof.
|
|
4
|
+
import {readCoverageForRepo, normalizeRepoParts} from './coverage-reports.js'
|
|
5
|
+
import {computeStaticTestReachability} from './static-test-reachability.js'
|
|
6
|
+
import {createPathClassifier, hasPathClass} from '../path-classification.js'
|
|
7
|
+
import {isStructuralRelation} from '../graph/relations.js'
|
|
8
|
+
|
|
9
|
+
const NON_PRODUCT = ['generated', 'mock', 'story', 'docs', 'benchmark', 'temp']
|
|
10
|
+
const endpoint = (value) => String(value && typeof value === 'object' ? value.id : value || '')
|
|
11
|
+
const normalize = (value) => String(value || '').replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '')
|
|
12
|
+
const boundedInt = (value, fallback, min, max) => {
|
|
13
|
+
const number = Number(value)
|
|
14
|
+
return Number.isInteger(number) ? Math.max(min, Math.min(max, number)) : fallback
|
|
15
|
+
}
|
|
16
|
+
const round = (value) => Math.round(Number(value || 0) * 100) / 100
|
|
17
|
+
const clamp = (value, min = 0, max = 100) => Math.max(min, Math.min(max, Number(value) || 0))
|
|
18
|
+
|
|
19
|
+
function normalizeScope(value) {
|
|
20
|
+
const path = normalize(value)
|
|
21
|
+
if (!path) return {ok: true, path: ''}
|
|
22
|
+
if (path.includes('\0') || /^[a-z]:\//i.test(path) || path.startsWith('/') || path.split('/').includes('..')) {
|
|
23
|
+
return {ok: false, error: 'path must be repository-relative and cannot contain traversal segments'}
|
|
24
|
+
}
|
|
25
|
+
return {ok: true, path}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function coverageForRange(record, startLine, endLine) {
|
|
29
|
+
if (!record) return null
|
|
30
|
+
if (!(record.lines instanceof Map) || !record.lines.size) return Number.isFinite(record.pct) ? record.pct : null
|
|
31
|
+
let total = 0
|
|
32
|
+
let covered = 0
|
|
33
|
+
for (let line = Math.max(1, startLine); line <= Math.max(startLine, endLine); line++) {
|
|
34
|
+
if (!record.lines.has(line)) continue
|
|
35
|
+
total++
|
|
36
|
+
if (Number(record.lines.get(line)) > 0) covered++
|
|
37
|
+
}
|
|
38
|
+
return total ? covered / total : Number.isFinite(record.pct) ? record.pct : null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function graphRiskBySymbol(graph, symbolIds) {
|
|
42
|
+
const state = new Map([...symbolIds].map((id) => [id, {
|
|
43
|
+
incoming: 0, outgoing: 0, callers: new Set(), callees: new Set(),
|
|
44
|
+
}]))
|
|
45
|
+
for (const link of graph?.links || []) {
|
|
46
|
+
if (!link || link.typeOnly === true || link.compileOnly === true || link.barrelProxy === true || isStructuralRelation(link.relation)) continue
|
|
47
|
+
const source = endpoint(link.source)
|
|
48
|
+
const target = endpoint(link.target)
|
|
49
|
+
if (!source || !target || source === target) continue
|
|
50
|
+
if (state.has(source)) {
|
|
51
|
+
const item = state.get(source)
|
|
52
|
+
item.outgoing++
|
|
53
|
+
item.callees.add(target)
|
|
54
|
+
}
|
|
55
|
+
if (state.has(target)) {
|
|
56
|
+
const item = state.get(target)
|
|
57
|
+
item.incoming++
|
|
58
|
+
item.callers.add(source)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return new Map([...state].map(([id, item]) => [id, {
|
|
62
|
+
fanIn: item.callers.size,
|
|
63
|
+
fanOut: item.callees.size,
|
|
64
|
+
incomingEdges: item.incoming,
|
|
65
|
+
outgoingEdges: item.outgoing,
|
|
66
|
+
}]))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function localSyntaxScore(complexity, thresholds) {
|
|
70
|
+
const insideLoop = Number(complexity.allocationsInLoops || 0)
|
|
71
|
+
+ Number(complexity.copiesInLoops || 0)
|
|
72
|
+
+ Number(complexity.linearOpsInLoops || 0)
|
|
73
|
+
+ Number(complexity.sortsInLoops || 0) * 2
|
|
74
|
+
+ Number(complexity.recursionInLoops || 0) * 2
|
|
75
|
+
const score = Number(complexity.timeRank || 0) * 12
|
|
76
|
+
+ Number(complexity.memoryRank || 0) * 5
|
|
77
|
+
+ Math.min(18, Number(complexity.cyclomatic || 1) / thresholds.cyclomatic * 10)
|
|
78
|
+
+ Math.min(12, Number(complexity.callCount || 0) / thresholds.calls * 6)
|
|
79
|
+
+ Math.min(18, Number(complexity.maxLoopDepth || 0) * 5)
|
|
80
|
+
+ Math.min(24, insideLoop * 5)
|
|
81
|
+
+ (complexity.recursion ? 6 : 0)
|
|
82
|
+
return round(clamp(score))
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function couplingScore(risk) {
|
|
86
|
+
return round(clamp(Math.log2(1 + risk.fanIn) * 20 + Math.log2(1 + risk.fanOut) * 10))
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function reasonsFor(complexity, thresholds) {
|
|
90
|
+
const reasons = []
|
|
91
|
+
if (Number(complexity.timeRank || 0) >= thresholds.timeRank) reasons.push(String(complexity.timeLabel || `local time rank ${complexity.timeRank}`))
|
|
92
|
+
if (Number(complexity.cyclomatic || 0) >= thresholds.cyclomatic) reasons.push(`cyclomatic ${complexity.cyclomatic} >= ${thresholds.cyclomatic}`)
|
|
93
|
+
if (Number(complexity.callCount || 0) >= thresholds.calls) reasons.push(`${complexity.callCount} local call sites >= ${thresholds.calls}`)
|
|
94
|
+
if (Number(complexity.maxLoopDepth || 0) >= thresholds.loopDepth) reasons.push(`loop depth ${complexity.maxLoopDepth} >= ${thresholds.loopDepth}`)
|
|
95
|
+
if (complexity.allocationsInLoops) reasons.push(`${complexity.allocationsInLoops} allocation(s) inside iteration`)
|
|
96
|
+
if (complexity.copiesInLoops) reasons.push(`${complexity.copiesInLoops} copy/copies inside iteration`)
|
|
97
|
+
if (complexity.linearOpsInLoops) reasons.push(`${complexity.linearOpsInLoops} linear operation(s) inside iteration`)
|
|
98
|
+
if (complexity.sortsInLoops) reasons.push(`${complexity.sortsInLoops} sort(s) inside iteration`)
|
|
99
|
+
if (complexity.recursionInLoops) reasons.push(`${complexity.recursionInLoops} recursive call(s) inside iteration`)
|
|
100
|
+
if (complexity.recursion) reasons.push('direct recursion; bound remains unknown')
|
|
101
|
+
return [...new Set(reasons)].slice(0, 12)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function computeHotPathReview(graph, options = {}) {
|
|
105
|
+
const scope = normalizeScope(options.path)
|
|
106
|
+
if (!scope.ok) return {ok: false, error: scope.error}
|
|
107
|
+
const thresholds = {
|
|
108
|
+
cyclomatic: boundedInt(options.cyclomaticThreshold, 8, 2, 1000),
|
|
109
|
+
calls: boundedInt(options.callThreshold, 12, 1, 10000),
|
|
110
|
+
loopDepth: boundedInt(options.loopDepthThreshold, 2, 1, 10),
|
|
111
|
+
timeRank: boundedInt(options.timeRankThreshold, 2, 0, 5),
|
|
112
|
+
minScore: boundedInt(options.minScore, 0, 0, 100),
|
|
113
|
+
}
|
|
114
|
+
const topN = boundedInt(options.topN, 20, 1, 100)
|
|
115
|
+
const classifier = createPathClassifier(options.repoRoot || null)
|
|
116
|
+
const knownFiles = [...new Set((graph?.nodes || []).map((node) => normalize(node?.source_file)).filter(Boolean))]
|
|
117
|
+
const measuredCoverage = options.repoRoot ? readCoverageForRepo(options.repoRoot, knownFiles) : new Map()
|
|
118
|
+
const coverageSources = [...new Set([...measuredCoverage.values()].map((item) => item?.source).filter(Boolean))].sort()
|
|
119
|
+
const staticTests = measuredCoverage.size || !options.repoRoot
|
|
120
|
+
? null
|
|
121
|
+
: computeStaticTestReachability(graph, {repoRoot: options.repoRoot, path: scope.path})
|
|
122
|
+
const staticByFile = new Map((staticTests?.reachable || []).map((item) => [normalize(item.file), item]))
|
|
123
|
+
|
|
124
|
+
const eligible = []
|
|
125
|
+
const excluded = {tests: 0, classified: 0, outOfScope: 0}
|
|
126
|
+
for (const node of graph?.nodes || []) {
|
|
127
|
+
const complexity = node?.complexity
|
|
128
|
+
const file = normalize(node?.source_file)
|
|
129
|
+
if (!complexity || !file || !String(node?.id || '').includes('#')) continue
|
|
130
|
+
if (scope.path && file !== scope.path && !file.startsWith(`${scope.path}/`)) { excluded.outOfScope++; continue }
|
|
131
|
+
const classification = classifier.explain(file)
|
|
132
|
+
const test = hasPathClass(classification, 'test', 'e2e')
|
|
133
|
+
const classified = classification.excluded || hasPathClass(classification, ...NON_PRODUCT)
|
|
134
|
+
if (test && options.includeTests !== true) { excluded.tests++; continue }
|
|
135
|
+
if (classified && options.includeClassified !== true) { excluded.classified++; continue }
|
|
136
|
+
eligible.push({node, complexity, file, classification: test ? 'test' : classified ? 'classified' : 'production'})
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const riskById = graphRiskBySymbol(graph, new Set(eligible.map(({node}) => String(node.id))))
|
|
140
|
+
const candidates = []
|
|
141
|
+
for (const entry of eligible) {
|
|
142
|
+
const {node, complexity, file} = entry
|
|
143
|
+
const reasons = reasonsFor(complexity, thresholds)
|
|
144
|
+
if (!reasons.length) continue
|
|
145
|
+
const risk = riskById.get(String(node.id)) || {fanIn: 0, fanOut: 0, incomingEdges: 0, outgoingEdges: 0}
|
|
146
|
+
const syntaxScore = localSyntaxScore(complexity, thresholds)
|
|
147
|
+
const graphScore = couplingScore(risk)
|
|
148
|
+
const startLine = Number(complexity.startLine) || Number(String(node.source_location || '').replace(/^L/, '')) || 0
|
|
149
|
+
const endLine = Number(complexity.endLine) || Number(String(node.source_end || '').replace(/^L/, '')) || startLine
|
|
150
|
+
const coverageRecord = measuredCoverage.get(normalizeRepoParts(file)) || null
|
|
151
|
+
const actualCoverage = coverageForRange(coverageRecord, startLine, endLine)
|
|
152
|
+
const nearest = staticByFile.get(file)?.nearestTests?.[0] || null
|
|
153
|
+
const coverageRisk = actualCoverage == null ? 0 : (1 - actualCoverage) * 100
|
|
154
|
+
const score = round(clamp(syntaxScore * 0.72 + graphScore * 0.2 + coverageRisk * 0.08))
|
|
155
|
+
if (score < thresholds.minScore) continue
|
|
156
|
+
const directHotEvidence = Array.isArray(complexity.hotEvidence) ? complexity.hotEvidence.slice(0, 12) : []
|
|
157
|
+
const confidence = directHotEvidence.length ? 'HIGH' : complexity.recursion ? 'LOW' : 'MEDIUM'
|
|
158
|
+
candidates.push({
|
|
159
|
+
id: String(node.id),
|
|
160
|
+
label: String(node.label || node.norm_label || node.id),
|
|
161
|
+
kind: String(node.symbol_kind || 'symbol'),
|
|
162
|
+
file,
|
|
163
|
+
startLine,
|
|
164
|
+
endLine,
|
|
165
|
+
classification: entry.classification,
|
|
166
|
+
score,
|
|
167
|
+
confidence,
|
|
168
|
+
localSyntax: {
|
|
169
|
+
score: syntaxScore,
|
|
170
|
+
timeRank: Number(complexity.timeRank || 0),
|
|
171
|
+
timeLabel: String(complexity.timeLabel || ''),
|
|
172
|
+
memoryRank: Number(complexity.memoryRank || 0),
|
|
173
|
+
memoryLabel: String(complexity.memoryLabel || ''),
|
|
174
|
+
cyclomatic: Number(complexity.cyclomatic || 0),
|
|
175
|
+
calls: Number(complexity.callCount || 0),
|
|
176
|
+
loops: Number(complexity.loops || 0),
|
|
177
|
+
maxLoopDepth: Number(complexity.maxLoopDepth || 0),
|
|
178
|
+
allocationsInLoops: Number(complexity.allocationsInLoops || 0),
|
|
179
|
+
copiesInLoops: Number(complexity.copiesInLoops || 0),
|
|
180
|
+
linearOpsInLoops: Number(complexity.linearOpsInLoops || 0),
|
|
181
|
+
sortsInLoops: Number(complexity.sortsInLoops || 0),
|
|
182
|
+
recursionInLoops: Number(complexity.recursionInLoops || 0),
|
|
183
|
+
recursion: complexity.recursion === true,
|
|
184
|
+
},
|
|
185
|
+
graphRisk: {...risk, score: graphScore},
|
|
186
|
+
testEvidence: actualCoverage != null
|
|
187
|
+
? {actualCoverage, source: coverageRecord?.source || 'coverage report'}
|
|
188
|
+
: nearest
|
|
189
|
+
? {actualCoverage: 'NOT_AVAILABLE', staticReachable: true, nearestTest: nearest.test, distance: nearest.distance, confidence: nearest.confidence}
|
|
190
|
+
: {actualCoverage: 'NOT_AVAILABLE', staticReachable: false},
|
|
191
|
+
reasons,
|
|
192
|
+
sourceEvidence: directHotEvidence,
|
|
193
|
+
})
|
|
194
|
+
}
|
|
195
|
+
candidates.sort((left, right) => right.score - left.score
|
|
196
|
+
|| right.localSyntax.score - left.localSyntax.score
|
|
197
|
+
|| right.graphRisk.fanIn - left.graphRisk.fanIn
|
|
198
|
+
|| left.id.localeCompare(right.id))
|
|
199
|
+
const hotspots = candidates.slice(0, topN)
|
|
200
|
+
return {
|
|
201
|
+
ok: true,
|
|
202
|
+
modelVersion: 1,
|
|
203
|
+
complexityVersion: Number(graph?.complexityV) || 0,
|
|
204
|
+
scope: {
|
|
205
|
+
path: scope.path || null,
|
|
206
|
+
includeTests: options.includeTests === true,
|
|
207
|
+
includeClassified: options.includeClassified === true,
|
|
208
|
+
},
|
|
209
|
+
thresholds,
|
|
210
|
+
analyzedSymbols: eligible.length,
|
|
211
|
+
candidateSymbols: candidates.length,
|
|
212
|
+
coverage: measuredCoverage.size
|
|
213
|
+
? {actualCoverage: 'AVAILABLE', measuredFiles: measuredCoverage.size, sources: coverageSources}
|
|
214
|
+
: {actualCoverage: 'NOT_AVAILABLE', staticReachability: staticTests ? {
|
|
215
|
+
reachableFiles: staticTests.reachableFiles,
|
|
216
|
+
productFiles: staticTests.productFiles,
|
|
217
|
+
truncated: staticTests.bounds.truncated,
|
|
218
|
+
} : null},
|
|
219
|
+
excluded,
|
|
220
|
+
hotspots,
|
|
221
|
+
bounds: {topN, returned: hotspots.length, totalCandidates: candidates.length, truncated: candidates.length > hotspots.length},
|
|
222
|
+
caveats: [
|
|
223
|
+
'Scores rank parser-derived local syntax cost; they are not profiler measurements.',
|
|
224
|
+
'Graph risk is reported separately and does not propagate loop complexity through callees.',
|
|
225
|
+
'Recursion bounds, CFG reachability, dead stores and taint flow are not inferred by this model.',
|
|
226
|
+
],
|
|
227
|
+
}
|
|
228
|
+
}
|
|
@@ -139,6 +139,21 @@ export async function runInternalAudit(repoPath, { graph, advisoryStorePath, ski
|
|
|
139
139
|
}));
|
|
140
140
|
unusedExportCount++;
|
|
141
141
|
}
|
|
142
|
+
for (const symbol of dead.testOnlySymbols || []) {
|
|
143
|
+
if (isNonProductPath(symbol.file)) continue;
|
|
144
|
+
findings.push(makeFinding({
|
|
145
|
+
category: "unused",
|
|
146
|
+
rule: "test-only-symbol",
|
|
147
|
+
severity: "low",
|
|
148
|
+
confidence: symbol.publicApi ? "low" : "medium",
|
|
149
|
+
title: `Production symbol used only by tests: ${symbol.label}`,
|
|
150
|
+
detail: `${symbol.reason}. Static analysis cannot rule out external or reflective consumers, so review before removal.`,
|
|
151
|
+
file: symbol.file,
|
|
152
|
+
graphNodeId: symbol.id,
|
|
153
|
+
source: "internal",
|
|
154
|
+
fixHint: "verify production/config consumers, then keep as intentional test support or remove it together with obsolete tests",
|
|
155
|
+
}));
|
|
156
|
+
}
|
|
142
157
|
|
|
143
158
|
// ---- supply-chain: installed packages × cached OSV advisories. 100% OFFLINE here — the cache is
|
|
144
159
|
// refreshed only by the explicit repos:advisory-refresh action. Never blocks the rest of the audit.
|
|
@@ -247,6 +262,13 @@ export async function runInternalAudit(repoPath, { graph, advisoryStorePath, ski
|
|
|
247
262
|
}
|
|
248
263
|
|
|
249
264
|
const sorted = sortFindings(findings);
|
|
265
|
+
const dependencyFindings = sorted.filter((finding) => ["unused-dep", "missing-dep", "duplicate-dep"].includes(finding.rule));
|
|
266
|
+
const dependencyStatus = (graph.graphBuildMode && graph.graphBuildMode !== "full") || graph.graphBuildScope
|
|
267
|
+
? "PARTIAL"
|
|
268
|
+
: "COMPLETE";
|
|
269
|
+
const importedPackages = new Set(externalImports
|
|
270
|
+
.filter((entry) => entry?.pkg && !entry.builtin && !entry.unresolved)
|
|
271
|
+
.map((entry) => `${entry.ecosystem || "npm"}:${entry.pkg}`));
|
|
250
272
|
return {
|
|
251
273
|
ok: true,
|
|
252
274
|
engine: "internal",
|
|
@@ -276,10 +298,24 @@ export async function runInternalAudit(repoPath, { graph, advisoryStorePath, ski
|
|
|
276
298
|
},
|
|
277
299
|
summary: summarizeFindings(sorted),
|
|
278
300
|
findings: sorted,
|
|
301
|
+
dependencyReport: {
|
|
302
|
+
status: dependencyStatus,
|
|
303
|
+
declared: dep.declared.size + goDep.declared.size + pyDep.declared.size,
|
|
304
|
+
importedPackages: importedPackages.size,
|
|
305
|
+
importRecords: externalImports.length,
|
|
306
|
+
unused: dependencyFindings.filter((finding) => finding.rule === "unused-dep").length,
|
|
307
|
+
missing: dependencyFindings.filter((finding) => finding.rule === "missing-dep").length,
|
|
308
|
+
duplicateDeclarations: dependencyFindings.filter((finding) => finding.rule === "duplicate-dep").length,
|
|
309
|
+
packageScopes: packageScopes.length,
|
|
310
|
+
reason: dependencyStatus === "COMPLETE"
|
|
311
|
+
? "All discovered dependency manifests were compared with the complete indexed import set."
|
|
312
|
+
: "The dependency result is scoped to a partial graph and is not a repository-wide clean bill.",
|
|
313
|
+
},
|
|
279
314
|
deadReport: {
|
|
280
315
|
deadSymbols: dead.deadSymbols.filter((symbol) => !isNonProductPath(symbol.file)).length,
|
|
281
316
|
deadFiles: actionableDeadFiles.length,
|
|
282
317
|
unusedExports: unusedExportCount,
|
|
318
|
+
testOnlySymbols: (dead.testOnlySymbols || []).filter((symbol) => !isNonProductPath(symbol.file)).length,
|
|
283
319
|
},
|
|
284
320
|
conventionReachability: {
|
|
285
321
|
count: conventionEvidence.length,
|