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 CHANGED
@@ -77,10 +77,12 @@ claude mcp add -s user weavatrix -- node <path-to>/weavatrix/bin/weavatrix-mcp.m
77
77
  (`<repoRoot-parent>/weavatrix-graphs/<repoName>/graph.json`). Pass an explicit
78
78
  `<graph.json> <repoRoot>` pair instead if you keep graphs elsewhere.
79
79
 
80
- No graph yet? Ask the agent to call `rebuild_graph`; it builds the missing graph locally. When the
81
- `open_repo` can change the active repository and build its graph. Retargeting is offline but
82
- intentionally changes the filesystem boundary for subsequent tools; omit `retarget` from an explicit
83
- capability list when a registration must stay pinned to one repository.
80
+ No graph yet? Ask the agent to call `rebuild_graph`; it builds the missing graph locally.
81
+ `open_repo` can change the active repository and builds a missing graph automatically. A normal
82
+ `open_repo` call also upgrades graphs created before `0.1.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, import
99
- cycles, orphans, boundary rules, offline OSV vulnerabilities + typosquat + lockfile drift),
100
- `find_duplicates` (MOSS winnowing over method bodies — catches copy-paste even after renames),
101
- `coverage_map` (existing coverage reports mapped onto the graph; untested hotspots ranked by
102
- connectivity — tests are never executed)
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.2` must be rebuilt once before syncing.
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.2",
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 missing; `build:false` probes without building).
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
- export function computeDead(graph, sources) {
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({ externalImports = [], pkg = {}, workspacePkgNames = new Set(), configTexts = new Map() } = {}) {
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 package.json — it only works via a transitive install (phantom dependency) and can break on any lockfile change.`,
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({ externalImports = [], pyManifest = null, configTexts = new Map() } = {}) {
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 declares no Python dependencies at all (no requirements.txt / pyproject / Pipfile)"} it only works because the environment happens to have it${u.dist !== top ? ` (PyPI package is likely "${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."}`,
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
  }