weavatrix 0.1.2 → 0.1.4

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 (37) hide show
  1. package/README.md +92 -17
  2. package/package.json +1 -1
  3. package/skill/SKILL.md +62 -4
  4. package/src/analysis/dead-check.js +87 -6
  5. package/src/analysis/dep-check-ecosystems.js +157 -0
  6. package/src/analysis/dep-check.js +97 -133
  7. package/src/analysis/dep-rules.js +91 -12
  8. package/src/analysis/duplicates.compute.js +12 -18
  9. package/src/analysis/endpoints-rust.js +124 -0
  10. package/src/analysis/endpoints.js +56 -6
  11. package/src/analysis/graph-analysis.aggregate.js +34 -25
  12. package/src/analysis/graph-analysis.edges.js +21 -0
  13. package/src/analysis/graph-analysis.js +1 -1
  14. package/src/analysis/graph-analysis.summaries.js +4 -1
  15. package/src/analysis/internal-audit.collect.js +162 -25
  16. package/src/analysis/internal-audit.reach.js +52 -11
  17. package/src/analysis/internal-audit.run.js +71 -18
  18. package/src/graph/builder/lang-java.js +177 -14
  19. package/src/graph/builder/lang-js.js +66 -23
  20. package/src/graph/builder/lang-rust.js +129 -6
  21. package/src/graph/community.js +17 -0
  22. package/src/graph/internal-builder.build.js +79 -17
  23. package/src/graph/internal-builder.java.js +33 -0
  24. package/src/graph/internal-builder.langs.js +43 -2
  25. package/src/graph/internal-builder.resolvers.js +197 -21
  26. package/src/graph/relations.js +4 -0
  27. package/src/mcp/catalog.mjs +4 -4
  28. package/src/mcp/graph-context.mjs +22 -94
  29. package/src/mcp/graph-diff.mjs +163 -0
  30. package/src/mcp/sync-payload.mjs +36 -6
  31. package/src/mcp/tools-actions.mjs +22 -7
  32. package/src/mcp/tools-graph-hubs.mjs +58 -0
  33. package/src/mcp/tools-graph.mjs +13 -22
  34. package/src/mcp/tools-health.mjs +54 -18
  35. package/src/mcp/tools-impact.mjs +61 -45
  36. package/src/mcp-server.mjs +3 -0
  37. package/src/security/advisory-store.js +51 -7
package/README.md CHANGED
@@ -77,10 +77,12 @@ claude mcp add -s user weavatrix -- node <path-to>/weavatrix/bin/weavatrix-mcp.m
77
77
  (`<repoRoot-parent>/weavatrix-graphs/<repoName>/graph.json`). Pass an explicit
78
78
  `<graph.json> <repoRoot>` pair instead if you keep graphs elsewhere.
79
79
 
80
- No graph yet? Ask the agent to call `rebuild_graph`; it builds the missing graph locally. When the
81
- `open_repo` can change the active repository and build its graph. Retargeting is offline but
82
- intentionally changes the filesystem boundary for subsequent tools; omit `retarget` from an explicit
83
- capability list when a registration must stay pinned to one repository.
80
+ No graph yet? Ask the agent to call `rebuild_graph`; it builds the missing graph locally.
81
+ `open_repo` can change the active repository and builds a missing graph automatically. A normal
82
+ `open_repo` call also upgrades graphs created before `0.1.4` to edge metadata v2; `build:false`
83
+ probes without building and refuses a legacy graph. Retargeting is offline but intentionally changes
84
+ the filesystem boundary for subsequent tools; omit `retarget` from an explicit capability list when
85
+ a registration must stay pinned to one repository.
84
86
 
85
87
  An agent skill with recipes ships in [skill/SKILL.md](skill/SKILL.md) — install as
86
88
  `~/.claude/skills/weavatrix/SKILL.md`.
@@ -89,17 +91,19 @@ An agent skill with recipes ships in [skill/SKILL.md](skill/SKILL.md) — instal
89
91
 
90
92
  **graph** — `graph_stats`, `get_node`, `get_neighbors`, `query_graph`, `god_nodes`,
91
93
  `shortest_path`, `get_community`, `list_communities`, `module_map`, `get_dependents`,
92
- `change_impact`, `graph_diff`
94
+ `change_impact`, `graph_diff`. Runtime dependencies, TypeScript type-only coupling and language
95
+ compile-only edges (Rust module/use, Java imports) are reported separately where that distinction
96
+ changes the result.
93
97
 
94
98
  **search / source** — `search_code` (ripgrep-backed, pure-Node fallback), `read_source` (a
95
99
  symbol's actual code in one hop), `list_endpoints` (HTTP route inventory:
96
- Express/Fastify/Nest/Flask/FastAPI/Go mux …)
100
+ Express/Fastify/Nest/Flask/FastAPI/Go mux/Rust axum and actix-web …)
97
101
 
