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.
- package/README.md +92 -17
- package/package.json +1 -1
- package/skill/SKILL.md +62 -4
- package/src/analysis/dead-check.js +87 -6
- package/src/analysis/dep-check-ecosystems.js +157 -0
- package/src/analysis/dep-check.js +97 -133
- package/src/analysis/dep-rules.js +91 -12
- package/src/analysis/duplicates.compute.js +12 -18
- package/src/analysis/endpoints-rust.js +124 -0
- package/src/analysis/endpoints.js +56 -6
- package/src/analysis/graph-analysis.aggregate.js +34 -25
- package/src/analysis/graph-analysis.edges.js +21 -0
- package/src/analysis/graph-analysis.js +1 -1
- package/src/analysis/graph-analysis.summaries.js +4 -1
- package/src/analysis/internal-audit.collect.js +162 -25
- package/src/analysis/internal-audit.reach.js +52 -11
- package/src/analysis/internal-audit.run.js +71 -18
- package/src/graph/builder/lang-java.js +177 -14
- package/src/graph/builder/lang-js.js +66 -23
- package/src/graph/builder/lang-rust.js +129 -6
- package/src/graph/community.js +17 -0
- package/src/graph/internal-builder.build.js +79 -17
- package/src/graph/internal-builder.java.js +33 -0
- package/src/graph/internal-builder.langs.js +43 -2
- package/src/graph/internal-builder.resolvers.js +197 -21
- package/src/graph/relations.js +4 -0
- package/src/mcp/catalog.mjs +4 -4
- package/src/mcp/graph-context.mjs +22 -94
- package/src/mcp/graph-diff.mjs +163 -0
- package/src/mcp/sync-payload.mjs +36 -6
- package/src/mcp/tools-actions.mjs +22 -7
- package/src/mcp/tools-graph-hubs.mjs +58 -0
- package/src/mcp/tools-graph.mjs +13 -22
- package/src/mcp/tools-health.mjs +54 -18
- package/src/mcp/tools-impact.mjs +61 -45
- package/src/mcp-server.mjs +3 -0
- 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.
|
|
81
|
-
`open_repo` can change the active repository and
|
|
82
|
-
|
|
83
|
-
|
|
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,
|
|
99
|
-
cycles, orphans, boundary rules, offline OSV vulnerabilities + typosquat +
|
|
100
|
-
`find_duplicates` (MOSS winnowing over method bodies — catches copy-paste even
|
|
101
|
-
`coverage_map` (existing coverage reports mapped onto the graph; untested hotspots
|
|
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
|
|
114
|
-
|
|
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.
|
|
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
|
|
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,
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
+
}
|