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 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. **32 tools available; 29 enabled by the default offline profile. Local-first: with
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?"* → `get_dependents` walks reverse edges transitively: every caller,
25
- importer and subclass that can feel the refactor, ranked by proximity × connectivity.
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.4: their edges never become `EXACT_LSP`, even when a mixed repository reports a complete
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), `list_endpoints` (HTTP route inventory:
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, functions,
133
- methods, and symbols, with confidence/evidence and explicit public/framework/dynamic caveats),
134
- `run_audit` (unused files/exports/dependencies, missing npm/Go/Python deps, runtime
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), `verify_architecture`,
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.4",
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` and read semantic precision state,
44
- provider and `EXACT_LSP` count. For TypeScript/JavaScript, use an exact node id with
45
- `get_dependents` and inspect the edge provenance; use `find_dead_code` for bounded zero-reference
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.4, so their edges never become `EXACT_LSP` even when a mixed
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
- - **Audit completeness**: read `checks.osv.status` and `checks.malware.status`. For OSV, `OK` is
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` and `coverage_map`. A changed-file-only audit is
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
- for (const l of links) if (!isStructuralRelation(l.relation)) inbound.add(ep(l.target));
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
- { kind: "graph", fact: "No inbound non-structural graph edge targets this symbol." },
100
- { kind: "source-index", fact: "Its identifier has no second indexed occurrence that establishes a caller." },
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: "Confirm with read_source, get_dependents, exact search, framework/config inspection and tests; never auto-delete.",
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 = 30; // fragments below this never enter the index (UI slider min)
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
- if (sim >= FLOOR_SIM && count >= MIN_SHARED_FP) pairs.push([i, j, Math.round(sim * 100)]);
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 < FLOOR_TOKENS) continue;
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 < FLOOR_TOKENS) return;
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 < FLOOR_TOKENS) continue;
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,