weavatrix 0.1.2 → 0.1.3
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 +66 -11
- package/package.json +1 -1
- package/skill/SKILL.md +54 -3
- package/src/analysis/dead-check.js +80 -4
- package/src/analysis/dep-check.js +84 -8
- package/src/analysis/dep-rules.js +74 -8
- package/src/analysis/duplicates.compute.js +12 -18
- package/src/analysis/endpoints.js +45 -2
- package/src/analysis/graph-analysis.aggregate.js +11 -3
- package/src/analysis/internal-audit.collect.js +122 -25
- package/src/analysis/internal-audit.reach.js +52 -11
- package/src/analysis/internal-audit.run.js +65 -17
- package/src/graph/builder/lang-js.js +66 -23
- package/src/graph/internal-builder.build.js +48 -10
- package/src/graph/internal-builder.langs.js +43 -2
- package/src/graph/internal-builder.resolvers.js +88 -19
- package/src/mcp/catalog.mjs +2 -2
- package/src/mcp/graph-context.mjs +100 -13
- package/src/mcp/sync-payload.mjs +9 -1
- package/src/mcp/tools-actions.mjs +22 -7
- package/src/mcp/tools-graph.mjs +46 -12
- package/src/mcp/tools-health.mjs +42 -18
- package/src/mcp/tools-impact.mjs +52 -41
- 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.3` to typed import edges; `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,18 @@ 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 and TypeScript type-only coupling are reported
|
|
95
|
+
separately where that distinction changes the result.
|
|
93
96
|
|
|
94
97
|
**search / source** — `search_code` (ripgrep-backed, pure-Node fallback), `read_source` (a
|
|
95
98
|
symbol's actual code in one hop), `list_endpoints` (HTTP route inventory:
|
|
96
99
|
Express/Fastify/Nest/Flask/FastAPI/Go mux …)
|
|
97
100
|
|
|
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)
|
|
101
|
+
**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
|
+
lockfile drift), `find_duplicates` (MOSS winnowing over method bodies — catches copy-paste even
|
|
104
|
+
after renames), `coverage_map` (existing coverage reports mapped onto the graph; untested hotspots
|
|
105
|
+
ranked by connectivity — tests are never executed)
|
|
103
106
|
|
|
104
107
|
**build** — `rebuild_graph` (reports the structural delta, keeps the prior state as
|
|
105
108
|
`graph.prev.json`)
|
|
@@ -113,6 +116,51 @@ Quality of life: graph tools self-report staleness vs the repo HEAD; ambiguous n
|
|
|
113
116
|
disclosed instead of silently guessed; and the server **hot-reloads its own tool code** when the
|
|
114
117
|
files under `src/mcp/` change — no reconnect needed.
|
|
115
118
|
|
|
119
|
+
## Signal quality and repository configuration
|
|
120
|
+
|
|
121
|
+
Weavatrix `0.1.3` reduces the most common sources of static-analysis noise:
|
|
122
|
+
|
|
123
|
+
- In Git repositories, graph and clone scans use tracked plus non-ignored untracked files, so
|
|
124
|
+
`.gitignore`-excluded build outputs such as packaged applications do not dominate findings.
|
|
125
|
+
- TypeScript `import type` and type-only re-exports remain visible as compile-time coupling but do
|
|
126
|
+
not inflate runtime-cycle severity. `module_map`, `change_impact` and structural diffs preserve
|
|
127
|
+
that distinction. `god_nodes` ranks unique neighbors with runtime connectivity first and reports
|
|
128
|
+
repeated occurrences separately.
|
|
129
|
+
- Dependency checks resolve the nearest workspace manifest and `tsconfig`/`jsconfig` aliases,
|
|
130
|
+
account for framework-owned runtime peers such as Next.js + `react-dom`, and recognize Next.js
|
|
131
|
+
App Router route exports as endpoints.
|
|
132
|
+
- `coverage_map` reports coverage as **unavailable** when no supported report exists. That means
|
|
133
|
+
“no data”, not zero coverage.
|
|
134
|
+
- Duplicate output is a review queue, not a verdict: near-identical bodies are clone candidates;
|
|
135
|
+
same-name/different-body pairs are divergence candidates. Read both sources and confirm the
|
|
136
|
+
shared contract before consolidating code.
|
|
137
|
+
|
|
138
|
+
`run_audit` makes incomplete security coverage explicit. OSV state is `OK` only after every
|
|
139
|
+
supported pinned package/version for this repository was queried successfully. `PARTIAL` means
|
|
140
|
+
some queries failed, the response was incomplete, the dependency fingerprint changed, or the cache
|
|
141
|
+
uses a legacy stamp; `NOT_CHECKED` means there is no per-repository refresh; `ERROR` means the local
|
|
142
|
+
check itself failed. None of the latter three states is a clean vulnerability result. The cache
|
|
143
|
+
stores a fingerprint of the supported dependency set so a lockfile change cannot silently reuse a
|
|
144
|
+
stale `OK`.
|
|
145
|
+
|
|
146
|
+
For conventions that cannot be inferred safely, add an optional `.weavatrix-deps.json` at the
|
|
147
|
+
repository root:
|
|
148
|
+
|
|
149
|
+
```json
|
|
150
|
+
{
|
|
151
|
+
"entrypoints": ["scripts/publish-release.mjs"],
|
|
152
|
+
"python": {
|
|
153
|
+
"managedDependencies": ["numpy", "openvino-genai"],
|
|
154
|
+
"ignoreDependencies": ["vendor-sdk"]
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
`entrypoints` protects framework/script entry files from dead-code classification.
|
|
160
|
+
`managedDependencies` declares Python modules supplied by an external runtime;
|
|
161
|
+
`ignoreDependencies` suppresses intentionally unresolved Python packages. Keep the lists narrow:
|
|
162
|
+
they change audit interpretation, not the repository or its dependency installation.
|
|
163
|
+
|
|
116
164
|
## Privacy: local-first, offline by design
|
|
117
165
|
|
|
118
166
|
Graph queries, audits, clone scans and repository switching run locally. The default capability set
|
|
@@ -127,7 +175,14 @@ tools; both require the explicit `online` group and a tool call:
|
|
|
127
175
|
symbol names and line ranges, import/dependency identifiers, edges and numeric metrics. Unknown
|
|
128
176
|
fields are discarded; source file bodies are never read for sync or included in the payload. The
|
|
129
177
|
endpoint is **yours**, configured via `WEAVATRIX_SYNC_URL` / `WEAVATRIX_SYNC_TOKEN`. Off by default.
|
|
130
|
-
Graphs built before `0.1.
|
|
178
|
+
Sync payload v2 preserves type-only edge metadata. Graphs built before `0.1.3` must be rebuilt
|
|
179
|
+
once before syncing; a normal `open_repo` call performs that upgrade automatically.
|
|
180
|
+
|
|
181
|
+
If `refresh_advisories` is not listed by the MCP client, that is the expected default: the
|
|
182
|
+
registration does not include `online`. Only with the user's approval, add `online` to the final
|
|
183
|
+
capability list (for example `graph,search,source,health,build,retarget,online`), restart/reconnect
|
|
184
|
+
the MCP server, and then invoke `refresh_advisories`. Enabling the group does not trigger a request
|
|
185
|
+
by itself.
|
|
131
186
|
|
|
132
187
|
Capability groups (`graph`, `search`, `source`, `health`, `build`, `retarget`, `online`) are
|
|
133
188
|
selectable through the final positional argument. Omitted caps use the safe default above; an
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "weavatrix",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
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,34 @@ 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.3` graphs to typed import edges; `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 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.
|
|
35
|
+
- **Repository universe**: in Git repositories, graph and duplicate scans include tracked plus
|
|
36
|
+
non-ignored untracked files. If an old graph still contains packaged/generated output, rebuild it
|
|
37
|
+
before interpreting the result.
|
|
38
|
+
- **Coverage**: `coverage_map` reads an existing report. `unavailable` means no supported report was
|
|
39
|
+
found, not 0% coverage; do not rank testing risk from that state.
|
|
40
|
+
- **Audit completeness**: read `checks.osv.status` and `checks.malware.status`. For OSV, `OK` is
|
|
41
|
+
complete for the recorded dependency fingerprint; `PARTIAL` is incomplete or stale,
|
|
42
|
+
`NOT_CHECKED` has no repository-specific result, and `ERROR` means the local check failed. Treat
|
|
43
|
+
the same non-`OK` states as incomplete for malware scanning. Refresh OSV only when the user has
|
|
44
|
+
authorized adding the optional `online` capability group to the MCP registration and then
|
|
45
|
+
invoking `refresh_advisories`; enabling the group alone sends nothing.
|
|
24
46
|
- **Offline by design**: scans and graph queries run in-process against local files; coverage tools
|
|
25
47
|
read existing reports and never run tests. The ONLY network-touching tools live in the `online`
|
|
26
48
|
capability group and run solely when explicitly called: `refresh_advisories` (queries OSV.dev with
|
|
@@ -46,16 +68,45 @@ Tools are named `mcp__weavatrix__…`. If none are available, ask the user to re
|
|
|
46
68
|
(cycle broken? new module dependency introduced? symbols orphaned?); re-query later with
|
|
47
69
|
`graph_diff` (optionally scoped by `path`). The semantic complement to the textual git diff.
|
|
48
70
|
- **Health sweep**: `run_audit` (filter by `category`/`min_severity`) → `find_duplicates` →
|
|
49
|
-
`coverage_map`.
|
|
71
|
+
`coverage_map`. Inspect the source behind shortlisted findings before proposing edits.
|
|
50
72
|
- **API inventory**: `list_endpoints`.
|
|
51
73
|
- **Find code**: `search_code` (regex + glob) → `get_node` → `read_source`.
|
|
52
74
|
- **Another repo**: `list_known_repos` → `open_repo <path>`
|
|
53
|
-
(builds the graph when
|
|
75
|
+
(builds or upgrades the graph when needed; `build:false` probes without building).
|
|
76
|
+
|
|
77
|
+
## Repository-specific conventions
|
|
78
|
+
|
|
79
|
+
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:
|
|
83
|
+
|
|
84
|
+
```json
|
|
85
|
+
{
|
|
86
|
+
"entrypoints": ["scripts/publish-release.mjs"],
|
|
87
|
+
"python": {
|
|
88
|
+
"managedDependencies": ["numpy", "openvino-genai"],
|
|
89
|
+
"ignoreDependencies": ["vendor-sdk"]
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Keep exceptions narrow. `managedDependencies` documents modules provided outside the repo's Python
|
|
95
|
+
manifest; `ignoreDependencies` suppresses intentionally unresolved imports.
|
|
96
|
+
|
|
97
|
+
## Sync
|
|
98
|
+
|
|
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
|
|
101
|
+
unavailable until the user opts into `online` and configures `WEAVATRIX_SYNC_URL`.
|
|
54
102
|
|
|
55
103
|
## Troubleshooting
|
|
56
104
|
|
|
57
105
|
- `Graph unavailable` → `rebuild_graph`; `open_repo` can select another valid repository path unless
|
|
58
106
|
the registration deliberately omitted `retarget`.
|
|
107
|
+
- `refresh_advisories` is unavailable → with the user's approval, re-register/reconfigure the MCP
|
|
108
|
+
capability list to include `online`, reconnect it, and then invoke the tool. Do not enable network
|
|
109
|
+
access merely to turn `NOT_CHECKED` into a cosmetic green state.
|
|
59
110
|
- `No coverage report` → run the repo's own tests with coverage (`vitest run --coverage`,
|
|
60
111
|
`jest --coverage`, `pytest --cov --cov-report=json`, `go test -coverprofile=coverage.out`),
|
|
61
112
|
then re-call.
|
|
@@ -6,6 +6,7 @@
|
|
|
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";
|
|
10
11
|
|
|
11
12
|
const IDENT_RE = /[A-Za-z_$][\w$]*/g;
|
|
@@ -15,7 +16,80 @@ const bareName = (label) => String(label || "").replace(/\s*\(.*$/, "").replace(
|
|
|
15
16
|
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
17
|
const TEST_FILE = /(^|[\\/])[^\\/]*[._-](test|spec)\.[a-z0-9]+$|(^|[\\/])(test|tests|__tests__|spec)[\\/]/i;
|
|
17
18
|
|
|
18
|
-
|
|
19
|
+
// Framework-owned entry modules are invoked by convention rather than a source import. Keep this narrow:
|
|
20
|
+
// these are Next.js App/Pages Router surfaces and framework metadata files, not every file under `app/`.
|
|
21
|
+
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
|
+
|
|
24
|
+
const lineOfNode = (n) => {
|
|
25
|
+
const m = /@(\d+)$/.exec(String(n.id || "")) || /L(\d+)/.exec(String(n.source_location || ""));
|
|
26
|
+
return m ? Number(m[1]) : 0;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// Older graphs marked every method of an exported class as exported. Verify that an `exported` node is
|
|
30
|
+
// actually a module-surface declaration before reporting it. New graphs expose symbol_kind/member_of and
|
|
31
|
+
// already keep members unexported, but the source check preserves correctness for cached v0.1.2 graphs.
|
|
32
|
+
function hasModuleExport(source, node, name) {
|
|
33
|
+
if (node.member_of || node.symbol_kind === "method") return false;
|
|
34
|
+
const lines = String(source || "").split(/\r?\n/);
|
|
35
|
+
const line = lines[Math.max(0, lineOfNode(node) - 1)] || "";
|
|
36
|
+
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
37
|
+
const direct = new RegExp(`\\bexport\\s+(?:default\\s+)?(?:(?:declare|abstract|async)\\s+)*(?:function|class|const|let|var|enum|interface|type|namespace)\\s+${escaped}\\b`);
|
|
38
|
+
if (direct.test(line) || direct.test(String(source || ""))) return true;
|
|
39
|
+
const exportDefault = new RegExp(`\\bexport\\s+default\\s+${escaped}\\b`);
|
|
40
|
+
if (exportDefault.test(String(source || ""))) return true;
|
|
41
|
+
// `export { local }`, `export { local as publicName }` and CommonJS explicit exports.
|
|
42
|
+
const exportList = new RegExp(`\\bexport\\s*\\{[^}]*\\b${escaped}\\b[^}]*\\}`, "s");
|
|
43
|
+
const commonJs = new RegExp(`(?:module\\.exports\\s*=\\s*\\{[^}]*\\b${escaped}\\b|(?:module\\.exports|exports)\\.${escaped}\\s*=)`, "s");
|
|
44
|
+
return exportList.test(String(source || "")) || commonJs.test(String(source || ""));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function namespaceConsumedFiles(sources, graph) {
|
|
48
|
+
const files = new Set(sources.keys());
|
|
49
|
+
const consumed = new Set();
|
|
50
|
+
const extensions = ["", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"];
|
|
51
|
+
const resolve = (from, spec) => {
|
|
52
|
+
if (!spec.startsWith(".")) return "";
|
|
53
|
+
const base = posix.normalize(posix.join(posix.dirname(from.replace(/\\/g, "/")), spec));
|
|
54
|
+
for (const ext of extensions) {
|
|
55
|
+
const p = `${base}${ext}`;
|
|
56
|
+
if (files.has(p)) return p;
|
|
57
|
+
}
|
|
58
|
+
for (const ext of extensions.slice(1)) {
|
|
59
|
+
const p = `${base}/index${ext}`;
|
|
60
|
+
if (files.has(p)) return p;
|
|
61
|
+
}
|
|
62
|
+
return "";
|
|
63
|
+
};
|
|
64
|
+
for (const [file, textValue] of sources) {
|
|
65
|
+
const text = String(textValue || "");
|
|
66
|
+
const patterns = [
|
|
67
|
+
/\bimport\s+\*\s+as\s+[A-Za-z_$][\w$]*\s+from\s*(["'])([^"']+)\1/g,
|
|
68
|
+
/\bexport\s+\*\s+from\s*(["'])([^"']+)\1/g,
|
|
69
|
+
/\b(?:const|let|var)\s+[A-Za-z_$][\w$]*\s*=\s*require\(\s*(["'])([^"']+)\1\s*\)/g,
|
|
70
|
+
];
|
|
71
|
+
for (const re of patterns) {
|
|
72
|
+
let m;
|
|
73
|
+
while ((m = re.exec(text))) {
|
|
74
|
+
const target = resolve(file, m[2]);
|
|
75
|
+
if (target) consumed.add(target);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// New graphs retain the exact specifier on file import edges, which also resolves namespace imports
|
|
80
|
+
// expressed through tsconfig aliases (the relative-path fallback above intentionally cannot guess those).
|
|
81
|
+
for (const link of graph.links || []) {
|
|
82
|
+
if (link.relation !== "imports" && link.relation !== "re_exports") continue;
|
|
83
|
+
const from = String(link.source?.id || link.source || ""), target = String(link.target?.id || link.target || "");
|
|
84
|
+
if (!link.specifier || from.includes("#") || target.includes("#") || !sources.has(from)) continue;
|
|
85
|
+
const spec = String(link.specifier).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
86
|
+
const text = String(sources.get(from) || "");
|
|
87
|
+
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);
|
|
88
|
+
}
|
|
89
|
+
return consumed;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function computeDead(graph, sources, { entrySet = new Set() } = {}) {
|
|
19
93
|
const nodes = graph.nodes || [], links = graph.links || [];
|
|
20
94
|
const ep = (v) => (v && typeof v === "object" ? v.id : v);
|
|
21
95
|
const inbound = new Set();
|
|
@@ -73,7 +147,7 @@ export function computeDead(graph, sources) {
|
|
|
73
147
|
const deadFiles = [];
|
|
74
148
|
for (const n of nodes) {
|
|
75
149
|
if (String(n.id).includes("#")) continue; // file nodes only
|
|
76
|
-
if (inbound.has(n.id) || ENTRY_FILE.test(n.source_file)) continue;
|
|
150
|
+
if (inbound.has(n.id) || ENTRY_FILE.test(n.source_file) || isFrameworkEntryFile(n.source_file) || entrySet.has(n.source_file)) continue;
|
|
77
151
|
const syms = symsByFile.get(n.source_file) || [];
|
|
78
152
|
if (syms.length && syms.every((s) => deadSet.has(s.id))) deadFiles.push({ file: n.source_file, reason: "not imported; all symbols unreferenced" });
|
|
79
153
|
}
|
|
@@ -90,19 +164,21 @@ export function computeDead(graph, sources) {
|
|
|
90
164
|
// with no inbound non-contains edge whose bare name occurs in NO file other than its own. Same-file-only
|
|
91
165
|
// usage means the symbol is alive but the export is not — still worth surfacing. Pure like computeDead.
|
|
92
166
|
// dynamicTargets = files reached via dynamic import() — their exports are consumed at runtime, skip them.
|
|
93
|
-
export function computeUnusedExports(graph, sources, { dynamicTargets = new Set() } = {}) {
|
|
167
|
+
export function computeUnusedExports(graph, sources, { dynamicTargets = new Set(), entrySet = new Set() } = {}) {
|
|
94
168
|
const nodes = graph.nodes || [], links = graph.links || [];
|
|
95
169
|
const ep = (v) => (v && typeof v === "object" ? v.id : v);
|
|
96
170
|
const inbound = new Set();
|
|
97
171
|
for (const l of links) if (l.relation !== "contains") inbound.add(ep(l.target));
|
|
98
172
|
|
|
173
|
+
const namespaceConsumed = namespaceConsumedFiles(sources, graph);
|
|
99
174
|
const candidates = [];
|
|
100
175
|
const wanted = new Set();
|
|
101
176
|
for (const n of nodes) {
|
|
102
177
|
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;
|
|
178
|
+
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
179
|
const nm = bareName(n.label);
|
|
105
180
|
if (!nm || !/^[A-Za-z_$]/.test(nm)) continue;
|
|
181
|
+
if (!hasModuleExport(sources.get(n.source_file), n, nm)) continue;
|
|
106
182
|
candidates.push({ n, nm });
|
|
107
183
|
wanted.add(nm);
|
|
108
184
|
}
|
|
@@ -26,6 +26,16 @@ const BIN_PKG = {
|
|
|
26
26
|
playwright: "@playwright/test",
|
|
27
27
|
};
|
|
28
28
|
|
|
29
|
+
// Required peers consumed inside a framework/build tool rather than imported by application source.
|
|
30
|
+
// These contracts are package-scope local and deliberately narrow; declaring the provider is required
|
|
31
|
+
// for suppression, so an unrelated app still gets the normal unused-dependency finding.
|
|
32
|
+
const FRAMEWORK_RUNTIME_PEERS = new Map([
|
|
33
|
+
["next", ["react-dom"]],
|
|
34
|
+
["vinext", ["@vitejs/plugin-react", "@vitejs/plugin-rsc", "react-server-dom-webpack", "vite"]],
|
|
35
|
+
["electron-vite", ["vite"]],
|
|
36
|
+
["@cloudflare/vite-plugin", ["vite", "wrangler"]],
|
|
37
|
+
]);
|
|
38
|
+
|
|
29
39
|
const escRe = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
30
40
|
// word-ish mention: the name not embedded inside a longer identifier/path segment
|
|
31
41
|
const mentioned = (blob, name) => new RegExp(`(^|[^\\w@.-])${escRe(name)}($|[^\\w.-])`).test(blob);
|
|
@@ -35,8 +45,21 @@ const mentioned = (blob, name) => new RegExp(`(^|[^\\w@.-])${escRe(name)}($|[^\\
|
|
|
35
45
|
// pkg — parsed package.json ({} for non-JS repos → no dep findings)
|
|
36
46
|
// workspacePkgNames — Set of monorepo-local package names (never "missing")
|
|
37
47
|
// configTexts — Map<fileName, text> of root config files + CI workflows (mention scanning)
|
|
38
|
-
export function computeDepFindings({
|
|
48
|
+
export function computeDepFindings({
|
|
49
|
+
externalImports = [], pkg = {}, workspacePkgNames = new Set(), configTexts = new Map(),
|
|
50
|
+
aliases = [], scope = "", manifest = "package.json",
|
|
51
|
+
} = {}) {
|
|
39
52
|
const findings = [];
|
|
53
|
+
const meta = { scope: scope || ".", manifest };
|
|
54
|
+
const isAliasSpec = (spec) => aliases.some((a) => {
|
|
55
|
+
const s = String(spec || "");
|
|
56
|
+
const key = typeof a === "string" ? a : String(a?.key || "");
|
|
57
|
+
const prefix = typeof a === "string" ? a.replace(/\*.*$/, "") : String(a?.prefix || "");
|
|
58
|
+
const suffix = key.includes("*") ? key.slice(key.indexOf("*") + 1) : String(a?.suffix || "");
|
|
59
|
+
return key.includes("*")
|
|
60
|
+
? !!prefix && s.startsWith(prefix) && (!suffix || s.endsWith(suffix))
|
|
61
|
+
: !!key && s === key;
|
|
62
|
+
});
|
|
40
63
|
const sections = {
|
|
41
64
|
dependencies: pkg.dependencies || {},
|
|
42
65
|
devDependencies: pkg.devDependencies || {},
|
|
@@ -45,12 +68,20 @@ export function computeDepFindings({ externalImports = [], pkg = {}, workspacePk
|
|
|
45
68
|
};
|
|
46
69
|
const allDeclared = new Set(Object.values(sections).flatMap((s) => Object.keys(s)));
|
|
47
70
|
const selfName = String(pkg.name || "");
|
|
71
|
+
// Some frameworks consume required peers internally, so application source legitimately never imports
|
|
72
|
+
// them. Keep this deliberately narrow: react-dom is a required Next.js runtime peer in the same package
|
|
73
|
+
// scope, not a general React exemption and not a repo-wide whitelist.
|
|
74
|
+
const frameworkRuntime = new Set();
|
|
75
|
+
for (const [provider, peers] of FRAMEWORK_RUNTIME_PEERS) {
|
|
76
|
+
if (allDeclared.has(provider)) for (const peer of peers) frameworkRuntime.add(peer);
|
|
77
|
+
}
|
|
48
78
|
|
|
49
79
|
// ---- usage index from the graph's recorded imports ----
|
|
50
80
|
const usedPackages = new Map(); // pkgName -> { files:Set, lines:Map(file→first line), kinds:Set }
|
|
51
81
|
let builtinUsed = false;
|
|
52
82
|
for (const e of externalImports) {
|
|
53
83
|
if (e.ecosystem && e.ecosystem !== "npm") continue; // go/python imports have their own checkers
|
|
84
|
+
if (isAliasSpec(e.spec)) continue;
|
|
54
85
|
if (e.unresolved) continue;
|
|
55
86
|
if (e.builtin) { builtinUsed = true; continue; }
|
|
56
87
|
if (!e.pkg) continue;
|
|
@@ -75,10 +106,10 @@ export function computeDepFindings({ externalImports = [], pkg = {}, workspacePk
|
|
|
75
106
|
for (const [section, deps] of Object.entries(sections)) {
|
|
76
107
|
if (section === "peerDependencies") continue; // peers are consumer-facing contracts, not usage
|
|
77
108
|
for (const name of Object.keys(deps)) {
|
|
78
|
-
if (name === selfName || usedPackages.has(name)) continue;
|
|
109
|
+
if (name === selfName || usedPackages.has(name) || frameworkRuntime.has(name)) continue;
|
|
79
110
|
const tb = typesBase(name);
|
|
80
111
|
if (tb) { // @types/x is used iff x is used (or it types the Node builtins)
|
|
81
|
-
if (tb === "node" ? builtinUsed : usedPackages.has(tb) || mentioned(configBlob, tb)) continue;
|
|
112
|
+
if (tb === "node" ? builtinUsed : usedPackages.has(tb) || frameworkRuntime.has(tb) || mentioned(configBlob, tb)) continue;
|
|
82
113
|
}
|
|
83
114
|
if (binReferenced(name) || mentioned(configBlob, name)) continue; // scripts/config keep it alive
|
|
84
115
|
const ecosystem = CONFIG_ECOSYSTEM_RE.test(name);
|
|
@@ -94,6 +125,7 @@ export function computeDepFindings({ externalImports = [], pkg = {}, workspacePk
|
|
|
94
125
|
package: name,
|
|
95
126
|
source: "internal",
|
|
96
127
|
fixHint: `npm uninstall ${name} (after confirming no config/CLI usage)`,
|
|
128
|
+
...meta,
|
|
97
129
|
}));
|
|
98
130
|
}
|
|
99
131
|
}
|
|
@@ -109,13 +141,14 @@ export function computeDepFindings({ externalImports = [], pkg = {}, workspacePk
|
|
|
109
141
|
severity: testOnly ? "low" : "medium",
|
|
110
142
|
confidence: "high",
|
|
111
143
|
title: `Missing dependency: ${name}`,
|
|
112
|
-
detail: `"${name}" is imported by ${files.length} file(s) but not declared in
|
|
144
|
+
detail: `"${name}" is imported by ${files.length} file(s) in scope ${meta.scope} but not declared in ${manifest} — it only works via a transitive install (phantom dependency) and can break on any lockfile change.`,
|
|
113
145
|
package: name,
|
|
114
146
|
file: files[0],
|
|
115
147
|
line: use.lines.get(files[0]) || 0,
|
|
116
148
|
evidence: files.slice(0, 5).map((f) => ({ file: f, line: use.lines.get(f) || 0, snippet: "" })),
|
|
117
149
|
source: "internal",
|
|
118
150
|
fixHint: `npm install ${testOnly ? "--save-dev " : ""}${name}`,
|
|
151
|
+
...meta,
|
|
119
152
|
}));
|
|
120
153
|
}
|
|
121
154
|
|
|
@@ -134,6 +167,7 @@ export function computeDepFindings({ externalImports = [], pkg = {}, workspacePk
|
|
|
134
167
|
detail: `"${name}" is declared in ${ss.join(" + ")} — npm resolves one of them; keep a single section.`,
|
|
135
168
|
package: name,
|
|
136
169
|
source: "internal",
|
|
170
|
+
...meta,
|
|
137
171
|
}));
|
|
138
172
|
}
|
|
139
173
|
|
|
@@ -156,6 +190,7 @@ export function computeDepFindings({ externalImports = [], pkg = {}, workspacePk
|
|
|
156
190
|
file: e.file,
|
|
157
191
|
line: e.line || 0,
|
|
158
192
|
source: "internal",
|
|
193
|
+
...meta,
|
|
159
194
|
}));
|
|
160
195
|
}
|
|
161
196
|
if (unresolvedCount > unresolvedSeen.size) {
|
|
@@ -167,12 +202,45 @@ export function computeDepFindings({ externalImports = [], pkg = {}, workspacePk
|
|
|
167
202
|
title: `…and ${unresolvedCount - unresolvedSeen.size} more unresolved imports`,
|
|
168
203
|
detail: "Finding list capped at 100 unique unresolved imports; the count above is the true total.",
|
|
169
204
|
source: "internal",
|
|
205
|
+
...meta,
|
|
170
206
|
}));
|
|
171
207
|
}
|
|
172
208
|
|
|
173
209
|
return { findings, usedPackages, declared: allDeclared };
|
|
174
210
|
}
|
|
175
211
|
|
|
212
|
+
const normScope = (root) => String(root || "").replace(/\\/g, "/").replace(/^\.\//, "").replace(/^\/+|\/+$/g, "");
|
|
213
|
+
const ownsFile = (scope, file) => !scope || file === scope || String(file || "").startsWith(`${scope}/`);
|
|
214
|
+
|
|
215
|
+
// Judge every import against its nearest ancestor package.json. This is the dependency equivalent of
|
|
216
|
+
// Node's package scope and prevents nested Next/Vite apps from inheriting the root manifest by accident.
|
|
217
|
+
export function computeScopedDepFindings({
|
|
218
|
+
externalImports = [], packageScopes = [], workspacePkgNames = new Set(), configTexts = new Map(),
|
|
219
|
+
} = {}) {
|
|
220
|
+
const scopes = packageScopes.length
|
|
221
|
+
? packageScopes.map((s) => ({ ...s, root: normScope(s.root) })).sort((a, b) => b.root.length - a.root.length)
|
|
222
|
+
: [{ root: "", manifest: "package.json", pkg: {}, aliases: [] }];
|
|
223
|
+
const importsByScope = new Map(scopes.map((s) => [s, []]));
|
|
224
|
+
for (const e of externalImports) {
|
|
225
|
+
const owner = scopes.find((s) => ownsFile(s.root, e.file)) || scopes[scopes.length - 1];
|
|
226
|
+
importsByScope.get(owner).push(e);
|
|
227
|
+
}
|
|
228
|
+
const configOwner = new Map();
|
|
229
|
+
for (const [file] of configTexts) configOwner.set(file, scopes.find((scope) => ownsFile(scope.root, file)) || scopes[scopes.length - 1]);
|
|
230
|
+
const findings = [], usedPackages = new Map(), declared = new Set();
|
|
231
|
+
for (const s of scopes) {
|
|
232
|
+
const scopeConfig = new Map([...configTexts].filter(([f]) => configOwner.get(f) === s));
|
|
233
|
+
const r = computeDepFindings({
|
|
234
|
+
externalImports: importsByScope.get(s), pkg: s.pkg || {}, workspacePkgNames, configTexts: scopeConfig,
|
|
235
|
+
aliases: s.aliases || [], scope: s.root, manifest: s.manifest || (s.root ? `${s.root}/package.json` : "package.json"),
|
|
236
|
+
});
|
|
237
|
+
findings.push(...r.findings);
|
|
238
|
+
for (const [name, use] of r.usedPackages) usedPackages.set(`${s.root || "."}:${name}`, use);
|
|
239
|
+
for (const name of r.declared) declared.add(`${s.root || "."}:${name}`);
|
|
240
|
+
}
|
|
241
|
+
return { findings, usedPackages, declared };
|
|
242
|
+
}
|
|
243
|
+
|
|
176
244
|
const TEST_PATH_RE = /(^|[/\\])(test|tests|__tests__|spec|e2e|__mocks__)([/\\]|$)|[._-](test|spec)\.[a-z0-9]+$|_test\.go$/i;
|
|
177
245
|
|
|
178
246
|
// ---- Go: set math over ecosystem:"Go" externalImports vs go.mod requires ----
|
|
@@ -243,11 +311,17 @@ const PY_TOOL_DISTS = new Set(("pytest tox nox black ruff flake8 pylint mypy pyr
|
|
|
243
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(" "));
|
|
244
312
|
const pyNorm = (n) => String(n || "").toLowerCase().replace(/[-_.]+/g, "-");
|
|
245
313
|
|
|
246
|
-
export function computePyDepFindings({
|
|
314
|
+
export function computePyDepFindings({
|
|
315
|
+
externalImports = [], pyManifest = null, configTexts = new Map(),
|
|
316
|
+
managedDependencies = [], ignoredDependencies = [],
|
|
317
|
+
} = {}) {
|
|
247
318
|
const findings = [];
|
|
248
319
|
const deps = (pyManifest && pyManifest.deps) || [];
|
|
249
320
|
const present = !!(pyManifest && pyManifest.present);
|
|
250
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);
|
|
251
325
|
|
|
252
326
|
const used = new Map(); // top import → { dist, files:Set, lines:Map }
|
|
253
327
|
for (const e of externalImports) {
|
|
@@ -268,6 +342,7 @@ export function computePyDepFindings({ externalImports = [], pyManifest = null,
|
|
|
268
342
|
if (present) {
|
|
269
343
|
for (const d of deps) {
|
|
270
344
|
const n = pyNorm(d.name);
|
|
345
|
+
if (managed.has(n) || ignored.has(n)) continue;
|
|
271
346
|
if (d.buildSystem || PY_TOOL_DISTS.has(n) || /^types-|-stubs$|^pytest-|^flake8-|^sphinx/.test(n)) continue;
|
|
272
347
|
let hit = false;
|
|
273
348
|
for (const [top, u] of used) if (covers(d.name, top, u.dist)) { hit = true; break; }
|
|
@@ -286,6 +361,7 @@ export function computePyDepFindings({ externalImports = [], pyManifest = null,
|
|
|
286
361
|
}
|
|
287
362
|
}
|
|
288
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;
|
|
289
365
|
let hit = false;
|
|
290
366
|
for (const D of declared) if (covers(D, top, u.dist)) { hit = true; break; }
|
|
291
367
|
if (hit) continue;
|
|
@@ -297,14 +373,14 @@ export function computePyDepFindings({ externalImports = [], pyManifest = null,
|
|
|
297
373
|
severity: present ? (testOnly ? "low" : "medium") : "low",
|
|
298
374
|
confidence: present ? "medium" : "low",
|
|
299
375
|
title: `Missing Python dependency: ${u.dist}`,
|
|
300
|
-
detail: `"${top}" is imported by ${files.length} file(s) but ${present ? "no declared dependency provides it" : "the repo
|
|
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."}`,
|
|
301
377
|
package: u.dist,
|
|
302
378
|
file: files[0],
|
|
303
379
|
line: u.lines.get(files[0]) || 0,
|
|
304
380
|
evidence: files.slice(0, 5).map((f) => ({ file: f, line: u.lines.get(f) || 0, snippet: "" })),
|
|
305
381
|
source: "internal",
|
|
306
|
-
fixHint: `pip install ${u.dist} (and add it to requirements.txt / pyproject)`,
|
|
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`,
|
|
307
383
|
}));
|
|
308
384
|
}
|
|
309
|
-
return { findings, declared };
|
|
385
|
+
return { findings, declared, managed };
|
|
310
386
|
}
|