98
- **health** — `run_audit` (dead code, unused exports, missing/unused npm/Go/Python deps, import
99
- cycles, orphans, boundary rules, offline OSV vulnerabilities + typosquat + lockfile drift),
100
- `find_duplicates` (MOSS winnowing over method bodies — catches copy-paste even after renames),
101
- `coverage_map` (existing coverage reports mapped onto the graph; untested hotspots ranked by
102
- connectivity — tests are never executed)
102
+ **health** — `run_audit` (dead code, unused exports, missing/unused npm/Go/Python deps, runtime
103
+ cycles, type-only/compile-only coupling, orphans, boundary rules, offline OSV vulnerabilities + typosquat +
104
+ lockfile drift), `find_duplicates` (MOSS winnowing over method bodies — catches copy-paste even
105
+ after renames), `coverage_map` (existing coverage reports mapped onto the graph; untested hotspots
106
+ ranked by connectivity — tests are never executed)
103
107
 
104
108
  **build** — `rebuild_graph` (reports the structural delta, keeps the prior state as
105
109
  `graph.prev.json`)
@@ -110,8 +114,72 @@ changes the active repository boundary
110
114
  **online** *(explicit opt-in — see Privacy)* — `refresh_advisories`, `sync_graph`
111
115
 
112
116
  Quality of life: graph tools self-report staleness vs the repo HEAD; ambiguous name lookups are
113
- disclosed instead of silently guessed; and the server **hot-reloads its own tool code** when the
114
- files under `src/mcp/` change — no reconnect needed.
117
+ disclosed instead of silently guessed; and the server **hot-reloads its watched MCP tool entry
118
+ modules and catalog** when those files change — other MCP helpers and analysis engines require a
119
+ reconnect.
120
+
121
+ ## Signal quality and repository configuration
122
+
123
+ Weavatrix `0.1.4` reduces the most common sources of static-analysis noise while deepening Rust and
124
+ Java graphs:
125
+
126
+ - In Git repositories, graph and clone scans use tracked plus non-ignored untracked files, so
127
+ `.gitignore`-excluded build outputs such as packaged applications do not dominate findings.
128
+ - TypeScript `import type` and type-only re-exports remain visible as compile-time coupling but do
129
+ not inflate runtime-cycle severity. `module_map`, `change_impact` and structural diffs preserve
130
+ that distinction. `god_nodes` ranks unique neighbors with runtime connectivity first and reports
131
+ repeated occurrences separately.
132
+ - Rust `mod`, `use` and `pub use` paths now resolve between files and modules. They are marked
133
+ compile-only, so they enrich `module_map` and compile-time coupling without inventing runtime
134
+ initialization cycles or promoting compile-time coupling to runtime impact. Axum and actix-web
135
+ routes are included in `list_endpoints`.
136
+ - Java class/interface/enum/record/annotation declarations retain their symbol kind; methods and
137
+ constructors are linked to their declaring type with visibility metadata. Internal
138
+ `extends`/`implements` relationships and resolvable type references link to real declarations.
139
+ Imports are compile-only; call/reference/heritage edges contribute impact. Maven/Gradle Java
140
+ trees use package-aware communities instead of one giant `src` bucket. External or synthetic
141
+ placeholder types are not created merely to inflate graph counts.
142
+ - Dependency checks resolve the nearest workspace manifest and `tsconfig`/`jsconfig` aliases,
143
+ account for framework-owned runtime peers such as Next.js + `react-dom`, and recognize Next.js
144
+ App Router route exports as endpoints.
145
+ - Generated NAPI-RS platform loaders and declared template/example catalogs no longer create
146
+ phantom runtime dependency, orphan or unused-export findings. Conventional template roots are
147
+ inferred conservatively; custom roots can be declared explicitly.
148
+ - `coverage_map` reports coverage as **unavailable** when no supported report exists. That means
149
+ “no data”, not zero coverage.
150
+ - Duplicate output is a review queue, not a verdict: near-identical bodies are clone candidates;
151
+ same-name/different-body pairs are divergence candidates. Read both sources and confirm the
152
+ shared contract before consolidating code.
153
+
154
+ `run_audit` makes incomplete security coverage explicit. OSV state is `OK` only after every
155
+ supported pinned package/version for this repository was queried successfully. `PARTIAL` means
156
+ some queries failed, the response was incomplete, the dependency fingerprint changed, or the cache
157
+ uses a legacy stamp; `NOT_CHECKED` means there is no per-repository refresh; `ERROR` means the local
158
+ check itself failed. None of the latter three states is a clean vulnerability result. The cache
159
+ stores a fingerprint of the supported dependency set so a lockfile change cannot silently reuse a
160
+ stale `OK`.
161
+
162
+ For conventions that cannot be inferred safely, add an optional `.weavatrix-deps.json` at the
163
+ repository root:
164
+
165
+ ```json
166
+ {
167
+ "entrypoints": ["scripts/publish-release.mjs"],
168
+ "nonRuntimeRoots": ["library", "catalogs/examples"],
169
+ "python": {
170
+ "managedDependencies": ["numpy", "openvino-genai"],
171
+ "ignoreDependencies": ["vendor-sdk"]
172
+ }
173
+ }
174
+ ```
175
+
176
+ `entrypoints` protects framework/script entry files from dead-code classification.
177
+ `nonRuntimeRoots` (alias: `templateRoots`) marks reusable examples/templates that are not deployed
178
+ as one application. It suppresses orphan/dead/unused-export noise and missing/unresolved dependency
179
+ findings when every use is inside those roots. Import edges, cycles and boundary checks remain visible.
180
+ `managedDependencies` declares Python modules supplied by an external runtime;
181
+ `ignoreDependencies` suppresses intentionally unresolved Python packages. Keep the lists narrow:
182
+ they change audit interpretation, not the repository or its dependency installation.
115
183
 
