weavatrix 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +92 -17
  2. package/package.json +1 -1
  3. package/skill/SKILL.md +62 -4
  4. package/src/analysis/dead-check.js +87 -6
  5. package/src/analysis/dep-check-ecosystems.js +157 -0
  6. package/src/analysis/dep-check.js +97 -133
  7. package/src/analysis/dep-rules.js +91 -12
  8. package/src/analysis/duplicates.compute.js +12 -18
  9. package/src/analysis/endpoints-rust.js +124 -0
  10. package/src/analysis/endpoints.js +56 -6
  11. package/src/analysis/graph-analysis.aggregate.js +34 -25
  12. package/src/analysis/graph-analysis.edges.js +21 -0
  13. package/src/analysis/graph-analysis.js +1 -1
  14. package/src/analysis/graph-analysis.summaries.js +4 -1
  15. package/src/analysis/internal-audit.collect.js +162 -25
  16. package/src/analysis/internal-audit.reach.js +52 -11
  17. package/src/analysis/internal-audit.run.js +71 -18
  18. package/src/graph/builder/lang-java.js +177 -14
  19. package/src/graph/builder/lang-js.js +66 -23
  20. package/src/graph/builder/lang-rust.js +129 -6
  21. package/src/graph/community.js +17 -0
  22. package/src/graph/internal-builder.build.js +79 -17
  23. package/src/graph/internal-builder.java.js +33 -0
  24. package/src/graph/internal-builder.langs.js +43 -2
  25. package/src/graph/internal-builder.resolvers.js +197 -21
  26. package/src/graph/relations.js +4 -0
  27. package/src/mcp/catalog.mjs +4 -4
  28. package/src/mcp/graph-context.mjs +22 -94
  29. package/src/mcp/graph-diff.mjs +163 -0
  30. package/src/mcp/sync-payload.mjs +36 -6
  31. package/src/mcp/tools-actions.mjs +22 -7
  32. package/src/mcp/tools-graph-hubs.mjs +58 -0
  33. package/src/mcp/tools-graph.mjs +13 -22
  34. package/src/mcp/tools-health.mjs +54 -18
  35. package/src/mcp/tools-impact.mjs +61 -45
  36. package/src/mcp-server.mjs +3 -0
  37. package/src/security/advisory-store.js +51 -7
