weavatrix 0.1.3 → 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.
package/README.md CHANGED
@@ -79,7 +79,7 @@ claude mcp add -s user weavatrix -- node <path-to>/weavatrix/bin/weavatrix-mcp.m
79
79
 
80
80
  No graph yet? Ask the agent to call `rebuild_graph`; it builds the missing graph locally.
81
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.3` to typed import edges; `build:false`
82
+ `open_repo` call also upgrades graphs created before `0.1.4` to edge metadata v2; `build:false`
83
83
  probes without building and refuses a legacy graph. Retargeting is offline but intentionally changes
84
84
  the filesystem boundary for subsequent tools; omit `retarget` from an explicit capability list when
85
85
  a registration must stay pinned to one repository.
@@ -91,15 +91,16 @@ An agent skill with recipes ships in [skill/SKILL.md](skill/SKILL.md) — instal
91
91
 
92
92
  **graph** — `graph_stats`, `get_node`, `get_neighbors`, `query_graph`, `god_nodes`,
93
93
  `shortest_path`, `get_community`, `list_communities`, `module_map`, `get_dependents`,
94
- `change_impact`, `graph_diff`. Runtime dependencies and TypeScript type-only coupling are reported
95
- separately where that distinction changes the result.
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.
96
97
 
97
98
  **search / source** — `search_code` (ripgrep-backed, pure-Node fallback), `read_source` (a
98
99
  symbol's actual code in one hop), `list_endpoints` (HTTP route inventory:
99
- Express/Fastify/Nest/Flask/FastAPI/Go mux …)
100
+ Express/Fastify/Nest/Flask/FastAPI/Go mux/Rust axum and actix-web …)
100
101
 
101
102
  **health** — `run_audit` (dead code, unused exports, missing/unused npm/Go/Python deps, runtime
102
- cycles, type-only coupling, orphans, boundary rules, offline OSV vulnerabilities + typosquat +
103
+ cycles, type-only/compile-only coupling, orphans, boundary rules, offline OSV vulnerabilities + typosquat +
103
104
  lockfile drift), `find_duplicates` (MOSS winnowing over method bodies — catches copy-paste even
104
105
  after renames), `coverage_map` (existing coverage reports mapped onto the graph; untested hotspots
105
106
  ranked by connectivity — tests are never executed)
@@ -113,12 +114,14 @@ changes the active repository boundary
113
114
  **online** *(explicit opt-in — see Privacy)* — `refresh_advisories`, `sync_graph`
114
115
 
115
116
  Quality of life: graph tools self-report staleness vs the repo HEAD; ambiguous name lookups are
116
- disclosed instead of silently guessed; and the server **hot-reloads its own tool code** when the
117
- 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.
118
120
 
119
121
  ## Signal quality and repository configuration
120
122
 
121
- Weavatrix `0.1.3` reduces the most common sources of static-analysis noise:
123
+ Weavatrix `0.1.4` reduces the most common sources of static-analysis noise while deepening Rust and
124
+ Java graphs:
122
125
 
123
126
  - In Git repositories, graph and clone scans use tracked plus non-ignored untracked files, so
124
127
  `.gitignore`-excluded build outputs such as packaged applications do not dominate findings.
@@ -126,9 +129,22 @@ Weavatrix `0.1.3` reduces the most common sources of static-analysis noise:
126
129
  not inflate runtime-cycle severity. `module_map`, `change_impact` and structural diffs preserve
127
130
  that distinction. `god_nodes` ranks unique neighbors with runtime connectivity first and reports
128
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.
129
142
  - Dependency checks resolve the nearest workspace manifest and `tsconfig`/`jsconfig` aliases,
130
143
  account for framework-owned runtime peers such as Next.js + `react-dom`, and recognize Next.js
131
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.
132
148
  - `coverage_map` reports coverage as **unavailable** when no supported report exists. That means
133
149
  “no data”, not zero coverage.
134
150
  - Duplicate output is a review queue, not a verdict: near-identical bodies are clone candidates;
@@ -149,6 +165,7 @@ repository root:
149
165
  ```json