116
184
  ## Privacy: local-first, offline by design
117
185
 
@@ -127,7 +195,14 @@ tools; both require the explicit `online` group and a tool call:
127
195
  symbol names and line ranges, import/dependency identifiers, edges and numeric metrics. Unknown
128
196
  fields are discarded; source file bodies are never read for sync or included in the payload. The
129
197
  endpoint is **yours**, configured via `WEAVATRIX_SYNC_URL` / `WEAVATRIX_SYNC_TOKEN`. Off by default.
130
- Graphs built before `0.1.2` must be rebuilt once before syncing.
198
+ Sync payload v2 preserves type-only and compile-only edge metadata. Graphs built before `0.1.4` must be rebuilt
199
+ once before syncing; a normal `open_repo` call performs that upgrade automatically.
200
+
201
+ If `refresh_advisories` is not listed by the MCP client, that is the expected default: the
202
+ registration does not include `online`. Only with the user's approval, add `online` to the final
203
+ capability list (for example `graph,search,source,health,build,retarget,online`), restart/reconnect
204
+ the MCP server, and then invoke `refresh_advisories`. Enabling the group does not trigger a request
205
+ by itself.
131
206
 
132
207
  Capability groups (`graph`, `search`, `source`, `health`, `build`, `retarget`, `online`) are
133
208
  selectable through the final positional argument. Omitted caps use the safe default above; an
@@ -142,7 +217,7 @@ vulnerability findings. This is where each capability comes from and how it is c
142
217
  |---|---|---|
143
218
  | Network access | `refresh_advisories` sends pinned package names and versions to OSV; `sync_graph` sends a versioned allowlist of graph metadata (relative paths, symbols and line ranges, import/dependency identifiers, edges and numeric metrics). It discards unknown graph fields and does not read source file bodies | `online` is disabled by default; each request requires a tool call, and sync additionally requires `WEAVATRIX_SYNC_URL` |
144
219
  | Shell access | Local `git` powers staleness/change impact; `rg` accelerates search; timed-out Windows child processes may be terminated | Used only by the corresponding local operation; it does not imply network access |
145
- | Debug / dynamic loading | Cache-busted `import()` hot-reloads local MCP tool modules; `createRequire` loads package metadata and parser dependencies | Loads files from the installed package; no `eval` |
220
+ | Debug / dynamic loading | Cache-busted `import()` hot-reloads watched MCP tool entry modules; `createRequire` loads package metadata and parser dependencies | Loads files from the installed package; no `eval` |
146
221
  | Environment access | Reads `WEAVATRIX_*` configuration; local child processes inherit the normal host environment | `WEAVATRIX_SYNC_TOKEN` is removed from every child-process and worker environment and read only by `sync_graph` |
147
222
  | Filesystem access | Reads the active repository, graph, lockfiles and coverage reports; writes derived graphs and advisory cache | Realpath containment blocks traversal and symlink/junction escapes. `open_repo` is an explicit offline call that changes the active boundary; omit `retarget` in a custom capability list to pin one repository. The optional malware dependency scan may inspect installed dependency caches such as GOPATH |
148
223
  | URL strings | Fixed OSV/documentation URLs plus a user-configured sync URL | A URL string causes no request by itself; only the two `online` tools perform requests |
@@ -171,8 +246,8 @@ npm test # node --test
171
246
 
172
247
  Design rule: **no source file exceeds 300 lines.** Larger concerns split into dotted-suffix modules
173
248
  behind a slim facade (`foo.js` re-exports `foo.parse.js`, `foo.report.js`, …); the MCP layer lives
174
- in `src/mcp/` (graph context, four tool modules, catalog + hot-reload loader) behind the thin stdio
175
- entry `src/mcp-server.mjs`.
249
+ in `src/mcp/` (graph context, tool entry modules, focused helpers, and the catalog/hot-reload
250
+ loader) behind the thin stdio entry `src/mcp-server.mjs`.
176
251
 
177
252
  ## Roadmap
178
253
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "weavatrix",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
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@edgehawk.io>",
package/skill/SKILL.md CHANGED
@@ -15,12 +15,35 @@ Tools are named `mcp__weavatrix__…`. If none are available, ask the user to re
15
15
  (`claude mcp add -s user weavatrix -- npx -y weavatrix <repoRoot>`; Codex:
16
16
  `codex mcp add weavatrix -- npx -y weavatrix <repoRoot>`), then retry.
17
17
 
18
+ If only `refresh_advisories` or `sync_graph` is missing, do not diagnose a broken installation:
19
+ `online` is intentionally absent from the default capability list.
20
+
18
21
  ## Ground rules
19
22
 
23
+ - **Evidence, not verdicts**: treat audit, hub, orphan and duplicate output as hypotheses. Confirm a
24
+ finding in source and check framework/runtime conventions before deleting, merging or redesigning
25
+ code. A same-name/different-body pair is a divergence candidate, not proof of duplication.
20
26
  - **Freshness**: every graph tool appends a staleness warning when the repo has commits newer than