@@ -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", nonRuntimeRoots = [],
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,46 @@ 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
+ const normRoots = (nonRuntimeRoots || []).map((root) => String(root || "").replace(/\\/g, "/").replace(/^\.\//, "").replace(/^\/+|\/+$/g, "")).filter(Boolean);
72
+ const isNonRuntimeFile = (file) => {
73
+ const normalized = String(file || "").replace(/\\/g, "/").replace(/^\.\//, "");
74
+ return normRoots.some((root) => normalized === root || normalized.startsWith(`${root}/`));
75
+ };
76
+ const npmNameParts = (name) => {
77
+ const value = String(name || "");
78
+ const slash = value.startsWith("@") ? value.indexOf("/") : -1;
79
+ return slash > 0 ? { scope: value.slice(0, slash), base: value.slice(slash + 1) } : { scope: "", base: value };
80
+ };
81
+ const selfParts = npmNameParts(selfName);
82
+ const configuredNapi = npmNameParts(pkg.napi?.name || selfName);
83
+ const napiBinding = {
84
+ scope: configuredNapi.scope || (configuredNapi.base === selfParts.base ? selfParts.scope : ""),
85
+ base: configuredNapi.base,
86
+ };
87
+ const NAPI_PLATFORM_SUFFIX = /^(?:android-(?:arm64|arm-eabi)|win32-(?:x64|ia32|arm64)-msvc|darwin-(?:universal|x64|arm64)|freebsd-(?:x64|arm64)|linux-(?:x64|arm64|arm|riscv64|s390x|ppc64|loong64)-(?:gnu|musl|gnueabihf|musleabihf)|aix-ppc64|sunos-x64|wasm32-wasi(?:-preview1-threads)?)$/;
88
+ const isGeneratedNapiPackage = (name, files) => {
89
+ if (!pkg.napi || !napiBinding.base || !files.every((file) => /(^|\/)index\.[cm]?js$/i.test(String(file || "").replace(/\\/g, "/")))) return false;
90
+ const candidate = npmNameParts(name);
91
+ if (candidate.scope !== napiBinding.scope || !candidate.base.startsWith(`${napiBinding.base}-`)) return false;
92
+ return NAPI_PLATFORM_SUFFIX.test(candidate.base.slice(napiBinding.base.length + 1));
93
+ };
94
+ const isGeneratedNapiBinary = (entry) => !!pkg.napi
95
+ && /(^|\/)index\.[cm]?js$/i.test(String(entry.file || "").replace(/\\/g, "/"))
96
+ && /^\.\/[^/]+\.node$/i.test(String(entry.spec || ""));
97
+ // Some frameworks consume required peers internally, so application source legitimately never imports
98
+ // them. Keep this deliberately narrow: react-dom is a required Next.js runtime peer in the same package
99
+ // scope, not a general React exemption and not a repo-wide whitelist.
100
+ const frameworkRuntime = new Set();
101
+ for (const [provider, peers] of FRAMEWORK_RUNTIME_PEERS) {
102
+ if (allDeclared.has(provider)) for (const peer of peers) frameworkRuntime.add(peer);
103
+ }
48
104
 
49
105
  // ---- usage index from the graph's recorded imports ----
50
106
  const usedPackages = new Map(); // pkgName -> { files:Set, lines:Map(file→first line), kinds:Set }
51
107
  let builtinUsed = false;
52
108
  for (const e of externalImports) {
53
109
  if (e.ecosystem && e.ecosystem !== "npm") continue; // go/python imports have their own checkers
110
+ if (isAliasSpec(e.spec)) continue;
54
111
  if (e.unresolved) continue;
55
112
  if (e.builtin) { builtinUsed = true; continue; }
56
113
  if (!e.pkg) continue;
@@ -75,10 +132,10 @@ export function computeDepFindings({ externalImports = [], pkg = {}, workspacePk
75
132
  for (const [section, deps] of Object.entries(sections)) {
76
133
  if (section === "peerDependencies") continue; // peers are consumer-facing contracts, not usage
77
134
  for (const name of Object.keys(deps)) {
78
- if (name === selfName || usedPackages.has(name)) continue;
135
+ if (name === selfName || usedPackages.has(name) || frameworkRuntime.has(name)) continue;
79
136
  const tb = typesBase(name);
80
137
  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;
138
+ if (tb === "node" ? builtinUsed : usedPackages.has(tb) || frameworkRuntime.has(tb) || mentioned(configBlob, tb)) continue;
82
139
  }
83
140
  if (binReferenced(name) || mentioned(configBlob, name)) continue; // scripts/config keep it alive
84
141
  const ecosystem = CONFIG_ECOSYSTEM_RE.test(name);
@@ -94,6 +151,7 @@ export function computeDepFindings({ externalImports = [], pkg = {}, workspacePk
94
151
  package: name,
95
152
  source: "internal",
96
153
  fixHint: `npm uninstall ${name} (after confirming no config/CLI usage)`,
154
+ ...meta,
97
155
  }));
98
156
  }
99
157
  }
@@ -102,6 +160,7 @@ export function computeDepFindings({ externalImports = [], pkg = {}, workspacePk
102
160
  for (const [name, use] of usedPackages) {
103
161
  if (allDeclared.has(name) || name === selfName || workspacePkgNames.has(name)) continue;
104
162
  const files = [...use.files];
163
+ if (files.every(isNonRuntimeFile) || isGeneratedNapiPackage(name, files)) continue;
105
164
  const testOnly = files.every((f) => /(^|[/\\])(test|tests|__tests__|spec|e2e|__mocks__)([/\\]|$)|[._-](test|spec)\.[a-z]+$/i.test(f));
106
165
  findings.push(makeFinding({
107
166
  category: "unused",
@@ -109,13 +168,14 @@ export function computeDepFindings({ externalImports = [], pkg = {}, workspacePk
109
168
  severity: testOnly ? "low" : "medium",
110
169
  confidence: "high",
111
170
  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.`,
171
+ 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
172
  package: name,
114
173
  file: files[0],
115
174
  line: use.lines.get(files[0]) || 0,
116
175
  evidence: files.slice(0, 5).map((f) => ({ file: f, line: use.lines.get(f) || 0, snippet: "" })),
117
176
  source: "internal",
118
177
  fixHint: `npm install ${testOnly ? "--save-dev " : ""}${name}`,
178
+ ...meta,
119
179
  }));
120
180
  }
121
181
 
@@ -134,6 +194,7 @@ export function computeDepFindings({ externalImports = [], pkg = {}, workspacePk
134
194
  detail: `"${name}" is declared in ${ss.join(" + ")} — npm resolves one of them; keep a single section.`,
135
195
  package: name,
136
196
  source: "internal",
197
+ ...meta,
137
198
  }));
138
199
  }
139
200
 
@@ -142,6 +203,7 @@ export function computeDepFindings({ externalImports = [], pkg = {}, workspacePk
142
203
  let unresolvedCount = 0;
143
204
  for (const e of externalImports) {
144
205
  if (!e.unresolved) continue;
206
+ if (isNonRuntimeFile(e.file) || isGeneratedNapiBinary(e)) continue;
145
207
  unresolvedCount++;
146
208
  const key = e.file + "|" + e.spec;
147
209
  if (unresolvedSeen.has(key) || unresolvedSeen.size >= 100) continue; // cap the findings, keep the count honest
@@ -156,6 +218,7 @@ export function computeDepFindings({ externalImports = [], pkg = {}, workspacePk
156
218
  file: e.file,
157
219
  line: e.line || 0,
158
220
  source: "internal",
221
+ ...meta,
159
222
  }));
160
223
  }
161
224
  if (unresolvedCount > unresolvedSeen.size) {
@@ -167,144 +230,45 @@ export function computeDepFindings({ externalImports = [], pkg = {}, workspacePk
167
230
  title: `…and ${unresolvedCount - unresolvedSeen.size} more unresolved imports`,
168
231
  detail: "Finding list capped at 100 unique unresolved imports; the count above is the true total.",
169
232
  source: "internal",
233
+ ...meta,
170
234
  }));
171
235
  }
172
236
 
173
237
  return { findings, usedPackages, declared: allDeclared };
174
238
  }
175
239
 
176
- const TEST_PATH_RE = /(^|[/\\])(test|tests|__tests__|spec|e2e|__mocks__)([/\\]|$)|[._-](test|spec)\.[a-z0-9]+$|_test\.go$/i;
240
+ const normScope = (root) => String(root || "").replace(/\\/g, "/").replace(/^\.\//, "").replace(/^\/+|\/+$/g, "");
241
+ const ownsFile = (scope, file) => !scope || file === scope || String(file || "").startsWith(`${scope}/`);
177
242
 
178
- // ---- Go: set math over ecosystem:"Go" externalImports vs go.mod requires ----
179
- // goMod = parseGoMod() output. Only DIRECT requires can be "unused" (indirect ones belong to `go mod
180
- // tidy`); replace targets and the own module never count. Missing = imported module with no require
181
- // prefix rare in Go (builds fail), so it usually flags vendored/replaced setups: keep it medium.
182
- export function computeGoDepFindings({ externalImports = [], goMod = null } = {}) {
183
- const findings = [];
184
- if (!goMod || !goMod.module) return { findings, declared: new Set() };
185
- const requires = goMod.requires || [];
186
- const declared = new Set(requires.map((r) => r.path));
187
- const replaced = new Set((goMod.replaces || []).map((r) => r.from));
188
- const moduleOf = (spec) => { let best = ""; for (const p of declared) if ((spec === p || spec.startsWith(p + "/")) && p.length > best.length) best = p; return best; };
189
-
190
- const usedModules = new Set();
191
- const missing = new Map(); // pkg → { files:Set, lines:Map }
243
+ // Judge every import against its nearest ancestor package.json. This is the dependency equivalent of
244
+ // Node's package scope and prevents nested Next/Vite apps from inheriting the root manifest by accident.
245
+ export function computeScopedDepFindings({
246
+ externalImports = [], packageScopes = [], workspacePkgNames = new Set(), configTexts = new Map(),
247
+ nonRuntimeRoots = [],
248
+ } = {}) {
249
+ const scopes = packageScopes.length
250
+ ? packageScopes.map((s) => ({ ...s, root: normScope(s.root) })).sort((a, b) => b.root.length - a.root.length)
251
+ : [{ root: "", manifest: "package.json", pkg: {}, aliases: [] }];
252
+ const importsByScope = new Map(scopes.map((s) => [s, []]));
192
253
  for (const e of externalImports) {
193
- if (e.ecosystem !== "Go" || e.builtin || e.unresolved || !e.pkg) continue;
194
- const mod = moduleOf(e.spec || e.pkg) || (declared.has(e.pkg) ? e.pkg : "");
195
- if (mod) { usedModules.add(mod); continue; }
196
- let m = missing.get(e.pkg);
197
- if (!m) missing.set(e.pkg, (m = { files: new Set(), lines: new Map() }));
198
- m.files.add(e.file);
199
- if (!m.lines.has(e.file)) m.lines.set(e.file, e.line || 0);
254
+ const owner = scopes.find((s) => ownsFile(s.root, e.file)) || scopes[scopes.length - 1];
255
+ importsByScope.get(owner).push(e);
200
256
  }
201
-
202
- for (const r of requires) {
203
- if (r.indirect || usedModules.has(r.path)) continue;
204
- findings.push(makeFinding({
205
- category: "unused",
206
- rule: "unused-dep",
207
- severity: "low",
208
- confidence: "medium",
209
- title: `Unused Go module: ${r.path}`,
210
- 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.`,
211
- package: r.path,
212
- version: r.version,
213
- source: "internal",
214
- fixHint: "go mod tidy (drops requires nothing imports)",
215
- }));
216
- }
217
- for (const [pkg, use] of missing) {
218
- if (replaced.has(pkg)) continue;
219
- const files = [...use.files];
220
- findings.push(makeFinding({
221
- category: "unused",
222
- rule: "missing-dep",
223
- severity: "medium",
224
- confidence: "medium",
225
- title: `Missing Go module: ${pkg}`,
226
- 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.`,
227
- package: pkg,
228
- file: files[0],
229
- line: use.lines.get(files[0]) || 0,
230
- evidence: files.slice(0, 5).map((f) => ({ file: f, line: use.lines.get(f) || 0, snippet: "" })),
231
- source: "internal",
232
- fixHint: `go get ${pkg}`,
233
- }));
257
+ const configOwner = new Map();
258
+ for (const [file] of configTexts) configOwner.set(file, scopes.find((scope) => ownsFile(scope.root, file)) || scopes[scopes.length - 1]);
259
+ const findings = [], usedPackages = new Map(), declared = new Set();
260
+ for (const s of scopes) {
261
+ const scopeConfig = new Map([...configTexts].filter(([f]) => configOwner.get(f) === s));
262
+ const r = computeDepFindings({
263
+ externalImports: importsByScope.get(s), pkg: s.pkg || {}, workspacePkgNames, configTexts: scopeConfig,
264
+ aliases: s.aliases || [], scope: s.root, manifest: s.manifest || (s.root ? `${s.root}/package.json` : "package.json"),
265
+ nonRuntimeRoots,
266
+ });
267
+ findings.push(...r.findings);
268
+ for (const [name, use] of r.usedPackages) usedPackages.set(`${s.root || "."}:${name}`, use);
269
+ for (const name of r.declared) declared.add(`${s.root || "."}:${name}`);
234
270
  }
235
- return { findings, declared };
271
+ return { findings, usedPackages, declared };
236
272
  }
237
273
 
238
- // ---- Python: ecosystem:"PyPI" externalImports vs requirements/pyproject/Pipfile ----
239
- // Import→dist naming is heuristic (yaml→PyYAML, python-X/X-python variants), so matching is GENEROUS for
240
- // suppression and every unused finding stays low-confidence. CLI-only tools (pytest, black, gunicorn …)
241
- // and stub/plugin conventions (types-*, *-stubs, pytest-*, flake8-*) are never flagged unused.
242
- 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 " +
243
- "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
- const pyNorm = (n) => String(n || "").toLowerCase().replace(/[-_.]+/g, "-");
245
-
246
- export function computePyDepFindings({ externalImports = [], pyManifest = null, configTexts = new Map() } = {}) {
247
- const findings = [];
248
- const deps = (pyManifest && pyManifest.deps) || [];
249
- const present = !!(pyManifest && pyManifest.present);
250
- const declared = new Set(deps.map((d) => pyNorm(d.name)));
251
-
252
- const used = new Map(); // top import → { dist, files:Set, lines:Map }
253
- for (const e of externalImports) {
254
- if (e.ecosystem !== "PyPI" || e.builtin || e.unresolved || !e.pkg) continue;
255
- const top = String(e.spec || e.pkg).split(".")[0];
256
- let u = used.get(top);
257
- if (!u) used.set(top, (u = { dist: e.pkg, files: new Set(), lines: new Map() }));
258
- u.files.add(e.file);
259
- if (!u.lines.has(e.file)) u.lines.set(e.file, e.line || 0);
260
- }
261
- // generous match: does declared dist D cover import top t (dist guess g)?
262
- const covers = (D, t, g) => {
263
- const d = pyNorm(D), nt = pyNorm(t), ng = pyNorm(g);
264
- return d === nt || d === ng || d === `python-${nt}` || d === `${nt}-python` || d === `${nt}-binary` || d.replace(/\d+$/, "") === nt.replace(/\d+$/, "");
265
- };
266
-
267
- const configBlob = [...configTexts.values()].join("\n");
268
- if (present) {
269
- for (const d of deps) {
270
- const n = pyNorm(d.name);
271
- if (d.buildSystem || PY_TOOL_DISTS.has(n) || /^types-|-stubs$|^pytest-|^flake8-|^sphinx/.test(n)) continue;
272
- let hit = false;
273
- for (const [top, u] of used) if (covers(d.name, top, u.dist)) { hit = true; break; }
274
- if (hit || mentioned(configBlob, d.name)) continue;
275
- findings.push(makeFinding({
276
- category: "unused",
277
- rule: "unused-dep",
278
- severity: d.dev ? "info" : "low",
279
- confidence: "low",
280
- title: `Unused Python dependency: ${d.name}`,
281
- 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.`,
282
- package: d.name,
283
- source: "internal",
284
- fixHint: `remove "${d.name}" from the manifest after confirming nothing imports or shells out to it`,
285
- }));
286
- }
287
- }
288
- for (const [top, u] of used) {
289
- let hit = false;
290
- for (const D of declared) if (covers(D, top, u.dist)) { hit = true; break; }
291
- if (hit) continue;
292
- const files = [...u.files];
293
- const testOnly = files.every((f) => TEST_PATH_RE.test(f));
294
- findings.push(makeFinding({
295
- category: "unused",
296
- rule: "missing-dep",
297
- severity: present ? (testOnly ? "low" : "medium") : "low",
298
- confidence: present ? "medium" : "low",
299
- 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}")` : ""}.`,
301
- package: u.dist,
302
- file: files[0],
303
- line: u.lines.get(files[0]) || 0,
304
- evidence: files.slice(0, 5).map((f) => ({ file: f, line: u.lines.get(f) || 0, snippet: "" })),
305
- source: "internal",
306
- fixHint: `pip install ${u.dist} (and add it to requirements.txt / pyproject)`,
307
- }));
308
- }
309
- return { findings, declared };
310
- }
274
+ export { computeGoDepFindings, computePyDepFindings } from "./dep-check-ecosystems.js";
@@ -7,7 +7,6 @@
7
7
  // built from EXTRACTED import edges → high confidence.
8
8
  import { makeFinding } from "./findings.js";
9
9
  import { ENTRY_FILE } from "./dead-check.js";
10
-
11
10
  const TEST_FILE_RE = /(^|[/])(test|tests|__tests__|spec|e2e|__mocks__)([/]|$)|[._-](test|spec)\.[a-z0-9]+$/i;
12
11
  // config/data/docs: never "orphans" — nothing imports them by design
13
12
  const NON_CODE_RE = /\.(json|ya?ml|sh|ps1|md|txt|html?|css|scss|less)$|(^|[/])(dockerfile|containerfile)/i;
@@ -15,24 +14,54 @@ const NON_CODE_RE = /\.(json|ya?ml|sh|ps1|md|txt|html?|css|scss|less)$|(^|[/])(d
15
14
  const ep = (v) => String(v && typeof v === "object" ? v.id : v);
16
15
  const fileOf = (v) => { const s = ep(v); const h = s.indexOf("#"); return h < 0 ? s : s.slice(0, h); };
17
16
  const dirOf = (f) => (f.includes("/") ? f.slice(0, f.lastIndexOf("/")) : "");
18
-
19
17
  // File-level import adjacency. Go same-directory edges are excluded: a Go package IS the directory, the
20
18
  // compiler forbids real cross-package cycles, and our suffix-fallback Go resolution could fake them.
21
- export function buildFileImportGraph(graph) {
19
+ export function buildFileImportGraph(graph, { includeTypeOnly = false, includeCompileOnly = false } = {}) {
22
20
  const fileIds = new Set();
23
21
  for (const n of graph.nodes || []) if (!String(n.id).includes("#")) fileIds.add(String(n.id));
24
- const adj = new Map();
22
+ const runtimeAdj = new Map(); // runtime/value imports only
23
+ const allAdj = new Map(); // runtime + compile-time-only, used to describe architectural coupling
25
24
  const edges = [];
25
+ const allEdges = [];
26
+ const typeOnlyEdges = [];
27
+ const compileOnlyEdges = [];
28
+ const compileTimeEdges = [];
29
+ const runtimeSeen = new Set(), allSeen = new Set(), typeSeen = new Set(), compileSeen = new Set(), compileTimeSeen = new Set();
30
+ const add = (map, a, b) => {
31
+ let set = map.get(a);
32
+ if (!set) map.set(a, (set = new Set()));
33
+ set.add(b);
34
+ };
26
35
  for (const l of graph.links || []) {
27
36
  if (l.relation !== "imports" && l.relation !== "re_exports") continue;
28
37
  const a = ep(l.source), b = ep(l.target);
29
38
  if (!fileIds.has(a) || !fileIds.has(b) || a === b) continue;
30
39
  if (a.endsWith(".go") && b.endsWith(".go") && dirOf(a) === dirOf(b)) continue;
31
- let set = adj.get(a);
32
- if (!set) adj.set(a, (set = new Set()));
33
- if (!set.has(b)) { set.add(b); edges.push([a, b]); }
40
+ const key = `${a}\0${b}`;
41
+ if (!allSeen.has(key)) { allSeen.add(key); add(allAdj, a, b); allEdges.push([a, b]); }
42
+ if (l.typeOnly === true || l.compileOnly === true) {
43
+ if (l.typeOnly === true && !typeSeen.has(key)) { typeSeen.add(key); typeOnlyEdges.push([a, b]); }
44
+ if (l.compileOnly === true && !compileSeen.has(key)) { compileSeen.add(key); compileOnlyEdges.push([a, b]); }
45
+ if (!compileTimeSeen.has(key)) { compileTimeSeen.add(key); compileTimeEdges.push([a, b]); }
46
+ continue;
47
+ }
48
+ if (!runtimeSeen.has(key)) { runtimeSeen.add(key); add(runtimeAdj, a, b); edges.push([a, b]); }
34
49
  }
35
- return { fileIds, adj, edges };
50
+ const pureTypeOnlyEdges = typeOnlyEdges.filter(([a, b]) => !runtimeSeen.has(`${a}\0${b}`));
51
+ const pureCompileOnlyEdges = compileOnlyEdges.filter(([a, b]) => !runtimeSeen.has(`${a}\0${b}`));
52
+ const pureCompileTimeEdges = compileTimeEdges.filter(([a, b]) => !runtimeSeen.has(`${a}\0${b}`));
53
+ return {
54
+ fileIds,
55
+ adj: includeTypeOnly || includeCompileOnly ? allAdj : runtimeAdj,
56
+ edges: includeTypeOnly || includeCompileOnly ? allEdges : edges,
57
+ runtimeAdj,
58
+ runtimeEdges: edges,
59
+ allAdj,
60
+ allEdges,
61
+ typeOnlyEdges: pureTypeOnlyEdges,
62
+ compileOnlyEdges: pureCompileOnlyEdges,
63
+ compileTimeEdges: pureCompileTimeEdges,
64
+ };
36
65
  }
37
66
 
38
67
  // Iterative Tarjan (deep graphs must not blow the JS stack). Returns only non-trivial SCCs (size > 1).
@@ -144,12 +173,10 @@ export function checkBoundaries(edges, rules = {}) {
144
173
 
145
174
  const MAX_CYCLE_FINDINGS = 50;
146
175
  const MAX_BOUNDARY_FINDINGS = 100;
147
-
148
176
  // Assemble everything into unified Findings. rules comes from the repo's .weavatrix-deps.json (optional).
149
177
  export function computeStructureFindings(graph, { rules = {}, entrySet = new Set(), externalImportFiles = new Set() } = {}) {
150
- const { adj, edges } = buildFileImportGraph(graph);
178
+ const { adj, edges, allAdj, allEdges, typeOnlyEdges, compileOnlyEdges, compileTimeEdges } = buildFileImportGraph(graph);
151
179
  const findings = [];
152
-
153
180
  const sccs = findSccs(adj).sort((a, b) => b.length - a.length);
154
181
  for (const scc of sccs.slice(0, MAX_CYCLE_FINDINGS)) {
155
182
  const cycle = representativeCycle(adj, scc);
@@ -167,6 +194,40 @@ export function computeStructureFindings(graph, { rules = {}, entrySet = new Set
167
194
  fixHint: "extract the shared code into a module both sides import, or invert the weaker dependency",
168
195
  }));
169
196
  }
197
+
198
+ // TypeScript `import type` and Rust module/use edges are compile-time coupling. They can reveal real
199
+ // architecture, but cannot create an initialization-order/runtime cycle. Report SCCs that require
200
+ // either classification separately so agents do not churn working code for a phantom runtime hazard.
201
+ const runtimeKeys = new Set(sccs.map((s) => [...s].sort().join("\0")));
202
+ const allSccs = findSccs(allAdj).sort((a, b) => b.length - a.length);
203
+ const compileTimeCouplings = allSccs.filter((s) => !runtimeKeys.has([...s].sort().join("\0")));
204
+ const edgeCountIn = (scc, list) => {
205
+ const inside = new Set(scc);
206
+ return list.reduce((n, [a, b]) => n + (inside.has(a) && inside.has(b) ? 1 : 0), 0);
207
+ };
208
+ for (const scc of compileTimeCouplings.slice(0, MAX_CYCLE_FINDINGS)) {
209
+ const cycle = representativeCycle(allAdj, scc);
210
+ const runtimeInside = edgeCountIn(scc, edges);
211
+ const typeInside = edgeCountIn(scc, typeOnlyEdges);
212
+ const compileInside = edgeCountIn(scc, compileOnlyEdges);
213
+ const containsRuntimeCycle = sccs.some((runtime) => runtime.every((f) => scc.includes(f)));
214
+ const typeSpecific = compileInside === 0;
215
+ findings.push(makeFinding({
216
+ category: "structure",
217
+ rule: typeSpecific ? "type-coupling" : "compile-time-coupling",
218
+ severity: "info",
219
+ confidence: "high",
220
+ title: `${containsRuntimeCycle
221
+ ? (typeSpecific ? "Type imports expand dependency coupling" : "Compile-time edges expand dependency coupling")
222
+ : (typeSpecific ? "Type-induced dependency cycle (no runtime cycle)" : "Compile-time dependency cycle (no runtime cycle)")}: ${scc.length} files`,
223
+ detail: `${cycle.join(" → ")}. This strongly-connected group needs compile-time-only edges to close; it contains ${runtimeInside} runtime edge(s), ${typeInside} type-only edge(s), and ${compileInside} compile-only edge(s)${containsRuntimeCycle ? ", with a smaller runtime cycle reported separately" : ", while its runtime import graph is acyclic"}. Treat this as design coupling, not an initialization-order failure.`,
224
+ file: cycle[0],
225
+ graphNodeId: cycle[0],
226
+ evidence: cycle.map((f) => ({ file: f, line: 0, snippet: "" })),
227
+ source: "internal",
228
+ fixHint: "review the compile-time ownership only if the coupling impedes changes; no runtime-cycle fix is required",
229
+ }));
230
+ }
170
231
  if (sccs.length > MAX_CYCLE_FINDINGS) {
171
232
  findings.push(makeFinding({
172
233
  category: "structure", rule: "circular-dep", severity: "info", confidence: "high",
@@ -190,6 +251,8 @@ export function computeStructureFindings(graph, { rules = {}, entrySet = new Set
190
251
  }));
191
252
  }
192
253
 
254
+ // Architecture boundaries describe executable dependencies by default. Type-only contract sharing is
255
+ // visible in typeCouplings above, but must not be presented as a runtime layer violation.
193
256
  const violations = checkBoundaries(edges, rules);
194
257
  for (const v of violations.slice(0, MAX_BOUNDARY_FINDINGS)) {
195
258
  findings.push(makeFinding({
@@ -216,6 +279,22 @@ export function computeStructureFindings(graph, { rules = {}, entrySet = new Set
216
279
 
217
280
  return {
218
281
  findings,
219
- stats: { importEdges: edges.length, cycles: sccs.length, largestCycle: sccs[0]?.length || 0, orphans: findings.filter((f) => f.rule === "orphan-file").length, boundaryViolations: violations.length },
282
+ stats: {
283
+ importEdges: allEdges.length,
284
+ runtimeImportEdges: edges.length,
285
+ typeOnlyImportEdges: typeOnlyEdges.length,
286
+ compileOnlyImportEdges: compileOnlyEdges.length,
287
+ compileTimeImportEdges: compileTimeEdges.length,
288
+ cycles: sccs.length,
289
+ runtimeCycles: sccs.length,
290
+ largestCycle: sccs[0]?.length || 0,
291
+ // Backward-compatible aliases remain for edgeTypesV 1 consumers.
292
+ typeCouplings: compileTimeCouplings.length,
293
+ largestTypeCoupling: compileTimeCouplings[0]?.length || 0,
294
+ compileTimeCouplings: compileTimeCouplings.length,
295
+ largestCompileTimeCoupling: compileTimeCouplings[0]?.length || 0,
296
+ orphans: findings.filter((f) => f.rule === "orphan-file").length,
297
+ boundaryViolations: violations.length,
298
+ },
220
299
  };
221
300
  }
@@ -1,9 +1,9 @@
1
1
  // duplicates.compute.js — fragment extraction, inverted-index pairing, and the computeDuplicates
2
2
  // pipeline (split from duplicates.js; see the facade there for the full algorithm overview).
3
- import { readFileSync, readdirSync } from "node:fs";
4
- import { join, extname } from "node:path";
3
+ import { readFileSync } from "node:fs";
5
4
  import { isTestPath } from "../graph/graph-filter.js";
6
5
  import { createRepoBoundary } from "../repo-path.js";
6
+ import { listRepoFiles } from "./internal-audit.collect.js";
7
7
  import { stripNonCode, bodyEndLineCount, tokenize, fingerprints, extractLargeStrings } from "./duplicates.tokenize.js";
8
8
 
9
9
  const FLOOR_TOKENS = 30; // fragments below this never enter the index (UI slider min)
@@ -17,20 +17,7 @@ const WINDOW_EXTS = /\.(css|scss|sass|less|styl|html?|md|markdown|mdx|vue|svelte
17
17
  const WINDOW_LINES = 24; // non-overlapping block size for windowed files
18
18
  // Asset files are DEDUP-ONLY — they are NOT put in the code graph (that would score md/css as fake
19
19
  // "methods" in Health and clutter the GUI board), so the clone scanner finds them by walking the repo.
20
- const WALK_SKIP = new Set(["node_modules", ".git", "dist", "build", "coverage", "vendor", "weavatrix-graphs", ".next", "out", "__pycache__", ".venv", "venv", "site-packages"]);
21
20
  const MAX_ASSET_FILES = 4000;
22
- function walkAssets(root, dir, acc, depth) {
23
- if (depth > 40 || acc.length >= MAX_ASSET_FILES) return acc;
24
- let entries; try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return acc; }
25
- for (const e of entries) {
26
- if (acc.length >= MAX_ASSET_FILES) break;
27
- if (e.name.startsWith(".") || WALK_SKIP.has(e.name)) continue;
28
- const full = join(dir, e.name);
29
- if (e.isDirectory()) walkAssets(root, full, acc, depth + 1);
30
- else if (WINDOW_EXTS.test(e.name)) acc.push(full.slice(root.length + 1).replace(/\\/g, "/"));
31
- }
32
- return acc;
33
- }
34
21
  // Fingerprints shared by MORE than this many fragments are ubiquitous boilerplate and are excluded
35
22
  // from BOTH the shared count AND the union (so jaccard stays honest — see pairsForMode). Set well
36
23
  // above realistic clone multiplicity: N byte-identical copies each put all their fingerprints in
@@ -55,7 +42,7 @@ function computeNameTwins(frags) {
55
42
  frags.forEach((f, i) => {
56
43
  if (f.kind === "string") return;
57
44
  const name = String(f.label || "").trim().replace(/\(\)$/, "");
58
- if (name.length < 5 || !/^[A-Za-z_$][\w$]*$/.test(name) || TWIN_STOP.has(name.toLowerCase())) return;
45
+ if (name.length < 5 || !/^[A-Za-z_$][\w$]*$/.test(name) || TWIN_STOP.has(name.toLowerCase()) || /^do_(?:get|post|put|patch|delete|head|options)$/i.test(name)) return;
59
46
  const key = name.toLowerCase();
60
47
  let a = byName.get(key); if (!a) byName.set(key, (a = []));
61
48
  a.push(i);
@@ -66,15 +53,18 @@ function computeNameTwins(frags) {
66
53
  const files = new Set(idxs.map((i) => frags[i].file));
67
54
  if (files.size < 2 || idxs.length > 12) continue; // single-file overloads / framework-name explosions
68
55
  let simMin = 1, simMax = 0;
56
+ const pairs = [];
69
57
  for (let a = 0; a < idxs.length; a++) for (let b = a + 1; b < idxs.length; b++) {
70
58
  if (frags[idxs[a]].file === frags[idxs[b]].file) continue;
71
59
  const s = jac(frags[idxs[a]].fp.renamed, frags[idxs[b]].fp.renamed);
72
60
  if (s < simMin) simMin = s;
73
61
  if (s > simMax) simMax = s;
62
+ pairs.push({ a: idxs[a], b: idxs[b], similarity: Math.round(s * 100) });
74
63
  }
75
64
  out.push({
76
65
  label: String(frags[idxs[0]].label || "").replace(/\(\)$/, ""), members: idxs, files: files.size,
77
66
  simMin: Math.round(simMin * 100), simMax: Math.round(simMax * 100),
67
+ pairs,
78
68
  tokens: idxs.reduce((n, i) => n + frags[i].n, 0),
79
69
  });
80
70
  }
@@ -130,11 +120,14 @@ export function computeDuplicates(repoPath, graphJsonPath, opts = {}) {
130
120
  // total symbol nodes in the graph — 0 means a file-only graph (built by an older builder before
131
121
  // symbol extraction, or a stale graph): clone detection has nothing to work with, and the UI must
132
122
  // say "rebuild the graph" instead of the misleading "no clones at these thresholds".
123
+ const repoFiles = listRepoFiles(repoPath);
124
+ const allowedFiles = new Set(repoFiles);
133
125
  let graphSymbols = 0;
134
- for (const arr of byFile.values()) graphSymbols += arr.length;
126
+ for (const [file, arr] of byFile) if (allowedFiles.has(file)) graphSymbols += arr.length;
135
127
  const frags = [];
136
128
  const boundary = createRepoBoundary(repoPath);
137
129
  for (const [file, syms] of byFile) {
130
+ if (!allowedFiles.has(file)) continue;
138
131
  const resolved = boundary.resolve(file);
139
132
  if (!resolved.ok) continue;
140
133
  const full = resolved.path;
@@ -195,7 +188,8 @@ export function computeDuplicates(repoPath, graphJsonPath, opts = {}) {
195
188
  // ---- windowed fragments for symbol-less file types (CSS/HTML/MD/…), found by walking the repo (NOT the
196
189
  // graph — assets are dedup-only). Each file is sliced into WINDOW_LINES blocks and fingerprinted.
197
190
  const symFiles = new Set(byFile.keys());
198
- for (const file of walkAssets(repoPath, repoPath, [], 0)) {
191
+ const assetFiles = repoFiles.filter((file) => WINDOW_EXTS.test(file)).slice(0, MAX_ASSET_FILES);
192
+ for (const file of assetFiles) {
199
193
  if (symFiles.has(file)) continue;
200
194
  const resolved = boundary.resolve(file);
201
195
  if (!resolved.ok) continue;