weavatrix 0.1.0

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.
Files changed (89) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +146 -0
  3. package/bin/weavatrix-mcp.mjs +6 -0
  4. package/package.json +50 -0
  5. package/skill/SKILL.md +56 -0
  6. package/src/analysis/coverage-reports.js +232 -0
  7. package/src/analysis/dead-check.js +136 -0
  8. package/src/analysis/dep-check.js +310 -0
  9. package/src/analysis/dep-rules.js +221 -0
  10. package/src/analysis/duplicates-worker.js +11 -0
  11. package/src/analysis/duplicates.compute.js +215 -0
  12. package/src/analysis/duplicates.js +15 -0
  13. package/src/analysis/duplicates.run.js +51 -0
  14. package/src/analysis/duplicates.tokenize.js +182 -0
  15. package/src/analysis/endpoints.js +156 -0
  16. package/src/analysis/findings.js +52 -0
  17. package/src/analysis/graph-analysis.aggregate.js +280 -0
  18. package/src/analysis/graph-analysis.js +7 -0
  19. package/src/analysis/graph-analysis.refs.js +146 -0
  20. package/src/analysis/graph-analysis.summaries.js +62 -0
  21. package/src/analysis/internal-audit.collect.js +117 -0
  22. package/src/analysis/internal-audit.js +9 -0
  23. package/src/analysis/internal-audit.reach.js +47 -0
  24. package/src/analysis/internal-audit.run.js +189 -0
  25. package/src/analysis/manifests.js +169 -0
  26. package/src/analysis/source-complexity.ast.js +97 -0
  27. package/src/analysis/source-complexity.constants.js +55 -0
  28. package/src/analysis/source-complexity.js +14 -0
  29. package/src/analysis/source-complexity.report.js +76 -0
  30. package/src/analysis/source-complexity.walk.js +174 -0
  31. package/src/build-graph.js +102 -0
  32. package/src/config.js +5 -0
  33. package/src/graph/build-worker.js +30 -0
  34. package/src/graph/builder/lang-csharp.js +40 -0
  35. package/src/graph/builder/lang-css.js +29 -0
  36. package/src/graph/builder/lang-go.js +53 -0
  37. package/src/graph/builder/lang-html.js +29 -0
  38. package/src/graph/builder/lang-java.js +29 -0
  39. package/src/graph/builder/lang-js.js +168 -0
  40. package/src/graph/builder/lang-python.js +78 -0
  41. package/src/graph/builder/lang-rust.js +45 -0
  42. package/src/graph/builder/spec-pkg.js +87 -0
  43. package/src/graph/graph-filter.js +44 -0
  44. package/src/graph/internal-builder.build.js +217 -0
  45. package/src/graph/internal-builder.js +12 -0
  46. package/src/graph/internal-builder.langs.js +86 -0
  47. package/src/graph/internal-builder.resolvers.js +116 -0
  48. package/src/graph/layout.js +35 -0
  49. package/src/infra/infra-items.js +255 -0
  50. package/src/infra/infra-registry.js +184 -0
  51. package/src/infra/infra.detect.js +119 -0
  52. package/src/infra/infra.js +38 -0
  53. package/src/infra/infra.match.js +102 -0
  54. package/src/infra/infra.scan.js +93 -0
  55. package/src/mcp/catalog.mjs +68 -0
  56. package/src/mcp/graph-context.mjs +276 -0
  57. package/src/mcp/tools-actions.mjs +132 -0
  58. package/src/mcp/tools-graph.mjs +274 -0
  59. package/src/mcp/tools-health.mjs +209 -0
  60. package/src/mcp/tools-impact.mjs +189 -0
  61. package/src/mcp-rg.mjs +51 -0
  62. package/src/mcp-server.mjs +171 -0
  63. package/src/mcp-source-tools.mjs +139 -0
  64. package/src/process.js +77 -0
  65. package/src/scan/discover.inventory.js +78 -0
  66. package/src/scan/discover.js +5 -0
  67. package/src/scan/discover.list.js +79 -0
  68. package/src/scan/discover.stack.js +227 -0
  69. package/src/scan/search.core.js +24 -0
  70. package/src/scan/search.git.js +102 -0
  71. package/src/scan/search.js +9 -0
  72. package/src/scan/search.node.js +83 -0
  73. package/src/scan/search.preview.js +49 -0
  74. package/src/scan/search.rg.js +145 -0
  75. package/src/security/advisory-store.js +177 -0
  76. package/src/security/installed.js +247 -0
  77. package/src/security/malware-file-heuristics.js +121 -0
  78. package/src/security/malware-heuristics.exclusions.js +134 -0
  79. package/src/security/malware-heuristics.js +15 -0
  80. package/src/security/malware-heuristics.roots.js +142 -0
  81. package/src/security/malware-heuristics.scan.js +87 -0
  82. package/src/security/malware-heuristics.sweep.js +122 -0
  83. package/src/security/malware-scoring.js +84 -0
  84. package/src/security/match.js +85 -0
  85. package/src/security/registry-sig.classify.js +136 -0
  86. package/src/security/registry-sig.js +18 -0
  87. package/src/security/registry-sig.rules.js +188 -0
  88. package/src/security/typosquat.js +72 -0
  89. package/src/util.js +27 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sergii Ziborov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,146 @@