21
- the graph. Act on it: `rebuild_graph`.
27
+ the graph. Act on it: `rebuild_graph`. A normal `open_repo` builds missing graphs and upgrades
28
+ pre-`0.1.4` graphs to edge metadata v2; `build:false` deliberately refuses that upgrade.
22
29
  - **Ambiguity**: `get_node`/`get_neighbors`/`get_dependents` disclose `matched N nodes; using the
23
30
  best-connected` — read that note before trusting the answer; pass an exact node id to pin it.
31
+ - **Runtime versus compile time**: keep runtime cycles separate from TypeScript type-only and
32
+ language compile-only coupling (Rust `mod`/`use`/`pub use`, Java imports). `module_map`,
33
+ `change_impact` and `graph_diff` label the distinction; `god_nodes` ranks unique connectivity and
34
+ reports repeated references separately. Do not schedule a runtime-cycle refactor from
35
+ compile-time-only edges.
36
+ - **Repository universe**: in Git repositories, graph and duplicate scans include tracked plus
37
+ non-ignored untracked files. If an old graph still contains packaged/generated output, rebuild it
38
+ before interpreting the result.
39
+ - **Coverage**: `coverage_map` reads an existing report. `unavailable` means no supported report was
40
+ found, not 0% coverage; do not rank testing risk from that state.
41
+ - **Audit completeness**: read `checks.osv.status` and `checks.malware.status`. For OSV, `OK` is
42
+ complete for the recorded dependency fingerprint; `PARTIAL` is incomplete or stale,
43
+ `NOT_CHECKED` has no repository-specific result, and `ERROR` means the local check failed. Treat
44
+ the same non-`OK` states as incomplete for malware scanning. Refresh OSV only when the user has
45
+ authorized adding the optional `online` capability group to the MCP registration and then
46
+ invoking `refresh_advisories`; enabling the group alone sends nothing.
24
47
  - **Offline by design**: scans and graph queries run in-process against local files; coverage tools
25
48
  read existing reports and never run tests. The ONLY network-touching tools live in the `online`
26
49
  capability group and run solely when explicitly called: `refresh_advisories` (queries OSV.dev with
@@ -46,16 +69,51 @@ Tools are named `mcp__weavatrix__…`. If none are available, ask the user to re
46
69
  (cycle broken? new module dependency introduced? symbols orphaned?); re-query later with
47
70
  `graph_diff` (optionally scoped by `path`). The semantic complement to the textual git diff.
48
71
  - **Health sweep**: `run_audit` (filter by `category`/`min_severity`) → `find_duplicates` →
49
- `coverage_map`.
50
- - **API inventory**: `list_endpoints`.
72
+ `coverage_map`. Inspect the source behind shortlisted findings before proposing edits.
73
+ - **API inventory**: `list_endpoints` (including Next.js App Router, Rust axum and actix-web).
51
74
  - **Find code**: `search_code` (regex + glob) → `get_node` → `read_source`.
52
75
  - **Another repo**: `list_known_repos` → `open_repo <path>`
53
- (builds the graph when missing; `build:false` probes without building).
76
+ (builds or upgrades the graph when needed; `build:false` probes without building).
77
+
78
+ ## Repository-specific conventions
79
+
80
+ Weavatrix understands nearest workspace manifests, nested `tsconfig`/`jsconfig` aliases,
81
+ framework-owned runtime peers such as Next.js + `react-dom`, generated NAPI-RS platform loaders,
82
+ and Next.js App Router route exports. For project-specific entry points, reusable template catalogs,
83
+ or Python dependencies supplied by an external runtime, add `.weavatrix-deps.json` at the repository
84
+ root:
85
+
86
+ ```json
87
+ {
88
+ "entrypoints": ["scripts/publish-release.mjs"],
89
+ "nonRuntimeRoots": ["library", "catalogs/examples"],
90
+ "python": {
91
+ "managedDependencies": ["numpy", "openvino-genai"],
92
+ "ignoreDependencies": ["vendor-sdk"]
93
+ }
94
+ }
95
+ ```
96
+
97
+ Keep exceptions narrow. `nonRuntimeRoots` (alias `templateRoots`) marks reusable examples that are
98
+ not one deployed application. It suppresses orphan/dead/unused-export noise and missing/unresolved
99
+ dependency findings when every use is inside those roots; graph edges, cycles and boundaries remain
100
+ visible. `managedDependencies` documents modules provided outside the repo's Python manifest;
101
+ `ignoreDependencies` suppresses intentionally unresolved imports.
102
+
103
+ ## Sync
104
+
105
+ `sync_graph` uses allowlisted payload v2, including type-only/compile-only edge metadata but no
106
+ source bodies. A graph built before `0.1.4` must be rebuilt first; normal `open_repo` does this
107
+ automatically. Sync remains
108
+ unavailable until the user opts into `online` and configures `WEAVATRIX_SYNC_URL`.
54
109
 
55
110
  ## Troubleshooting
56
111
 
57
112
  - `Graph unavailable` → `rebuild_graph`; `open_repo` can select another valid repository path unless
58
113
  the registration deliberately omitted `retarget`.
114
+ - `refresh_advisories` is unavailable → with the user's approval, re-register/reconfigure the MCP
115
+ capability list to include `online`, reconnect it, and then invoke the tool. Do not enable network
116
+ access merely to turn `NOT_CHECKED` into a cosmetic green state.
59
117
  - `No coverage report` → run the repo's own tests with coverage (`vitest run --coverage`,
60
118
  `jest --coverage`, `pytest --cov --cov-report=json`, `go test -coverprofile=coverage.out`),