150
166
  {
151
167
  "entrypoints": ["scripts/publish-release.mjs"],
168
+ "nonRuntimeRoots": ["library", "catalogs/examples"],
152
169
  "python": {
153
170
  "managedDependencies": ["numpy", "openvino-genai"],
154
171
  "ignoreDependencies": ["vendor-sdk"]
@@ -157,6 +174,9 @@ repository root:
157
174
  ```
158
175
 
159
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.
160
180
  `managedDependencies` declares Python modules supplied by an external runtime;
161
181
  `ignoreDependencies` suppresses intentionally unresolved Python packages. Keep the lists narrow:
162
182
  they change audit interpretation, not the repository or its dependency installation.
@@ -175,7 +195,7 @@ tools; both require the explicit `online` group and a tool call:
175
195
  symbol names and line ranges, import/dependency identifiers, edges and numeric metrics. Unknown
176
196
  fields are discarded; source file bodies are never read for sync or included in the payload. The
177
197
  endpoint is **yours**, configured via `WEAVATRIX_SYNC_URL` / `WEAVATRIX_SYNC_TOKEN`. Off by default.
178
- Sync payload v2 preserves type-only edge metadata. Graphs built before `0.1.3` must be rebuilt
198
+ Sync payload v2 preserves type-only and compile-only edge metadata. Graphs built before `0.1.4` must be rebuilt
179
199
  once before syncing; a normal `open_repo` call performs that upgrade automatically.
180
200
 
181
201
  If `refresh_advisories` is not listed by the MCP client, that is the expected default: the
@@ -197,7 +217,7 @@ vulnerability findings. This is where each capability comes from and how it is c
197
217
  |---|---|---|
198
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` |
199
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 |
200
- | 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` |
201
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` |
202
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 |
203
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 |
@@ -226,8 +246,8 @@ npm test # node --test
226
246
 
227
247
  Design rule: **no source file exceeds 300 lines.** Larger concerns split into dotted-suffix modules
228
248
  behind a slim facade (`foo.js` re-exports `foo.parse.js`, `foo.report.js`, …); the MCP layer lives
229
- in `src/mcp/` (graph context, four tool modules, catalog + hot-reload loader) behind the thin stdio
230
- 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`.
231
251
 
232
252
  ## Roadmap
233
253
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "weavatrix",
3
- "version": "0.1.3",
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
@@ -25,13 +25,14 @@ If only `refresh_advisories` or `sync_graph` is missing, do not diagnose a broke
25
25
  code. A same-name/different-body pair is a divergence candidate, not proof of duplication.
26
26
  - **Freshness**: every graph tool appends a staleness warning when the repo has commits newer than
27
27
  the graph. Act on it: `rebuild_graph`. A normal `open_repo` builds missing graphs and upgrades
28
- pre-`0.1.3` graphs to typed import edges; `build:false` deliberately refuses that upgrade.
28
+ pre-`0.1.4` graphs to edge metadata v2; `build:false` deliberately refuses that upgrade.
29
29
  - **Ambiguity**: `get_node`/`get_neighbors`/`get_dependents` disclose `matched N nodes; using the
30
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 and TypeScript type-only coupling separate.
32
- `module_map` and `change_impact` label the distinction; `god_nodes` ranks unique connectivity and
33
- reports repeated references separately. Do not schedule a runtime-cycle refactor from type-only
34
- edges alone.
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.
35
36
  - **Repository universe**: in Git repositories, graph and duplicate scans include tracked plus
36
37
  non-ignored untracked files. If an old graph still contains packaged/generated output, rebuild it
37
38
  before interpreting the result.
@@ -69,7 +70,7 @@ If only `refresh_advisories` or `sync_graph` is missing, do not diagnose a broke
69
70
  `graph_diff` (optionally scoped by `path`). The semantic complement to the textual git diff.
70
71
  - **Health sweep**: `run_audit` (filter by `category`/`min_severity`) → `find_duplicates` →
71
72
  `coverage_map`. Inspect the source behind shortlisted findings before proposing edits.
72
- - **API inventory**: `list_endpoints`.
73
+ - **API inventory**: `list_endpoints` (including Next.js App Router, Rust axum and actix-web).
73
74
  - **Find code**: `search_code` (regex + glob) → `get_node` → `read_source`.
74
75
  - **Another repo**: `list_known_repos` → `open_repo <path>`
75
76
  (builds or upgrades the graph when needed; `build:false` probes without building).
@@ -77,13 +78,15 @@ If only `refresh_advisories` or `sync_graph` is missing, do not diagnose a broke
77
78
  ## Repository-specific conventions
78
79
 
79
80
  Weavatrix understands nearest workspace manifests, nested `tsconfig`/`jsconfig` aliases,
80
- framework-owned runtime peers such as Next.js + `react-dom`, and Next.js App Router route exports.
81
- For project-specific entry points or Python dependencies supplied by an external runtime, add
82
- `.weavatrix-deps.json` at the repository root:
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:
83
85
 
84
86
  ```json
85
87
  {
86
88
  "entrypoints": ["scripts/publish-release.mjs"],
89
+ "nonRuntimeRoots": ["library", "catalogs/examples"],
87
90
  "python": {
88
91
  "managedDependencies": ["numpy", "openvino-genai"],
89
92
  "ignoreDependencies": ["vendor-sdk"]
@@ -91,13 +94,17 @@ For project-specific entry points or Python dependencies supplied by an external
91
94
  }
92
95
  ```
93
96
 
94
- Keep exceptions narrow. `managedDependencies` documents modules provided outside the repo's Python
95
- manifest; `ignoreDependencies` suppresses intentionally unresolved imports.
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.
96
102
 
97
103
  ## Sync
98
104
 
99
- `sync_graph` uses allowlisted payload v2, including typed-edge metadata but no source bodies. A graph
100
- built before `0.1.3` must be rebuilt first; normal `open_repo` does this automatically. Sync remains
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
101
108
  unavailable until the user opts into `online` and configures `WEAVATRIX_SYNC_URL`.
102
109
 
103
110
  ## Troubleshooting
@@ -8,6 +8,7 @@
8
8
  import { readFileSync } from "node:fs";
9
9
  import { posix } from "node:path";
10
10
  import { createRepoBoundary } from "../repo-path.js";
11
+ import { isStructuralRelation } from "../graph/relations.js";
11
12
 
12
13
  const IDENT_RE = /[A-Za-z_$][\w$]*/g;
13
14
  const bareName = (label) => String(label || "").replace(/\s*\(.*$/, "").replace(/[()]/g, "").trim();
@@ -19,7 +20,11 @@ const TEST_FILE = /(^|[\\/])[^\\/]*[._-](test|spec)\.[a-z0-9]+$|(^|[\\/])(test|t
19
20
  // Framework-owned entry modules are invoked by convention rather than a source import. Keep this narrow:
20
21
  // these are Next.js App/Pages Router surfaces and framework metadata files, not every file under `app/`.
21
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;
22
- export const isFrameworkEntryFile = (file) => NEXT_ENTRY_FILE.test(String(file || "").replace(/\\/g, "/"));
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
+ };
23
28
 
24
29
  const lineOfNode = (n) => {
25
30
  const m = /@(\d+)$/.exec(String(n.id || "")) || /L(\d+)/.exec(String(n.source_location || ""));
@@ -93,7 +98,7 @@ export function computeDead(graph, sources, { entrySet = new Set() } = {}) {
93
98
  const nodes = graph.nodes || [], links = graph.links || [];
94
99
  const ep = (v) => (v && typeof v === "object" ? v.id : v);
95
100
  const inbound = new Set();
96
- 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));
97
102
 
98
103
  // whole-repo identifier frequency: a symbol whose name appears MORE than once total (its definition + at least
99
104
  // one use, same-file OR cross-file) is referenced. Errs toward "alive" (common-named symbols never flagged).
@@ -168,7 +173,7 @@ export function computeUnusedExports(graph, sources, { dynamicTargets = new Set(
168
173
  const nodes = graph.nodes || [], links = graph.links || [];
169
174
  const ep = (v) => (v && typeof v === "object" ? v.id : v);
170
175
  const inbound = new Set();
171
- 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));
172
177
 
173
178
  const namespaceConsumed = namespaceConsumedFiles(sources, graph);
174
179
  const candidates = [];
@@ -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
+ }
@@ -47,7 +47,7 @@ const mentioned = (blob, name) => new RegExp(`(^|[^\\w@.-])${escRe(name)}($|[^\\
47
47
  // configTexts — Map<fileName, text> of root config files + CI workflows (mention scanning)
48
48
  export function computeDepFindings({
49
49
  externalImports = [], pkg = {}, workspacePkgNames = new Set(), configTexts = new Map(),
50
- aliases = [], scope = "", manifest = "package.json",
50
+ aliases = [], scope = "", manifest = "package.json", nonRuntimeRoots = [],
51
51
  } = {}) {
52
52
  const findings = [];
53
53
  const meta = { scope: scope || ".", manifest };
@@ -68,6 +68,32 @@ export function computeDepFindings({
68
68
  };
69
69
  const allDeclared = new Set(Object.values(sections).flatMap((s) => Object.keys(s)));
70
70
  const selfName = String(pkg.name || "");
71
+ const normRoots = (nonRuntimeRoots || []).map((root) => String(root || "").replace(/\\/g, "/").replace(/^\.\//, "").replace(/^\/+|\/+$/g, "")).filter(Boolean);
72
+ const isNonRuntimeFile = (file) => {
73
+ const normalized = String(file || "").replace(/\\/g, "/").replace(/^\.\//, "");
74
+ return normRoots.some((root) => normalized === root || normalized.startsWith(`${root}/`));
75
+ };
76
+ const npmNameParts = (name) => {
77
+ const value = String(name || "");
78
+ const slash = value.startsWith("@") ? value.indexOf("/") : -1;
79
+ return slash > 0 ? { scope: value.slice(0, slash), base: value.slice(slash + 1) } : { scope: "", base: value };
80
+ };
81
+ const selfParts = npmNameParts(selfName);
82
+ const configuredNapi = npmNameParts(pkg.napi?.name || selfName);
83
+ const napiBinding = {
84
+ scope: configuredNapi.scope || (configuredNapi.base === selfParts.base ? selfParts.scope : ""),
85
+ base: configuredNapi.base,
86
+ };
87
+ const NAPI_PLATFORM_SUFFIX = /^(?:android-(?:arm64|arm-eabi)|win32-(?:x64|ia32|arm64)-msvc|darwin-(?:universal|x64|arm64)|freebsd-(?:x64|arm64)|linux-(?:x64|arm64|arm|riscv64|s390x|ppc64|loong64)-(?:gnu|musl|gnueabihf|musleabihf)|aix-ppc64|sunos-x64|wasm32-wasi(?:-preview1-threads)?)$/;
88
+ const isGeneratedNapiPackage = (name, files) => {
89
+ if (!pkg.napi || !napiBinding.base || !files.every((file) => /(^|\/)index\.[cm]?js$/i.test(String(file || "").replace(/\\/g, "/")))) return false;
90
+ const candidate = npmNameParts(name);
91
+ if (candidate.scope !== napiBinding.scope || !candidate.base.startsWith(`${napiBinding.base}-`)) return false;
92
+ return NAPI_PLATFORM_SUFFIX.test(candidate.base.slice(napiBinding.base.length + 1));
93
+ };
94
+ const isGeneratedNapiBinary = (entry) => !!pkg.napi
95
+ && /(^|\/)index\.[cm]?js$/i.test(String(entry.file || "").replace(/\\/g, "/"))
96
+ && /^\.\/[^/]+\.node$/i.test(String(entry.spec || ""));
71
97
  // Some frameworks consume required peers internally, so application source legitimately never imports
72
98
  // them. Keep this deliberately narrow: react-dom is a required Next.js runtime peer in the same package
73
99
  // scope, not a general React exemption and not a repo-wide whitelist.
@@ -134,6 +160,7 @@ export function computeDepFindings({
134
160
  for (const [name, use] of usedPackages) {
135
161
  if (allDeclared.has(name) || name === selfName || workspacePkgNames.has(name)) continue;
136
162
  const files = [...use.files];
163
+ if (files.every(isNonRuntimeFile) || isGeneratedNapiPackage(name, files)) continue;
137
164
  const testOnly = files.every((f) => /(^|[/\\])(test|tests|__tests__|spec|e2e|__mocks__)([/\\]|$)|[._-](test|spec)\.[a-z]+$/i.test(f));
138
165
  findings.push(makeFinding({
139
166
  category: "unused",
@@ -176,6 +203,7 @@ export function computeDepFindings({
176
203
  let unresolvedCount = 0;
177
204
  for (const e of externalImports) {
178
205
  if (!e.unresolved) continue;
206
+ if (isNonRuntimeFile(e.file) || isGeneratedNapiBinary(e)) continue;
179
207
  unresolvedCount++;
180
208
  const key = e.file + "|" + e.spec;
181
209
  if (unresolvedSeen.has(key) || unresolvedSeen.size >= 100) continue; // cap the findings, keep the count honest
@@ -216,6 +244,7 @@ const ownsFile = (scope, file) => !scope || file === scope || String(file || "")
216
244
  // Node's package scope and prevents nested Next/Vite apps from inheriting the root manifest by accident.
217
245
  export function computeScopedDepFindings({
218
246
  externalImports = [], packageScopes = [], workspacePkgNames = new Set(), configTexts = new Map(),
247
+ nonRuntimeRoots = [],
219
248
  } = {}) {
220
249
  const scopes = packageScopes.length
221
250
  ? packageScopes.map((s) => ({ ...s, root: normScope(s.root) })).sort((a, b) => b.root.length - a.root.length)
@@ -233,6 +262,7 @@ export function computeScopedDepFindings({
233
262
  const r = computeDepFindings({
234
263
  externalImports: importsByScope.get(s), pkg: s.pkg || {}, workspacePkgNames, configTexts: scopeConfig,
235
264
  aliases: s.aliases || [], scope: s.root, manifest: s.manifest || (s.root ? `${s.root}/package.json` : "package.json"),
265
+ nonRuntimeRoots,
236
266
  });
237
267
  findings.push(...r.findings);
238
268
  for (const [name, use] of r.usedPackages) usedPackages.set(`${s.root || "."}:${name}`, use);
@@ -241,146 +271,4 @@ export function computeScopedDepFindings({
241
271
  return { findings, usedPackages, declared };
242
272
  }
243
273
 
244
- const TEST_PATH_RE = /(^|[/\\])(test|tests|__tests__|spec|e2e|__mocks__)([/\\]|$)|[._-](test|spec)\.[a-z0-9]+$|_test\.go$/i;
245
-
246
- // ---- Go: set math over ecosystem:"Go" externalImports vs go.mod requires ----
247
- // goMod = parseGoMod() output. Only DIRECT requires can be "unused" (indirect ones belong to `go mod
248
- // tidy`); replace targets and the own module never count. Missing = imported module with no require
249
- // prefix — rare in Go (builds fail), so it usually flags vendored/replaced setups: keep it medium.
250
- export function computeGoDepFindings({ externalImports = [], goMod = null } = {}) {
251
- const findings = [];
252
- if (!goMod || !goMod.module) return { findings, declared: new Set() };
253
- const requires = goMod.requires || [];
254
- const declared = new Set(requires.map((r) => r.path));
255
- const replaced = new Set((goMod.replaces || []).map((r) => r.from));
256
- const moduleOf = (spec) => { let best = ""; for (const p of declared) if ((spec === p || spec.startsWith(p + "/")) && p.length > best.length) best = p; return best; };
257
-
258
- const usedModules = new Set();
259
- const missing = new Map(); // pkg → { files:Set, lines:Map }
260
- for (const e of externalImports) {
261
- if (e.ecosystem !== "Go" || e.builtin || e.unresolved || !e.pkg) continue;
262
- const mod = moduleOf(e.spec || e.pkg) || (declared.has(e.pkg) ? e.pkg : "");
263
- if (mod) { usedModules.add(mod); continue; }
264
- let m = missing.get(e.pkg);
265
- if (!m) missing.set(e.pkg, (m = { files: new Set(), lines: new Map() }));
266
- m.files.add(e.file);
267
- if (!m.lines.has(e.file)) m.lines.set(e.file, e.line || 0);
268
- }
269
-
270
- for (const r of requires) {
271
- if (r.indirect || usedModules.has(r.path)) continue;
272
- findings.push(makeFinding({
273
- category: "unused",
274
- rule: "unused-dep",
275
- severity: "low",
276
- confidence: "medium",
277
- title: `Unused Go module: ${r.path}`,
278
- 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.`,
279
- package: r.path,
280
- version: r.version,
281
- source: "internal",
282
- fixHint: "go mod tidy (drops requires nothing imports)",
283
- }));
284
- }
285
- for (const [pkg, use] of missing) {
286
- if (replaced.has(pkg)) continue;
287
- const files = [...use.files];
288
- findings.push(makeFinding({
289
- category: "unused",
290
- rule: "missing-dep",
291
- severity: "medium",
292
- confidence: "medium",
293
- title: `Missing Go module: ${pkg}`,
294
- 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.`,
295
- package: pkg,
296
- file: files[0],
297
- line: use.lines.get(files[0]) || 0,
298
- evidence: files.slice(0, 5).map((f) => ({ file: f, line: use.lines.get(f) || 0, snippet: "" })),
299
- source: "internal",
300
- fixHint: `go get ${pkg}`,
301
- }));
302
- }
303
- return { findings, declared };
304
- }
305
-
306
- // ---- Python: ecosystem:"PyPI" externalImports vs requirements/pyproject/Pipfile ----
307
- // Import→dist naming is heuristic (yaml→PyYAML, python-X/X-python variants), so matching is GENEROUS for
308
- // suppression and every unused finding stays low-confidence. CLI-only tools (pytest, black, gunicorn …)
309
- // and stub/plugin conventions (types-*, *-stubs, pytest-*, flake8-*) are never flagged unused.
310
- 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 " +
311
- "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(" "));
312
- const pyNorm = (n) => String(n || "").toLowerCase().replace(/[-_.]+/g, "-");
313
-
314
- export function computePyDepFindings({
315
- externalImports = [], pyManifest = null, configTexts = new Map(),
316
- managedDependencies = [], ignoredDependencies = [],
317
- } = {}) {
318
- const findings = [];
319
- const deps = (pyManifest && pyManifest.deps) || [];
320
- const present = !!(pyManifest && pyManifest.present);
321
- const declared = new Set(deps.map((d) => pyNorm(d.name)));
322
- const managed = new Set((managedDependencies || []).map(pyNorm));
323
- const ignored = new Set((ignoredDependencies || []).map(pyNorm));
324
- for (const name of managed) declared.add(name);
325
-
326
- const used = new Map(); // top import → { dist, files:Set, lines:Map }
327
- for (const e of externalImports) {
328
- if (e.ecosystem !== "PyPI" || e.builtin || e.unresolved || !e.pkg) continue;
329
- const top = String(e.spec || e.pkg).split(".")[0];
330
- let u = used.get(top);
331
- if (!u) used.set(top, (u = { dist: e.pkg, files: new Set(), lines: new Map() }));
332
- u.files.add(e.file);
333
- if (!u.lines.has(e.file)) u.lines.set(e.file, e.line || 0);
334
- }
335
- // generous match: does declared dist D cover import top t (dist guess g)?
336
- const covers = (D, t, g) => {
337
- const d = pyNorm(D), nt = pyNorm(t), ng = pyNorm(g);
338
- return d === nt || d === ng || d === `python-${nt}` || d === `${nt}-python` || d === `${nt}-binary` || d.replace(/\d+$/, "") === nt.replace(/\d+$/, "");
339
- };
340
-
341
- const configBlob = [...configTexts.values()].join("\n");
342
- if (present) {
343
- for (const d of deps) {
344
- const n = pyNorm(d.name);
345
- if (managed.has(n) || ignored.has(n)) continue;
346
- if (d.buildSystem || PY_TOOL_DISTS.has(n) || /^types-|-stubs$|^pytest-|^flake8-|^sphinx/.test(n)) continue;
347
- let hit = false;
348
- for (const [top, u] of used) if (covers(d.name, top, u.dist)) { hit = true; break; }
349
- if (hit || mentioned(configBlob, d.name)) continue;
350
- findings.push(makeFinding({
351
- category: "unused",
352
- rule: "unused-dep",
353
- severity: d.dev ? "info" : "low",
354
- confidence: "low",
355
- title: `Unused Python dependency: ${d.name}`,
356
- 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.`,
357
- package: d.name,
358
- source: "internal",
359
- fixHint: `remove "${d.name}" from the manifest after confirming nothing imports or shells out to it`,
360
- }));
361
- }
362
- }
363
- for (const [top, u] of used) {
364
- if (ignored.has(pyNorm(top)) || ignored.has(pyNorm(u.dist)) || managed.has(pyNorm(top)) || managed.has(pyNorm(u.dist))) continue;
365
- let hit = false;
366
- for (const D of declared) if (covers(D, top, u.dist)) { hit = true; break; }
367
- if (hit) continue;
368
- const files = [...u.files];
369
- const testOnly = files.every((f) => TEST_PATH_RE.test(f));
370
- findings.push(makeFinding({
371
- category: "unused",
372
- rule: "missing-dep",
373
- severity: present ? (testOnly ? "low" : "medium") : "low",
374
- confidence: present ? "medium" : "low",
375
- title: `Missing Python dependency: ${u.dist}`,
376
- 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."}`,
377
- package: u.dist,
378
- file: files[0],
379
- line: u.lines.get(files[0]) || 0,
380
- evidence: files.slice(0, 5).map((f) => ({ file: f, line: u.lines.get(f) || 0, snippet: "" })),
381
- source: "internal",
382
- 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`,
383
- }));
384
- }
385
- return { findings, declared, managed };
386
- }
274
+ export { computeGoDepFindings, computePyDepFindings } from "./dep-check-ecosystems.js";