1
+ # Weavatrix
2
+
3
+ **Code graph & blast-radius MCP server for AI coding agents.**
4
+
5
+ Grep sees text. Weavatrix sees structure. It builds a dependency graph of any local repository —
6
+ files, symbols, and the imports/calls/inheritance connecting them — and serves it to Claude Code,
7
+ Codex, or any MCP client: change impact, transitive dependents, health audit, clone detection,
8
+ coverage mapping. **23 tools. One server for every repo on your machine. Local-first: your code
9
+ never leaves it.**
10
+
11
+ - Website: [weavatrix.com](https://weavatrix.com)
12
+ - Source: [github.com/sergii-ziborov/weavatrix](https://github.com/sergii-ziborov/weavatrix)
13
+ - Status: **pre-release** — npm publish imminent; API may still shift before v0.1.
14
+
15
+ ## Why
16
+
17
+ An AI agent editing code without the dependency graph is refactoring blind. Weavatrix gives it
18
+ answers grep can't produce:
19
+
20
+ - *"What breaks if I change this?"* → `change_impact` diffs your branch (staged, unstaged and
21
+ untracked included), maps the changed files and symbols onto the graph, and lists everything that
22
+ depends on them — with test coverage attached, so the **untested part of the blast radius** stands
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.
26
+ - *"Did my refactor actually decouple anything?"* → `rebuild_graph` + `graph_diff` report the
27
+ structural delta: new module dependencies, broken or introduced import cycles, symbols that lost
28
+ their last caller.
29
+
30
+ ## Quick start
31
+
32
+ Requires Node ≥ 18. One command:
33
+
34
+ ```sh
35
+ # Claude Code — one user-level registration serves every repo on the machine:
36
+ claude mcp add -s user weavatrix -- npx -y weavatrix <repoRoot>
37
+ ```
38
+
39
+ Codex CLI:
40
+
41
+ ```sh
42
+ codex mcp add weavatrix -- npx -y weavatrix <repoRoot>
43
+ ```
44
+
45
+ ```toml
46
+ # or in ~/.codex/config.toml
47
+ [mcp_servers.weavatrix]
48
+ command = "npx"
49
+ args = ["-y", "weavatrix", "C:/path/to/repo"]
50
+ startup_timeout_sec = 20
51
+ tool_timeout_sec = 60
52
+ ```
53
+
54
+ Or clone it:
55
+
56
+ ```sh
57
+ git clone https://github.com/sergii-ziborov/weavatrix
58
+ cd weavatrix && npm install
59
+ claude mcp add -s user weavatrix -- node <path-to>/weavatrix/bin/weavatrix-mcp.mjs <repoRoot>
60
+ ```
61
+
62
+ - `<repoRoot>` — the repository to start with; the graph location is derived automatically
63
+ (`<repoRoot-parent>/weavatrix-graphs/<repoName>/graph.json`). Pass an explicit
64
+ `<graph.json> <repoRoot>` pair instead if you keep graphs elsewhere.
65
+
66
+ No graph yet? Just ask the agent to call `rebuild_graph` (or `open_repo`) — it builds missing
67
+ graphs itself. From then on, `open_repo` retargets the same running server at **any** other local
68
+ repository; never register a second copy.
69
+
70
+ An agent skill with recipes ships in [skill/SKILL.md](skill/SKILL.md) — install as
71
+ `~/.claude/skills/weavatrix/SKILL.md`.
72
+
73
+ ## Tools
74
+
75
+ **graph** — `graph_stats`, `get_node`, `get_neighbors`, `query_graph`, `god_nodes`,
76
+ `shortest_path`, `get_community`, `list_communities`, `module_map`, `get_dependents`,
77
+ `change_impact`, `graph_diff`, `list_known_repos`
78
+
79
+ **search / source** — `search_code` (ripgrep-backed, pure-Node fallback), `read_source` (a
80
+ symbol's actual code in one hop), `list_endpoints` (HTTP route inventory:
81
+ Express/Fastify/Nest/Flask/FastAPI/Go mux …)
82
+
83
+ **health** — `run_audit` (dead code, unused exports, missing/unused npm/Go/Python deps, import
84
+ cycles, orphans, boundary rules, offline OSV vulnerabilities + typosquat + lockfile drift),
85
+ `find_duplicates` (MOSS winnowing over method bodies — catches copy-paste even after renames),
86
+ `coverage_map` (existing coverage reports mapped onto the graph; untested hotspots ranked by
87
+ connectivity — tests are never executed)
88
+
89
+ **build** — `rebuild_graph` (reports the structural delta, keeps the prior state as
90
+ `graph.prev.json`), `open_repo`
91
+
92
+ **online** *(explicit opt-in — see Privacy)* — `refresh_advisories`, `sync_graph`
93
+
94
+ Quality of life: graph tools self-report staleness vs the repo HEAD; ambiguous name lookups are
95
+ disclosed instead of silently guessed; and the server **hot-reloads its own tool code** when the
96
+ files under `src/mcp/` change — no reconnect needed.
97
+
98
+ ## Privacy: local-first, offline by design
99
+
100
+ Graph queries, audits and clone scans run in-process against local files. Exactly two tools can
101
+ touch the network, both in the `online` capability group and both inert until explicitly invoked:
102
+
103
+ - `refresh_advisories` — queries [OSV.dev](https://osv.dev) with your lockfile's package
104
+ **names + versions** (that is what an OSV query is; never source code) and caches the advisories
105
+ in `~/.weavatrix/advisories.json`. `run_audit` then matches against that store fully offline.
106
+ - `sync_graph` — pushes `graph.json` (file paths, symbol names, edges — never file contents) to an
107
+ endpoint **you** configure via `WEAVATRIX_SYNC_URL` / `WEAVATRIX_SYNC_TOKEN`. Off by default.
108
+
109
+ Capability groups (`graph`, `search`, `source`, `health`, `build`, `online`) are selectable per
110
+ registration via a fifth argument — a comma-separated list. Omit `online` from it and those tools
111
+ don't exist for that client.
112
+
113
+ ## Languages
114
+
115
+ JavaScript · TypeScript · TSX · Python · Go · Java · C# · Rust · HTML · CSS — parsed with
116
+ [web-tree-sitter](https://github.com/tree-sitter/tree-sitter) WASM grammars; no Python install, no
117
+ native compilation.
118
+
119
+ ## On-disk layout
120
+
121
+ Graphs are derived data and never live inside your repo: they go to a `weavatrix-graphs/` folder
122
+ **next to** it (one folder per repo, holding `graph.json` + `graph.prev.json`).
123
+
124
+ ## Development
125
+
126
+ ```sh
127
+ npm install
128
+ npm test # node --test — 224 tests
129
+ ```
130
+
131
+ Design rule: **no source file exceeds 300 lines.** Larger concerns split into dotted-suffix modules
132
+ behind a slim facade (`foo.js` re-exports `foo.parse.js`, `foo.report.js`, …); the MCP layer lives
133
+ in `src/mcp/` (graph context, four tool modules, catalog + hot-reload loader) behind the thin stdio
134
+ entry `src/mcp-server.mjs`.
135
+
136
+ ## Roadmap
137
+
138
+ - **Hosted graph view** on [weavatrix.com](https://weavatrix.com) — sync with one tool call, share
139
+ the interactive map with your team
140
+ - **Graph-anchored memory** — agent notes pinned to nodes/modules, staleness-tracked as the code
141
+ under them changes
142
+ - **CI blast radius** — `change_impact` as a PR comment
143
+
144
+ ## License
145
+
146
+ [MIT](LICENSE) © 2026 Sergii Ziborov
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+ // Weavatrix MCP stdio entry — thin launcher so npm/npx get a shebang'd bin
3
+ // while the server itself stays a plain module. Positional args pass through:
4
+ // weavatrix-mcp <repoRoot> [caps] — graph path derived automatically
5
+ // weavatrix-mcp <graph.json> <repoRoot> [caps] — explicit graph file
6
+ import('../src/mcp-server.mjs')
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "weavatrix",
3
+ "version": "0.1.0",
4
+ "type": "module",
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
+ "author": "Sergii Ziborov <sergii@edgehawk.io>",
7
+ "license": "MIT",
8
+ "homepage": "https://weavatrix.com",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/sergii-ziborov/weavatrix.git"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/sergii-ziborov/weavatrix/issues"
15
+ },
16
+ "mcpName": "io.github.sergii-ziborov/weavatrix",
17
+ "engines": {
18
+ "node": ">=18"
19
+ },
20
+ "bin": {
21
+ "weavatrix-mcp": "bin/weavatrix-mcp.mjs"
22
+ },
23
+ "files": [
24
+ "bin",
25
+ "src",
26
+ "skill",
27
+ "README.md",
28
+ "LICENSE"
29
+ ],
30
+ "scripts": {
31
+ "test": "node --test"
32
+ },
33
+ "keywords": [
34
+ "mcp",
35
+ "model-context-protocol",
36
+ "code-graph",
37
+ "impact-analysis",
38
+ "blast-radius",
39
+ "static-analysis",
40
+ "dead-code",
41
+ "dependency-graph",
42
+ "tree-sitter",
43
+ "claude",
44
+ "ai-agents"
45
+ ],
46
+ "dependencies": {
47
+ "tree-sitter-wasms": "^0.1.13",
48
+ "web-tree-sitter": "^0.25.3"
49
+ }
50
+ }
package/skill/SKILL.md ADDED
@@ -0,0 +1,56 @@
1
+ ---
2
+ name: weavatrix
3
+ description: Drive the weavatrix MCP — code graph, blast-radius (get_dependents/change_impact), health audit, duplicates, coverage, endpoints — over any local repo. Use when analyzing code structure, refactor risk, dead code, dependency health, or before opening a PR.
4
+ ---
5
+
6
+ # weavatrix MCP
7
+
8
+ Structure-analysis tools over a prebuilt code graph plus the weavatrix analysis engines. One running
9
+ server handles ANY local repository via `open_repo` — never register a second weavatrix server for a
10
+ sibling repo.
11
+
12
+ ## Step 0 — if the tools are missing
13
+
14
+ Tools are named `mcp__weavatrix__…`. If none are available, ask the user to register the server
15
+ (`claude mcp add -s user weavatrix -- npx -y weavatrix <repoRoot>`; Codex:
16
+ `codex mcp add weavatrix -- npx -y weavatrix <repoRoot>`), then retry.
17
+
18
+ ## Ground rules
19
+
20
+ - **Freshness**: every graph tool appends a staleness warning when the repo has commits newer than
21
+ the graph. Act on it: `rebuild_graph`.
22
+ - **Ambiguity**: `get_node`/`get_neighbors`/`get_dependents` disclose `matched N nodes; using the
23
+ best-connected` — read that note before trusting the answer; pass an exact node id to pin it.
24
+ - **Offline by design**: scans and graph queries run in-process against local files; coverage tools
25
+ read existing reports and never run tests. The ONLY network-touching tools live in the `online`
26
+ capability group and run solely when explicitly called: `refresh_advisories` (queries OSV.dev with
27
+ package names + versions so `run_audit` has fresh vulnerability data) and `sync_graph` (pushes
28
+ graph.json to a user-configured endpoint; disabled until `WEAVATRIX_SYNC_URL` is set). Registering
29
+ the server with a caps list that omits `online` removes both.
30
+
31
+ ## Recipes
32
+
33
+ - **Orient in an unfamiliar repo**: `open_repo` → `module_map` → `list_communities` → `god_nodes`.
34
+ - **Refactor safety for one symbol**: `get_dependents` → `coverage_map` (low coverage × many
35
+ dependents ⇒ write tests first) → `read_source`.
36
+ - **Pre-PR review of your current changes**: `change_impact` (auto merge-base; includes uncommitted
37
+ and untracked work, coverage attached, untested hotspots called out) → drill with `get_dependents`.
38
+ - **Impact of a PR that is NOT checked out**: pass its changed-file list explicitly —
39
+ `change_impact files=[…]` — same blast-radius + coverage view, no checkout.
40
+ - **Validate a refactor structurally**: edit → `rebuild_graph` — it reports the structural delta
41
+ (cycle broken? new module dependency introduced? symbols orphaned?); re-query later with
42
+ `graph_diff` (optionally scoped by `path`). The semantic complement to the textual git diff.
43
+ - **Health sweep**: `run_audit` (filter by `category`/`min_severity`) → `find_duplicates` →
44
+ `coverage_map`.
45
+ - **API inventory**: `list_endpoints`.
46
+ - **Find code**: `search_code` (regex + glob) → `get_node` → `read_source`.
47
+ - **Another repo**: `list_known_repos` → `open_repo <path>` (builds the graph when missing — minutes
48
+ on large repos; `build:false` probes without building).
49
+
50
+ ## Troubleshooting
51
+
52
+ - `Graph unavailable` → `open_repo` with a valid repo path.
53
+ - `No coverage report` → run the repo's own tests with coverage (`vitest run --coverage`,
54
+ `jest --coverage`, `pytest --cov --cov-report=json`, `go test -coverprofile=coverage.out`),
55
+ then re-call.
56
+ - `change_impact` says files are "not in the graph" → they're new/renamed; `rebuild_graph` and retry.
@@ -0,0 +1,232 @@
1
+ // graph-builder-coverage.js — coverage-report readers for the graph analysis. Parses Istanbul (summary/final),
2
+ // lcov, Python coverage.json, and Go coverage.out into per-file line-hit maps, resolving report paths onto the
3
+ // graph's known files. Split out of graph-builder-analysis.js (pure except for reading the repo's coverage files).
4
+ import { existsSync, readFileSync } from "node:fs";
5
+ import { join, relative, resolve } from "node:path";
6
+
7
+ export function normRepoPath(value) {
8
+ return String(value || "").replace(/\\/g, "/").replace(/^\.\//, "");
9
+ }
10
+
11
+ export function dirOfRepoPath(value) {
12
+ const p = normRepoPath(value);
13
+ const i = p.lastIndexOf("/");
14
+ return i >= 0 ? p.slice(0, i) : "";
15
+ }
16
+
17
+ export function normalizeRepoParts(value) {
18
+ const out = [];
19
+ for (const part of normRepoPath(value).split("/")) {
20
+ if (!part || part === ".") continue;
21
+ if (part === "..") out.pop();
22
+ else out.push(part);
23
+ }
24
+ return out.join("/");
25
+ }
26
+
27
+ function pct01(value) {
28
+ const n = Number(value);
29
+ if (!Number.isFinite(n)) return null;
30
+ return Math.max(0, Math.min(1, n > 1 ? n / 100 : n));
31
+ }
32
+
33
+ export function pctFromCounts(covered, total) {
34
+ const c = Number(covered);
35
+ const t = Number(total);
36
+ if (!Number.isFinite(c) || !Number.isFinite(t) || t <= 0) return null;
37
+ return Math.max(0, Math.min(1, c / t));
38
+ }
39
+
40
+ function resolveCoverageFile(rawPath, knownFiles, repoRoot) {
41
+ const known = (knownFiles || []).map(normalizeRepoParts).filter(Boolean);
42
+ const knownSet = new Set(known);
43
+ const candidates = [];
44
+ const add = (value) => {
45
+ const p = normalizeRepoParts(value);
46
+ if (p && !candidates.includes(p)) candidates.push(p);
47
+ };
48
+ const raw = String(rawPath || "").replace(/^file:\/\//i, "").replace(/[?#].*$/, "");
49
+ const normalizedRaw = normRepoPath(raw);
50
+ add(normalizedRaw);
51
+ add(normalizedRaw.replace(/^\.\//, ""));
52
+ if (repoRoot) {
53
+ try {
54
+ const rel = normRepoPath(relative(repoRoot, raw));
55
+ if (rel && !rel.startsWith("..")) add(rel);
56
+ } catch {
57
+ /* best effort */
58
+ }
59
+ const rootPrefix = `${normRepoPath(resolve(repoRoot)).toLowerCase()}/`;
60
+ const lowerRaw = normalizedRaw.toLowerCase();
61
+ if (lowerRaw.startsWith(rootPrefix)) add(normalizedRaw.slice(rootPrefix.length));
62
+ }
63
+ for (const candidate of candidates) if (knownSet.has(candidate)) return candidate;
64
+ const lower = normalizedRaw.toLowerCase();
65
+ const suffixHit = known
66
+ .slice()
67
+ .sort((a, b) => b.length - a.length)
68
+ .find((file) => lower.endsWith(`/${file.toLowerCase()}`) || lower === file.toLowerCase());
69
+ return suffixHit || candidates[0] || "";
70
+ }
71
+
72
+ function mergeCoverageEntry(map, rawPath, entry, knownFiles, repoRoot) {
73
+ const file = resolveCoverageFile(rawPath, knownFiles, repoRoot);
74
+ if (!file) return;
75
+ const current = map.get(file) || {};
76
+ map.set(file, {
77
+ pct: entry.pct != null ? entry.pct : current.pct ?? null,
78
+ total: entry.total != null ? entry.total : current.total ?? null,
79
+ covered: entry.covered != null ? entry.covered : current.covered ?? null,
80
+ lines: entry.lines || current.lines || null,
81
+ source: current.source && entry.source ? `${current.source}, ${entry.source}` : entry.source || current.source || ""
82
+ });
83
+ }
84
+
85
+ function addLineHit(lines, line, hit) {
86
+ const n = Math.max(1, Math.round(Number(line) || 0));
87
+ if (!n) return;
88
+ const current = lines.get(n);
89
+ lines.set(n, Math.max(Number(current) || 0, Number(hit) || 0));
90
+ }
91
+
92
+ function parseIstanbulSummary(map, filePath, knownFiles, repoRoot) {
93
+ const json = JSON.parse(readFileSync(filePath, "utf8"));
94
+ for (const [raw, value] of Object.entries(json || {})) {
95
+ if (raw === "total" || !value || typeof value !== "object") continue;
96
+ const lines = value.lines || value.statements || {};
97
+ mergeCoverageEntry(map, raw, {
98
+ pct: pct01(lines.pct),
99
+ total: Number.isFinite(Number(lines.total)) ? Number(lines.total) : null,
100
+ covered: Number.isFinite(Number(lines.covered)) ? Number(lines.covered) : null,
101
+ source: "coverage-summary.json"
102
+ }, knownFiles, repoRoot);
103
+ }
104
+ }
105
+
106
+ function parseIstanbulFinal(map, filePath, knownFiles, repoRoot) {
107
+ const json = JSON.parse(readFileSync(filePath, "utf8"));
108
+ for (const [raw, record] of Object.entries(json || {})) {
109
+ if (!record || typeof record !== "object") continue;
110
+ const statementMap = record.statementMap || {};
111
+ const hits = record.s || {};
112
+ const lines = new Map();
113
+ for (const [id, loc] of Object.entries(statementMap)) {
114
+ const hit = Number(hits[id]) || 0;
115
+ const start = Math.max(1, Math.round(Number(loc?.start?.line) || 0));
116
+ const end = Math.max(start, Math.round(Number(loc?.end?.line) || start));
117
+ for (let line = start; line <= end; line++) addLineHit(lines, line, hit);
118
+ }
119
+ const total = lines.size;
120
+ const covered = [...lines.values()].filter((hit) => Number(hit) > 0).length;
121
+ mergeCoverageEntry(map, record.path || raw, {
122
+ pct: pctFromCounts(covered, total),
123
+ total,
124
+ covered,
125
+ lines,
126
+ source: "coverage-final.json"
127
+ }, knownFiles, repoRoot);
128
+ }
129
+ }
130
+
131
+ function parseLcov(map, filePath, knownFiles, repoRoot) {
132
+ const flush = (record) => {
133
+ if (!record || !record.file) return;
134
+ const total = record.total ?? record.lines.size;
135
+ const covered = record.covered ?? [...record.lines.values()].filter((hit) => Number(hit) > 0).length;
136
+ mergeCoverageEntry(map, record.file, {
137
+ pct: pctFromCounts(covered, total),
138
+ total,
139
+ covered,
140
+ lines: record.lines,
141
+ source: "lcov.info"
142
+ }, knownFiles, repoRoot);
143
+ };
144
+ let current = null;
145
+ for (const rawLine of readFileSync(filePath, "utf8").split(/\r?\n/)) {
146
+ const line = rawLine.trim();
147
+ if (line.startsWith("SF:")) {
148
+ flush(current);
149
+ current = { file: line.slice(3), lines: new Map(), total: null, covered: null };
150
+ } else if (current && line.startsWith("DA:")) {
151
+ const [lineNo, count] = line.slice(3).split(",");
152
+ addLineHit(current.lines, lineNo, count);
153
+ } else if (current && line.startsWith("LF:")) {
154
+ current.total = Number(line.slice(3)) || current.total;
155
+ } else if (current && line.startsWith("LH:")) {
156
+ current.covered = Number(line.slice(3)) || current.covered;
157
+ } else if (line === "end_of_record") {
158
+ flush(current);
159
+ current = null;
160
+ }
161
+ }
162
+ flush(current);
163
+ }
164
+
165
+ function parseCoveragePyJson(map, filePath, knownFiles, repoRoot) {
166
+ const json = JSON.parse(readFileSync(filePath, "utf8"));
167
+ for (const [raw, file] of Object.entries(json?.files || {})) {
168
+ const lines = new Map();
169
+ for (const line of file.executed_lines || []) addLineHit(lines, line, 1);
170
+ for (const line of file.missing_lines || []) if (!lines.has(Number(line))) addLineHit(lines, line, 0);
171
+ const summary = file.summary || {};
172
+ const total = Number(summary.num_statements) || lines.size;
173
+ const covered = Number(summary.covered_lines) || [...lines.values()].filter((hit) => Number(hit) > 0).length;
174
+ mergeCoverageEntry(map, raw, {
175
+ pct: pct01(summary.percent_covered ?? summary.percent_covered_display) ?? pctFromCounts(covered, total),
176
+ total,
177
+ covered,
178
+ lines,
179
+ source: "coverage.json"
180
+ }, knownFiles, repoRoot);
181
+ }
182
+ }
183
+
184
+ function parseGoCoverage(map, filePath, knownFiles, repoRoot) {
185
+ const byFile = new Map();
186
+ for (const rawLine of readFileSync(filePath, "utf8").split(/\r?\n/)) {
187
+ const line = rawLine.trim();
188
+ if (!line || line.startsWith("mode:")) continue;
189
+ const m = line.match(/^(.+):(\d+)\.\d+,(\d+)\.\d+\s+\d+\s+(\d+)$/);
190
+ if (!m) continue;
191
+ const record = byFile.get(m[1]) || { lines: new Map() };
192
+ const start = Math.max(1, Number(m[2]) || 1);
193
+ const end = Math.max(start, Number(m[3]) || start);
194
+ for (let n = start; n <= end; n++) addLineHit(record.lines, n, Number(m[4]) || 0);
195
+ byFile.set(m[1], record);
196
+ }
197
+ for (const [raw, record] of byFile) {
198
+ const total = record.lines.size;
199
+ const covered = [...record.lines.values()].filter((hit) => Number(hit) > 0).length;
200
+ mergeCoverageEntry(map, raw, {
201
+ pct: pctFromCounts(covered, total),
202
+ total,
203
+ covered,
204
+ lines: record.lines,
205
+ source: "coverage.out"
206
+ }, knownFiles, repoRoot);
207
+ }
208
+ }
209
+
210
+ export function readCoverageForRepo(repoRoot, knownFiles) {
211
+ const out = new Map();
212
+ if (!repoRoot) return out;
213
+ const candidates = [
214
+ [join(repoRoot, "coverage", "coverage-summary.json"), parseIstanbulSummary],
215
+ [join(repoRoot, "coverage", "coverage-final.json"), parseIstanbulFinal],
216
+ [join(repoRoot, "coverage", "lcov.info"), parseLcov],
217
+ [join(repoRoot, "lcov.info"), parseLcov],
218
+ [join(repoRoot, "coverage", "coverage.json"), parseCoveragePyJson],
219
+ [join(repoRoot, "coverage.json"), parseCoveragePyJson],
220
+ [join(repoRoot, "coverage.out"), parseGoCoverage],
221
+ [join(repoRoot, "cover.out"), parseGoCoverage]
222
+ ];
223
+ for (const [path, parser] of candidates) {
224
+ if (!existsSync(path)) continue;
225
+ try {
226
+ parser(out, path, knownFiles, repoRoot);
227
+ } catch {
228
+ /* malformed/foreign coverage report: ignore */
229
+ }
230
+ }
231
+ return out;
232
+ }
@@ -0,0 +1,136 @@
1
+ // Portable, language-agnostic DEAD-code checker. A symbol is DEAD when it has NO inbound graph edge (nothing
2
+ // calls/imports/references/inherits it) AND its bare name appears NOWHERE in the repo outside its own file.
3
+ // A file is DEAD when nothing imports it, it isn't an entry point, and all its symbols are dead.
4
+ //
5
+ // Pure core `computeDead(graph, sources)` (sources = Map<fileRel, text>) is fully testable with no filesystem.
6
+ // `analyzeDeadCode(graph, repoRoot)` is the thin fs wrapper. Works on ANY graph-builder-schema graph (built-in OR
7
+ // graph-builder) — it only needs {nodes, links} + the source text. See [[graph-builder-internalization]].
8
+ import { readFileSync } from "node:fs";
9
+ import { join } from "node:path";
10
+
11
+ const IDENT_RE = /[A-Za-z_$][\w$]*/g;
12
+ const bareName = (label) => String(label || "").replace(/\s*\(.*$/, "").replace(/[()]/g, "").trim();
13
+ // entry surfaces are never dead even with no inbound edge (framework/CLI/HTTP enter them externally).
14
+ // Exported for internal-audit.js (reachability entry set) — keep the two in lockstep.
15
+ export const ENTRY_FILE = /(^|[\\/])(index|main|app|server|cli|cmd|bootstrap|entry|run|__main__|manage|wsgi|asgi|setup|conftest)\.[a-z0-9]+$|(^|[\\/])(bin|cmd)[\\/]|(^|[\\/])main\.go$/i;
16
+ const TEST_FILE = /(^|[\\/])[^\\/]*[._-](test|spec)\.[a-z0-9]+$|(^|[\\/])(test|tests|__tests__|spec)[\\/]/i;
17
+
18
+ export function computeDead(graph, sources) {
19
+ const nodes = graph.nodes || [], links = graph.links || [];
20
+ const ep = (v) => (v && typeof v === "object" ? v.id : v);
21
+ const inbound = new Set();
22
+ for (const l of links) if (l.relation !== "contains") inbound.add(ep(l.target));
23
+
24
+ // whole-repo identifier frequency: a symbol whose name appears MORE than once total (its definition + at least
25
+ // one use, same-file OR cross-file) is referenced. Errs toward "alive" (common-named symbols never flagged).
26
+ const globalFreq = new Map();
27
+ for (const [, text] of sources) for (const m of String(text || "").matchAll(IDENT_RE)) { const n = m[0]; globalFreq.set(n, (globalFreq.get(n) || 0) + 1); }
28
+
29
+ const symById = new Map();
30
+ const symsByFile = new Map();
31
+ for (const n of nodes) {
32
+ if (!String(n.id).includes("#")) continue;
33
+ symById.set(n.id, n);
34
+ (symsByFile.get(n.source_file) || symsByFile.set(n.source_file, []).get(n.source_file)).push(n);
35
+ }
36
+
37
+ // decorated defs (@app.route/@app.event/@pytest.fixture…) are entered by the framework: trust the
38
+ // builder's flag when present, else walk the source line(s) above the definition (graph-builder graphs).
39
+ const lineCache = new Map();
40
+ const linesOf = (f) => { let l = lineCache.get(f); if (!l) { l = String(sources.get(f) || "").split(/\r?\n/); lineCache.set(f, l); } return l; };
41
+ const symLine = (n) => { const m = /@(\d+)$/.exec(String(n.id)) || /L(\d+)/.exec(String(n.source_location || "")); return m ? Number(m[1]) : 0; };
42
+ const isDecorated = (n) => {
43
+ if (n.decorated) return true;
44
+ const ln = symLine(n);
45
+ if (!ln) return false;
46
+ const lines = linesOf(n.source_file);
47
+ for (let i = ln - 2; i >= 0 && i >= ln - 6; i--) {
48
+ const t = (lines[i] || "").trim();
49
+ if (t.startsWith("@")) return true;
50
+ if (t === "" || t.startsWith("#")) continue;
51
+ break;
52
+ }
53
+ return false;
54
+ };
55
+
56
+ const isReferenced = (n) => {
57
+ if (inbound.has(n.id)) return true; // a real graph edge targets it
58
+ const name = bareName(n.label);
59
+ if (!name || !/^[A-Za-z_$]/.test(name)) return true; // selectors/odd labels → don't flag
60
+ if (/^__\w+__$/.test(name)) return true; // dunders are invoked implicitly (with/str/==/iter…), never spelled
61
+ if ((globalFreq.get(name) || 0) > 1) return true; // name appears beyond its single definition
62
+ return isDecorated(n); // framework-registered via decorator
63
+ };
64
+
65
+ const deadSymbols = [];
66
+ for (const n of symById.values()) {
67
+ if (isReferenced(n)) continue;
68
+ const test = TEST_FILE.test(n.source_file);
69
+ deadSymbols.push({ id: n.id, file: n.source_file, label: n.label, test, reason: "no inbound edge and name unreferenced outside its file" });
70
+ }
71
+ const deadSet = new Set(deadSymbols.map((s) => s.id));
72
+
73
+ const deadFiles = [];
74
+ for (const n of nodes) {
75
+ if (String(n.id).includes("#")) continue; // file nodes only
76
+ if (inbound.has(n.id) || ENTRY_FILE.test(n.source_file)) continue;
77
+ const syms = symsByFile.get(n.source_file) || [];
78
+ if (syms.length && syms.every((s) => deadSet.has(s.id))) deadFiles.push({ file: n.source_file, reason: "not imported; all symbols unreferenced" });
79
+ }
80
+
81
+ return {
82
+ deadSymbols,
83
+ deadFiles,
84
+ referencedIds: [...symById.values()].filter(isReferenced).map((n) => n.id),
85
+ stats: { symbols: symById.size, deadSymbols: deadSymbols.length, files: nodes.filter((n) => !String(n.id).includes("#")).length, deadFiles: deadFiles.length },
86
+ };
87
+ }
88
+
89
+ // Export-scoped unused check (knip's "unused exports"): an EXPORTED symbol (P0's exported:true flag)
90
+ // with no inbound non-contains edge whose bare name occurs in NO file other than its own. Same-file-only
91
+ // usage means the symbol is alive but the export is not — still worth surfacing. Pure like computeDead.
92
+ // dynamicTargets = files reached via dynamic import() — their exports are consumed at runtime, skip them.
93
+ export function computeUnusedExports(graph, sources, { dynamicTargets = new Set() } = {}) {
94
+ const nodes = graph.nodes || [], links = graph.links || [];
95
+ const ep = (v) => (v && typeof v === "object" ? v.id : v);
96
+ const inbound = new Set();
97
+ for (const l of links) if (l.relation !== "contains") inbound.add(ep(l.target));
98
+
99
+ const candidates = [];
100
+ const wanted = new Set();
101
+ for (const n of nodes) {
102
+ if (!n.exported || !String(n.id).includes("#") || inbound.has(n.id)) continue;
103
+ if (ENTRY_FILE.test(n.source_file) || dynamicTargets.has(n.source_file)) continue;
104
+ const nm = bareName(n.label);
105
+ if (!nm || !/^[A-Za-z_$]/.test(nm)) continue;
106
+ candidates.push({ n, nm });
107
+ wanted.add(nm);
108
+ }
109
+ if (!candidates.length) return [];
110
+
111
+ const occursIn = new Map(); // name -> Set(files whose text contains it)
112
+ for (const [file, text] of sources) {
113
+ for (const m of String(text || "").matchAll(IDENT_RE)) {
114
+ const w = m[0];
115
+ if (!wanted.has(w)) continue;
116
+ (occursIn.get(w) || occursIn.set(w, new Set()).get(w)).add(file);
117
+ }
118
+ }
119
+
120
+ const out = [];
121
+ for (const { n, nm } of candidates) {
122
+ const occ = occursIn.get(nm);
123
+ if (occ && [...occ].some((f) => f !== n.source_file)) continue; // referenced beyond its own file → alive
124
+ out.push({ id: n.id, file: n.source_file, label: n.label, test: TEST_FILE.test(n.source_file), reason: "exported but never imported or referenced outside its own file" });
125
+ }
126
+ return out;
127
+ }
128
+
129
+ // fs wrapper: reads each graph file's source once, then runs the pure checker.
130
+ export function analyzeDeadCode(graph, repoRoot) {
131
+ const sources = new Map();
132
+ const files = new Set();
133
+ for (const n of graph.nodes || []) if (n.source_file) files.add(n.source_file);
134
+ for (const f of files) { try { sources.set(f, readFileSync(join(repoRoot, f), "utf8")); } catch { /* file gone */ } }
135
+ return computeDead(graph, sources);
136
+ }