61
119
  then re-call.
@@ -6,7 +6,9 @@
6
6
  // `analyzeDeadCode(graph, repoRoot)` is the thin fs wrapper. Works on ANY graph-builder-schema graph (built-in OR
7
7
  // graph-builder) — it only needs {nodes, links} + the source text. See [[graph-builder-internalization]].
8
8
  import { readFileSync } from "node:fs";
9
+ import { posix } from "node:path";
9
10
  import { createRepoBoundary } from "../repo-path.js";
11
+ import { isStructuralRelation } from "../graph/relations.js";
10
12
 
11
13
  const IDENT_RE = /[A-Za-z_$][\w$]*/g;
12
14
  const bareName = (label) => String(label || "").replace(/\s*\(.*$/, "").replace(/[()]/g, "").trim();
@@ -15,11 +17,88 @@ const bareName = (label) => String(label || "").replace(/\s*\(.*$/, "").replace(
15
17
  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
18
  const TEST_FILE = /(^|[\\/])[^\\/]*[._-](test|spec)\.[a-z0-9]+$|(^|[\\/])(test|tests|__tests__|spec)[\\/]/i;
17
19
 
18
- export function computeDead(graph, sources) {
20
+ // Framework-owned entry modules are invoked by convention rather than a source import. Keep this narrow:
21
+ // these are Next.js App/Pages Router surfaces and framework metadata files, not every file under `app/`.
22
+ export const NEXT_ENTRY_FILE = /(^|\/)(?:src\/)?app\/(?:.*\/)?(?:page|layout|template|loading|error|global-error|not-found|default|route|robots|sitemap|manifest|opengraph-image|twitter-image|icon|apple-icon)\.[cm]?[jt]sx?$|(^|\/)(?:src\/)?pages\/(?!.*\/(?:components?|lib|utils?)\/).+\.[cm]?[jt]sx?$|(^|\/)(?:middleware|instrumentation)\.[cm]?[jt]s$/i;
23
+ export const RUST_ENTRY_FILE = /(^|\/)(?:build\.rs|src\/(?:lib|main)\.rs)$/i;
24
+ export const isFrameworkEntryFile = (file) => {
25
+ const normalized = String(file || "").replace(/\\/g, "/");
26
+ return NEXT_ENTRY_FILE.test(normalized) || RUST_ENTRY_FILE.test(normalized);
27
+ };
28
+
29
+ const lineOfNode = (n) => {
30
+ const m = /@(\d+)$/.exec(String(n.id || "")) || /L(\d+)/.exec(String(n.source_location || ""));
31
+ return m ? Number(m[1]) : 0;
32
+ };
33
+
34
+ // Older graphs marked every method of an exported class as exported. Verify that an `exported` node is
35
+ // actually a module-surface declaration before reporting it. New graphs expose symbol_kind/member_of and
36
+ // already keep members unexported, but the source check preserves correctness for cached v0.1.2 graphs.
37
+ function hasModuleExport(source, node, name) {
38
+ if (node.member_of || node.symbol_kind === "method") return false;
39
+ const lines = String(source || "").split(/\r?\n/);
40
+ const line = lines[Math.max(0, lineOfNode(node) - 1)] || "";
41
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
42
+ const direct = new RegExp(`\\bexport\\s+(?:default\\s+)?(?:(?:declare|abstract|async)\\s+)*(?:function|class|const|let|var|enum|interface|type|namespace)\\s+${escaped}\\b`);
43
+ if (direct.test(line) || direct.test(String(source || ""))) return true;
44
+ const exportDefault = new RegExp(`\\bexport\\s+default\\s+${escaped}\\b`);
45
+ if (exportDefault.test(String(source || ""))) return true;
46
+ // `export { local }`, `export { local as publicName }` and CommonJS explicit exports.
47
+ const exportList = new RegExp(`\\bexport\\s*\\{[^}]*\\b${escaped}\\b[^}]*\\}`, "s");
48
+ const commonJs = new RegExp(`(?:module\\.exports\\s*=\\s*\\{[^}]*\\b${escaped}\\b|(?:module\\.exports|exports)\\.${escaped}\\s*=)`, "s");
49
+ return exportList.test(String(source || "")) || commonJs.test(String(source || ""));
50
+ }
51
+
52
+ function namespaceConsumedFiles(sources, graph) {
53
+ const files = new Set(sources.keys());
54
+ const consumed = new Set();
55
+ const extensions = ["", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"];
56
+ const resolve = (from, spec) => {
57
+ if (!spec.startsWith(".")) return "";
58
+ const base = posix.normalize(posix.join(posix.dirname(from.replace(/\\/g, "/")), spec));
59
+ for (const ext of extensions) {
60
+ const p = `${base}${ext}`;
61
+ if (files.has(p)) return p;
62
+ }
63
+ for (const ext of extensions.slice(1)) {
64
+ const p = `${base}/index${ext}`;
65
+ if (files.has(p)) return p;
66
+ }
67
+ return "";
68
+ };
69
+ for (const [file, textValue] of sources) {
70
+ const text = String(textValue || "");
71
+ const patterns = [
72
+ /\bimport\s+\*\s+as\s+[A-Za-z_$][\w$]*\s+from\s*(["'])([^"']+)\1/g,
73
+ /\bexport\s+\*\s+from\s*(["'])([^"']+)\1/g,
74
+ /\b(?:const|let|var)\s+[A-Za-z_$][\w$]*\s*=\s*require\(\s*(["'])([^"']+)\1\s*\)/g,
75
+ ];
76
+ for (const re of patterns) {
77
+ let m;
78
+ while ((m = re.exec(text))) {
79
+ const target = resolve(file, m[2]);
80
+ if (target) consumed.add(target);
81
+ }
82
+ }
83
+ }
84
+ // New graphs retain the exact specifier on file import edges, which also resolves namespace imports
85
+ // expressed through tsconfig aliases (the relative-path fallback above intentionally cannot guess those).
86
+ for (const link of graph.links || []) {
87
+ if (link.relation !== "imports" && link.relation !== "re_exports") continue;
88
+ const from = String(link.source?.id || link.source || ""), target = String(link.target?.id || link.target || "");
89
+ if (!link.specifier || from.includes("#") || target.includes("#") || !sources.has(from)) continue;
90
+ const spec = String(link.specifier).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
91
+ const text = String(sources.get(from) || "");
92
+ if (new RegExp(`(?:import\\s+\\*\\s+as\\s+[A-Za-z_$][\\w$]*\\s+from|export\\s+\\*\\s+from)\\s*["']${spec}["']`).test(text)) consumed.add(target);
93
+ }
94
+ return consumed;
95
+ }
96
+
97
+ export function computeDead(graph, sources, { entrySet = new Set() } = {}) {
19
98
  const nodes = graph.nodes || [], links = graph.links || [];
20
99
  const ep = (v) => (v && typeof v === "object" ? v.id : v);
21
100
  const inbound = new Set();
22
- for (const l of links) if (l.relation !== "contains") inbound.add(ep(l.target));
101
+ for (const l of links) if (!isStructuralRelation(l.relation)) inbound.add(ep(l.target));
23
102
 
24
103
  // whole-repo identifier frequency: a symbol whose name appears MORE than once total (its definition + at least
25
104
  // one use, same-file OR cross-file) is referenced. Errs toward "alive" (common-named symbols never flagged).
@@ -73,7 +152,7 @@ export function computeDead(graph, sources) {
73
152
  const deadFiles = [];
74
153
  for (const n of nodes) {
75
154
  if (String(n.id).includes("#")) continue; // file nodes only
76
- if (inbound.has(n.id) || ENTRY_FILE.test(n.source_file)) continue;
155
+ if (inbound.has(n.id) || ENTRY_FILE.test(n.source_file) || isFrameworkEntryFile(n.source_file) || entrySet.has(n.source_file)) continue;
77
156
  const syms = symsByFile.get(n.source_file) || [];
78
157
  if (syms.length && syms.every((s) => deadSet.has(s.id))) deadFiles.push({ file: n.source_file, reason: "not imported; all symbols unreferenced" });
79
158
  }
@@ -90,19 +169,21 @@ export function computeDead(graph, sources) {
90
169
  // with no inbound non-contains edge whose bare name occurs in NO file other than its own. Same-file-only
91
170
  // usage means the symbol is alive but the export is not — still worth surfacing. Pure like computeDead.
92
171
  // dynamicTargets = files reached via dynamic import() — their exports are consumed at runtime, skip them.
93
- export function computeUnusedExports(graph, sources, { dynamicTargets = new Set() } = {}) {
172
+ export function computeUnusedExports(graph, sources, { dynamicTargets = new Set(), entrySet = new Set() } = {}) {
94
173
  const nodes = graph.nodes || [], links = graph.links || [];
95
174
  const ep = (v) => (v && typeof v === "object" ? v.id : v);
96
175
  const inbound = new Set();
97
- for (const l of links) if (l.relation !== "contains") inbound.add(ep(l.target));
176
+ for (const l of links) if (!isStructuralRelation(l.relation)) inbound.add(ep(l.target));
98
177
 
178
+ const namespaceConsumed = namespaceConsumedFiles(sources, graph);
99
179
  const candidates = [];
100
180
  const wanted = new Set();
101
181
  for (const n of nodes) {
102
182
  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;
183
+ if (ENTRY_FILE.test(n.source_file) || isFrameworkEntryFile(n.source_file) || entrySet.has(n.source_file) || dynamicTargets.has(n.source_file) || namespaceConsumed.has(n.source_file)) continue;
104
184
  const nm = bareName(n.label);
105
185
  if (!nm || !/^[A-Za-z_$]/.test(nm)) continue;
186
+ if (!hasModuleExport(sources.get(n.source_file), n, nm)) continue;
106
187
  candidates.push({ n, nm });
107
188
  wanted.add(nm);
108
189
  }
@@ -0,0 +1,157 @@
1
+ import { makeFinding } from "./findings.js";
2
+
3
+ const TEST_PATH_RE = /(^|[/\\])(test|tests|__tests__|spec|e2e|__mocks__)([/\\]|$)|[._-](test|spec)\.[a-z0-9]+$|_test\.go$/i;
4
+ const escRe = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
5
+ const mentioned = (blob, name) => new RegExp(`(^|[^\\w@.-])${escRe(name)}($|[^\\w.-])`).test(blob);
6
+
7
+ // ---- Go: set math over ecosystem:"Go" externalImports vs go.mod requires ----
8
+ // goMod = parseGoMod() output. Only DIRECT requires can be "unused" (indirect ones belong to `go mod
9
+ // tidy`); replace targets and the own module never count. Missing = imported module with no require
10
+ // prefix — rare in Go (builds fail), so it usually flags vendored/replaced setups: keep it medium.
11
+ export function computeGoDepFindings({ externalImports = [], goMod = null, nonRuntimeRoots = [] } = {}) {
12
+ const findings = [];
13
+ if (!goMod || !goMod.module) return { findings, declared: new Set() };
14
+ const requires = goMod.requires || [];
15
+ const declared = new Set(requires.map((r) => r.path));
16
+ const replaced = new Set((goMod.replaces || []).map((r) => r.from));
17
+ const moduleOf = (spec) => { let best = ""; for (const p of declared) if ((spec === p || spec.startsWith(p + "/")) && p.length > best.length) best = p; return best; };
18
+
19
+ const usedModules = new Set();
20
+ const missing = new Map(); // pkg → { files:Set, lines:Map }
21
+ const inNonRuntimeRoot = (file) => (nonRuntimeRoots || []).some((root) => {
22
+ const f = String(file || "").replace(/\\/g, "/"), r = String(root || "").replace(/\\/g, "/").replace(/^\.\//, "").replace(/^\/+|\/+$/g, "");
23
+ return !!r && (f === r || f.startsWith(`${r}/`));
24
+ });
25
+ for (const e of externalImports) {
26
+ if (e.ecosystem !== "Go" || e.builtin || e.unresolved || !e.pkg) continue;
27
+ const mod = moduleOf(e.spec || e.pkg) || (declared.has(e.pkg) ? e.pkg : "");
28
+ if (mod) { usedModules.add(mod); continue; }
29
+ let m = missing.get(e.pkg);
30
+ if (!m) missing.set(e.pkg, (m = { files: new Set(), lines: new Map() }));
31
+ m.files.add(e.file);
32
+ if (!m.lines.has(e.file)) m.lines.set(e.file, e.line || 0);
33
+ }
34
+
35
+ for (const r of requires) {
36
+ if (r.indirect || usedModules.has(r.path)) continue;
37
+ findings.push(makeFinding({
38
+ category: "unused",
39
+ rule: "unused-dep",
40
+ severity: "low",
41
+ confidence: "medium",
42
+ title: `Unused Go module: ${r.path}`,
43
+ detail: `"${r.path}" is required (direct) in go.mod but no .go file imports it or any of its packages. Build-tag-guarded files can hide usage — confirm with \`go mod tidy\` before removing.`,
44
+ package: r.path,
45
+ version: r.version,
46
+ source: "internal",
47
+ fixHint: "go mod tidy (drops requires nothing imports)",
48
+ }));
49
+ }
50
+ for (const [pkg, use] of missing) {
51
+ if (replaced.has(pkg)) continue;
52
+ const files = [...use.files];
53
+ if (files.every(inNonRuntimeRoot)) continue;
54
+ findings.push(makeFinding({
55
+ category: "unused",
56
+ rule: "missing-dep",
57
+ severity: "medium",
58
+ confidence: "medium",
59
+ title: `Missing Go module: ${pkg}`,
60
+ detail: `"${pkg}" is imported by ${files.length} file(s) but go.mod has no matching require — a replace/workspace/vendor setup, or the module was never added.`,
61
+ package: pkg,
62
+ file: files[0],
63
+ line: use.lines.get(files[0]) || 0,
64
+ evidence: files.slice(0, 5).map((f) => ({ file: f, line: use.lines.get(f) || 0, snippet: "" })),
65
+ source: "internal",
66
+ fixHint: `go get ${pkg}`,
67
+ }));
68
+ }
69
+ return { findings, declared };
70
+ }
71
+
72
+ // ---- Python: ecosystem:"PyPI" externalImports vs requirements/pyproject/Pipfile ----
73
+ // Import→dist naming is heuristic (yaml→PyYAML, python-X/X-python variants), so matching is GENEROUS for
74
+ // suppression and every unused finding stays low-confidence. CLI-only tools (pytest, black, gunicorn …)
75
+ // and stub/plugin conventions (types-*, *-stubs, pytest-*, flake8-*) are never flagged unused.
76
+ const PY_TOOL_DISTS = new Set(("pytest tox nox black ruff flake8 pylint mypy pyright isort bandit coverage pre-commit pip setuptools wheel build twine poetry poetry-core " +
77
+ "pip-tools uv virtualenv pipenv gunicorn uwsgi supervisor ipython jupyter jupyterlab notebook ipykernel codecov autopep8 yapf commitizen detect-secrets safety pip-audit hatchling flit flit-core pdm").split(" "));
78
+ const pyNorm = (n) => String(n || "").toLowerCase().replace(/[-_.]+/g, "-");
79
+
80
+ export function computePyDepFindings({
81
+ externalImports = [], pyManifest = null, configTexts = new Map(),
82
+ managedDependencies = [], ignoredDependencies = [], nonRuntimeRoots = [],
83
+ } = {}) {
84
+ const findings = [];
85
+ const deps = (pyManifest && pyManifest.deps) || [];
86
+ const present = !!(pyManifest && pyManifest.present);
87
+ const declared = new Set(deps.map((d) => pyNorm(d.name)));
88
+ const managed = new Set((managedDependencies || []).map(pyNorm));
89
+ const ignored = new Set((ignoredDependencies || []).map(pyNorm));
90
+ const inNonRuntimeRoot = (file) => (nonRuntimeRoots || []).some((root) => {
91
+ const f = String(file || "").replace(/\\/g, "/"), r = String(root || "").replace(/\\/g, "/").replace(/^\.\//, "").replace(/^\/+|\/+$/g, "");
92
+ return !!r && (f === r || f.startsWith(`${r}/`));
93
+ });
94
+ for (const name of managed) declared.add(name);
95
+
96
+ const used = new Map(); // top import → { dist, files:Set, lines:Map }
97
+ for (const e of externalImports) {
98
+ if (e.ecosystem !== "PyPI" || e.builtin || e.unresolved || !e.pkg) continue;
99
+ const top = String(e.spec || e.pkg).split(".")[0];
100
+ let u = used.get(top);
101
+ if (!u) used.set(top, (u = { dist: e.pkg, files: new Set(), lines: new Map() }));
102
+ u.files.add(e.file);
103
+ if (!u.lines.has(e.file)) u.lines.set(e.file, e.line || 0);
104
+ }
105
+ // generous match: does declared dist D cover import top t (dist guess g)?
106
+ const covers = (D, t, g) => {
107
+ const d = pyNorm(D), nt = pyNorm(t), ng = pyNorm(g);
108
+ return d === nt || d === ng || d === `python-${nt}` || d === `${nt}-python` || d === `${nt}-binary` || d.replace(/\d+$/, "") === nt.replace(/\d+$/, "");
109
+ };
110
+
111
+ const configBlob = [...configTexts.values()].join("\n");
112
+ if (present) {
113
+ for (const d of deps) {
114
+ const n = pyNorm(d.name);
115
+ if (managed.has(n) || ignored.has(n)) continue;
116
+ if (d.buildSystem || PY_TOOL_DISTS.has(n) || /^types-|-stubs$|^pytest-|^flake8-|^sphinx/.test(n)) continue;
117
+ let hit = false;
118
+ for (const [top, u] of used) if (covers(d.name, top, u.dist)) { hit = true; break; }
119
+ if (hit || mentioned(configBlob, d.name)) continue;
120
+ findings.push(makeFinding({
121
+ category: "unused",
122
+ rule: "unused-dep",
123
+ severity: d.dev ? "info" : "low",
124
+ confidence: "low",
125
+ title: `Unused Python dependency: ${d.name}`,
126
+ detail: `"${d.name}" is declared but no .py file imports a module that maps to it. Import-name↔package-name mapping is heuristic and plugins/CLI tools load dynamically — review before removing.`,
127
+ package: d.name,
128
+ source: "internal",
129
+ fixHint: `remove "${d.name}" from the manifest after confirming nothing imports or shells out to it`,
130
+ }));
131
+ }
132
+ }
133
+ for (const [top, u] of used) {
134
+ if (ignored.has(pyNorm(top)) || ignored.has(pyNorm(u.dist)) || managed.has(pyNorm(top)) || managed.has(pyNorm(u.dist))) continue;
135
+ let hit = false;
136
+ for (const D of declared) if (covers(D, top, u.dist)) { hit = true; break; }
137
+ if (hit) continue;
138
+ const files = [...u.files];
139
+ if (files.every(inNonRuntimeRoot)) continue;
140
+ const testOnly = files.every((f) => TEST_PATH_RE.test(f));
141
+ findings.push(makeFinding({
142
+ category: "unused",
143
+ rule: "missing-dep",
144
+ severity: present ? (testOnly ? "low" : "medium") : "low",
145
+ confidence: present ? "medium" : "low",
146
+ title: `Missing Python dependency: ${u.dist}`,
147
+ detail: `"${top}" is imported by ${files.length} file(s) but ${present ? "no declared dependency provides it" : "the repo has no Python dependency manifest (requirements.txt / pyproject / Pipfile); a bundled or managed runtime may provide it"}${u.dist !== top ? ` (PyPI package is likely "${u.dist}")` : ""}.${present ? "" : " If this is intentional, declare it under python.managedDependencies in .weavatrix-deps.json."}`,
148
+ package: u.dist,
149
+ file: files[0],
150
+ line: u.lines.get(files[0]) || 0,
151
+ evidence: files.slice(0, 5).map((f) => ({ file: f, line: u.lines.get(f) || 0, snippet: "" })),
152
+ source: "internal",
153
+ fixHint: present ? `pip install ${u.dist} (and add it to requirements.txt / pyproject)` : `add ${u.dist} to a Python manifest, or declare it as a managed runtime dependency`,
154
+ }));
155
+ }
156
+ return { findings, declared, managed };
157
+ }