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.
@@ -18,21 +18,44 @@ const dirOf = (f) => (f.includes("/") ? f.slice(0, f.lastIndexOf("/")) : "");
18
18
 
19
19
  // File-level import adjacency. Go same-directory edges are excluded: a Go package IS the directory, the
20
20
  // compiler forbids real cross-package cycles, and our suffix-fallback Go resolution could fake them.
21
- export function buildFileImportGraph(graph) {
21
+ export function buildFileImportGraph(graph, { includeTypeOnly = false } = {}) {
22
22
  const fileIds = new Set();
23
23
  for (const n of graph.nodes || []) if (!String(n.id).includes("#")) fileIds.add(String(n.id));
24
- const adj = new Map();
24
+ const runtimeAdj = new Map(); // runtime/value imports only
25
+ const allAdj = new Map(); // runtime + type-only, used to describe architectural coupling
25
26
  const edges = [];
27
+ const allEdges = [];
28
+ const typeOnlyEdges = [];
29
+ const runtimeSeen = new Set(), allSeen = new Set(), typeSeen = 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) {
43
+ if (!typeSeen.has(key)) { typeSeen.add(key); typeOnlyEdges.push([a, b]); }
44
+ continue;
45
+ }
46
+ if (!runtimeSeen.has(key)) { runtimeSeen.add(key); add(runtimeAdj, a, b); edges.push([a, b]); }
34
47
  }
35
- return { fileIds, adj, edges };
48
+ const pureTypeOnlyEdges = typeOnlyEdges.filter(([a, b]) => !runtimeSeen.has(`${a}\0${b}`));
49
+ return {
50
+ fileIds,
51
+ adj: includeTypeOnly ? allAdj : runtimeAdj,
52
+ edges: includeTypeOnly ? allEdges : edges,
53
+ runtimeAdj,
54
+ runtimeEdges: edges,
55
+ allAdj,
56
+ allEdges,
57
+ typeOnlyEdges: pureTypeOnlyEdges,
58
+ };
36
59
  }
37
60
 
38
61
  // Iterative Tarjan (deep graphs must not blow the JS stack). Returns only non-trivial SCCs (size > 1).
@@ -147,7 +170,7 @@ const MAX_BOUNDARY_FINDINGS = 100;
147
170
 
148
171
  // Assemble everything into unified Findings. rules comes from the repo's .weavatrix-deps.json (optional).
149
172
  export function computeStructureFindings(graph, { rules = {}, entrySet = new Set(), externalImportFiles = new Set() } = {}) {
150
- const { adj, edges } = buildFileImportGraph(graph);
173
+ const { adj, edges, allAdj, allEdges, typeOnlyEdges } = buildFileImportGraph(graph);
151
174
  const findings = [];
152
175
 
153
176
  const sccs = findSccs(adj).sort((a, b) => b.length - a.length);
@@ -167,6 +190,36 @@ export function computeStructureFindings(graph, { rules = {}, entrySet = new Set
167
190
  fixHint: "extract the shared code into a module both sides import, or invert the weaker dependency",
168
191
  }));
169
192
  }
193
+
194
+ // A TypeScript `import type` is erased before runtime. It can still reveal architectural coupling, but
195
+ // it cannot create an initialization-order/runtime cycle. Report SCCs that grow only because of type
196
+ // edges separately and at info severity so agents do not churn working code to fix a phantom hazard.
197
+ const runtimeKeys = new Set(sccs.map((s) => [...s].sort().join("\0")));
198
+ const allSccs = findSccs(allAdj).sort((a, b) => b.length - a.length);
199
+ const typeCouplings = allSccs.filter((s) => !runtimeKeys.has([...s].sort().join("\0")));
200
+ const edgeCountIn = (scc, list) => {
201
+ const inside = new Set(scc);
202
+ return list.reduce((n, [a, b]) => n + (inside.has(a) && inside.has(b) ? 1 : 0), 0);
203
+ };
204
+ for (const scc of typeCouplings.slice(0, MAX_CYCLE_FINDINGS)) {
205
+ const cycle = representativeCycle(allAdj, scc);
206
+ const runtimeInside = edgeCountIn(scc, edges);
207
+ const typeInside = edgeCountIn(scc, typeOnlyEdges);
208
+ const containsRuntimeCycle = sccs.some((runtime) => runtime.every((f) => scc.includes(f)));
209
+ findings.push(makeFinding({
210
+ category: "structure",
211
+ rule: "type-coupling",
212
+ severity: "info",
213
+ confidence: "high",
214
+ title: `${containsRuntimeCycle ? "Type imports expand dependency coupling" : "Type-induced dependency cycle (no runtime cycle)"}: ${scc.length} files`,
215
+ detail: `${cycle.join(" → ")}. This strongly-connected group needs type-only edges to close; it contains ${runtimeInside} runtime edge(s) and ${typeInside} type-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.`,
216
+ file: cycle[0],
217
+ graphNodeId: cycle[0],
218
+ evidence: cycle.map((f) => ({ file: f, line: 0, snippet: "" })),
219
+ source: "internal",
220
+ fixHint: "review the shared type ownership only if the coupling impedes changes; no runtime-cycle fix is required",
221
+ }));
222
+ }
170
223
  if (sccs.length > MAX_CYCLE_FINDINGS) {
171
224
  findings.push(makeFinding({
172
225
  category: "structure", rule: "circular-dep", severity: "info", confidence: "high",
@@ -190,6 +243,8 @@ export function computeStructureFindings(graph, { rules = {}, entrySet = new Set
190
243
  }));
191
244
  }
192
245
 
246
+ // Architecture boundaries describe executable dependencies by default. Type-only contract sharing is
247
+ // visible in typeCouplings above, but must not be presented as a runtime layer violation.
193
248
  const violations = checkBoundaries(edges, rules);
194
249
  for (const v of violations.slice(0, MAX_BOUNDARY_FINDINGS)) {
195
250
  findings.push(makeFinding({
@@ -216,6 +271,17 @@ export function computeStructureFindings(graph, { rules = {}, entrySet = new Set
216
271
 
217
272
  return {
218
273
  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 },
274
+ stats: {
275
+ importEdges: allEdges.length,
276
+ runtimeImportEdges: edges.length,
277
+ typeOnlyImportEdges: typeOnlyEdges.length,
278
+ cycles: sccs.length,
279
+ runtimeCycles: sccs.length,
280
+ largestCycle: sccs[0]?.length || 0,
281
+ typeCouplings: typeCouplings.length,
282
+ largestTypeCoupling: typeCouplings[0]?.length || 0,
283
+ orphans: findings.filter((f) => f.rule === "orphan-file").length,
284
+ boundaryViolations: violations.length,
285
+ },
220
286
  };
221
287
  }
@@ -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;
@@ -44,9 +44,27 @@ function handlerName(expr) {
44
44
  // endpoints (handler "operation", {id} params). These keys never appear in a real route/handler table.
45
45
  const OPENAPI_BLOCK = /\boperationId\b|\bresponses\s*:|\brequestBody\b|\bschemaRef\b|\boperation\s*\(|\bsummary\s*:/;
46
46
 
47
- const looksLikePath = (p) => typeof p === "string" && /^\/[\w\-./:{}*$]*$/.test(p) && !p.includes("://");
47
+ const looksLikePath = (p) => typeof p === "string" && /^\/[\w\-./:{}*$?]*$/.test(p) && !p.includes("://");
48
48
  const cleanPath = (p) => String(p || "").replace(/\/+$/, "") || "/";
49
49
 
50
+ export function nextRoutePath(file) {
51
+ const parts = String(file || "").replace(/\\/g, "/").split("/").filter(Boolean);
52
+ if (!/^route\.[cm]?[jt]s$/i.test(parts.at(-1) || "")) return "";
53
+ const appAt = parts.lastIndexOf("app");
54
+ if (appAt < 0) return "";
55
+ const route = [];
56
+ for (let segment of parts.slice(appAt + 1, -1)) {
57
+ if (!segment || /^\([^)]*\)$/.test(segment) || segment.startsWith("@")) continue; // route groups / parallel slots
58
+ segment = segment.replace(/^\((?:\.{1,3})\)/, ""); // intercepting-route marker
59
+ let m;
60
+ if ((m = /^\[\[\.\.\.([^\]]+)\]\]$/.exec(segment))) segment = `*${m[1]}?`;
61
+ else if ((m = /^\[\.\.\.([^\]]+)\]$/.exec(segment))) segment = `*${m[1]}`;
62
+ else if ((m = /^\[([^\]]+)\]$/.exec(segment))) segment = `:${m[1]}`;
63
+ if (segment) route.push(segment);
64
+ }
65
+ return `/${route.join("/")}`;
66
+ }
67
+
50
68
  export function extractEndpointsFromText(text, file) {
51
69
  const out = [];
52
70
  const py = /\.py$/i.test(file);
@@ -58,6 +76,31 @@ export function extractEndpointsFromText(text, file) {
58
76
  out.push({ method: m, path: p, handler: handlerName(expr), file, line: lineAt(text, idx) });
59
77
  };
60
78
 
79
+ // Next.js App Router: the filesystem provides the path and exported HTTP-method functions provide the
80
+ // verbs. No literal route string exists in route.ts, so generic Express/FastAPI regexes cannot see it.
81
+ const nextPath = nextRoutePath(file);
82
+ if (nextPath) {
83
+ const seen = new Set();
84
+ const direct = /\bexport\s+(?:(?:async|declare)\s+)*(?:function\s+|(?:const|let|var)\s+)(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\b/g;
85
+ let nm;
86
+ while ((nm = direct.exec(text))) {
87
+ const method = nm[1].toUpperCase();
88
+ if (!seen.has(method)) { seen.add(method); add(method, nextPath, method, nm.index); }
89
+ }
90
+ const lists = /\bexport\s*\{([^}]+)\}/g;
91
+ let lm;
92
+ while ((lm = lists.exec(text))) {
93
+ for (const item of lm[1].split(",")) {
94
+ const mm = /^\s*([A-Za-z_$][\w$]*)(?:\s+as\s+(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS))?\s*$/.exec(item);
95
+ if (!mm) continue;
96
+ if (!mm[2] && !HTTP_METHODS.has(mm[1])) continue;
97
+ const method = String(mm[2] || mm[1]).toUpperCase();
98
+ if (!HTTP_METHODS.has(method)) continue;
99
+ if (!seen.has(method)) { seen.add(method); add(method, nextPath, mm[1], lm.index); }
100
+ }
101
+ }
102
+ }
103
+
61
104
  // ---- object routes: "/path": { GET: fn, POST: fn2 } or "/path": handler --------------------
62
105
  // find each "…": { or "…": expr, where the key looks like a path
63
106
  const objKeyRe = /(["'`])(\/[^"'`]*)\1\s*:\s*(\{)?/g;
@@ -132,7 +175,7 @@ export function detectEndpoints(repoPath, codeFiles) {
132
175
  const resolved = boundary.resolve(rel);
133
176
  if (!resolved.ok) continue;
134
177
  const text = safeRead(resolved.path);
135
- if (!text || !/["'`]\/|\.(get|post|put|patch|delete)\s*\(|HandleFunc|@\w*\.?(get|post|put|patch|delete)/i.test(text)) continue;
178
+ if (!text || (!nextRoutePath(rel) && !/["'`]\/|\.(get|post|put|patch|delete)\s*\(|HandleFunc|@\w*\.?(get|post|put|patch|delete)/i.test(text))) continue;
136
179
  for (const e of extractEndpointsFromText(text, rel.replace(/\\/g, "/"))) {
137
180
  const key = `${e.method} ${normParamKey(e.path)}`;
138
181
  const prev = byKey.get(key);
@@ -100,21 +100,25 @@ export function aggregateGraph(graph, repoRoot) {
100
100
  for (const node of nodes) if (node.source_file) id2file.set(node.id, fileIdOf(node));
101
101
  const fileEdges = new Map(); // key → { count, rels:{relation→n} } so we can emit the DOMINANT relation (call/import/inherit)
102
102
  const moduleEdges = new Map();
103
+ const typeOnlyFileEdges = new Map();
104
+ const typeOnlyModuleEdges = new Map();
103
105
  for (const link of links) {
104
106
  if (link.relation === "contains") continue;
105
107
  const fromFile = id2file.get(endpoint(link.source));
106
108
  const toFile = id2file.get(endpoint(link.target));
107
109
  if (fromFile && toFile && fromFile !== toFile) {
108
110
  const key = `${fromFile} ${toFile}`;
109
- let fe = fileEdges.get(key);
110
- if (!fe) fileEdges.set(key, (fe = { count: 0, rels: {} }));
111
+ const targetFileEdges = link.typeOnly === true ? typeOnlyFileEdges : fileEdges;
112
+ let fe = targetFileEdges.get(key);
113
+ if (!fe) targetFileEdges.set(key, (fe = { count: 0, rels: {} }));
111
114
  fe.count++;
112
115
  if (link.relation) fe.rels[link.relation] = (fe.rels[link.relation] || 0) + 1;
113
116
  const fromMod = fileModule.get(fromFile);
114
117
  const toMod = fileModule.get(toFile);
115
118
  if (fromMod && toMod && fromMod !== toMod) {
116
119
  const mkey = `${fromMod} ${toMod}`;
117
- moduleEdges.set(mkey, (moduleEdges.get(mkey) || 0) + 1);
120
+ const targetModuleEdges = link.typeOnly === true ? typeOnlyModuleEdges : moduleEdges;
121
+ targetModuleEdges.set(mkey, (targetModuleEdges.get(mkey) || 0) + 1);
118
122
  }
119
123
  }
120
124
  }
@@ -263,7 +267,9 @@ export function aggregateGraph(graph, repoRoot) {
263
267
  }))
264
268
  .sort((a, b) => b.fileCount - a.fileCount),
265
269
  moduleEdges: edgeList(moduleEdges),
270
+ typeOnlyModuleEdges: edgeList(typeOnlyModuleEdges),
266
271
  fileEdges: edgeList(fileEdges),
272
+ typeOnlyFileEdges: edgeList(typeOnlyFileEdges),
267
273
  symbols,
268
274
  symbolEdges,
269
275
  symbolRefs: [...new Set([...symbolLocalRefs.keys(), ...symbolExternalRefs.keys()])].map((id) => {
@@ -276,7 +282,9 @@ export function aggregateGraph(graph, repoRoot) {
276
282
  files: fileModule.size,
277
283
  nodes: nodes.filter((n) => n.file_type === "code").length,
278
284
  fileEdges: fileEdges.size,
285
+ typeOnlyFileEdges: typeOnlyFileEdges.size,
279
286
  moduleEdges: moduleEdges.size,
287
+ typeOnlyModuleEdges: typeOnlyModuleEdges.size,
280
288
  symbols: symbols.length,
281
289
  symbolEdges: symbolEdges.length
282
290
  }
@@ -1,9 +1,11 @@
1
1
  // internal-audit.collect.js — filesystem collection helpers for the internal audit: source/config
2
2
  // text gathering, workspace package names, and the Python manifest reader. Split from internal-audit.js.
3
3
  import { readFileSync, readdirSync } from "node:fs";
4
- import { join } from "node:path";
4
+ import { dirname, join } from "node:path";
5
+ import { spawnSync } from "node:child_process";
5
6
  import { parseRequirementsNames, parsePyprojectDeps, parsePipfileDeps } from "./manifests.js";
6
7
  import { createRepoBoundary } from "../repo-path.js";
8
+ import { childProcessEnv } from "../child-env.js";
7
9
 
8
10
  export const readText = (p) => { try { return readFileSync(p, "utf8"); } catch { return null; } };
9
11
  export const readJson = (p) => { try { return JSON.parse(readFileSync(p, "utf8")); } catch { return null; } };
@@ -18,10 +20,36 @@ export const readRepoJson = (boundary, relativePath) => {
18
20
  const SOURCE_EXT_RE = /\.(?:[cm]?[jt]sx?|py|go|vue|svelte)$/i;
19
21
  const SOURCE_SKIP_DIRS = new Set([
20
22
  ".git", ".hg", ".svn", "node_modules", "vendor", "dist", "build", "coverage", ".next", "out",
21
- "weavatrix-graphs", "weavatrix-graphs", "__pycache__", ".venv", "venv", "env", ".tox", "site-packages",
23
+ "release", "weavatrix-graphs", "__pycache__", ".venv", "venv", "env", ".tox", "site-packages",
22
24
  ".mypy_cache", ".pytest_cache",
23
25
  ]);
24
26
 
27
+ // One file universe for every filesystem-backed audit. In Git repos this exactly matches tracked files
28
+ // plus untracked/non-ignored work, so release bundles and other .gitignore outputs cannot re-enter through
29
+ // text/config/manifest fallback collectors after the graph builder correctly omitted them.
30
+ export function listRepoFiles(repoRoot) {
31
+ try {
32
+ const r = spawnSync("git", ["-C", repoRoot, "ls-files", "--cached", "--others", "--exclude-standard", "-z"], {
33
+ encoding: "utf8", windowsHide: true, timeout: 15_000, maxBuffer: 32 * 1024 * 1024,
34
+ env: childProcessEnv(),
35
+ });
36
+ if (r.status === 0) return String(r.stdout || "").split("\0").filter(Boolean).map((f) => f.replace(/\\/g, "/"));
37
+ } catch { /* non-Git repo or git unavailable: use the bounded walker below */ }
38
+
39
+ const files = [];
40
+ const walk = (abs, parts = []) => {
41
+ let entries = [];
42
+ try { entries = readdirSync(abs, { withFileTypes: true }); } catch { return; }
43
+ for (const entry of entries) {
44
+ if (entry.isDirectory()) {
45
+ if (!SOURCE_SKIP_DIRS.has(entry.name)) walk(join(abs, entry.name), [...parts, entry.name]);
46
+ } else if (entry.isFile()) files.push([...parts, entry.name].join("/"));
47
+ }
48
+ };
49
+ walk(repoRoot);
50
+ return files;
51
+ }
52
+
25
53
  export function collectSourceTexts(repoRoot, graph) {
26
54
  const sources = new Map();
27
55
  const boundary = createRepoBoundary(repoRoot);
@@ -36,19 +64,7 @@ export function collectSourceTexts(repoRoot, graph) {
36
64
 
37
65
  for (const n of graph.nodes || []) add(n.source_file);
38
66
 
39
- const walk = (abs, parts = []) => {
40
- let entries = [];
41
- try { entries = readdirSync(abs, { withFileTypes: true }); } catch { return; }
42
- for (const entry of entries) {
43
- if (entry.isDirectory()) {
44
- if (!SOURCE_SKIP_DIRS.has(entry.name)) walk(join(abs, entry.name), [...parts, entry.name]);
45
- continue;
46
- }
47
- if (!entry.isFile() || !SOURCE_EXT_RE.test(entry.name)) continue;
48
- add([...parts, entry.name].join("/"));
49
- }
50
- };
51
- walk(repoRoot);
67
+ for (const file of listRepoFiles(repoRoot)) if (SOURCE_EXT_RE.test(file)) add(file);
52
68
  return sources;
53
69
  }
54
70
 
@@ -62,7 +78,8 @@ const CONFIG_FILES = [
62
78
  "jest.config.js", "jest.config.ts", "jest.config.cjs", "jest.config.mjs",
63
79
  "vite.config.js", "vite.config.ts", "vite.config.mjs", "vitest.config.js", "vitest.config.ts",
64
80
  "webpack.config.js", "rollup.config.js", "esbuild.config.js",
65
- "postcss.config.js", "postcss.config.cjs", "tailwind.config.js", "tailwind.config.ts",
81
+ "postcss.config.js", "postcss.config.cjs", "postcss.config.mjs", "postcss.config.ts",
82
+ "tailwind.config.js", "tailwind.config.cjs", "tailwind.config.mjs", "tailwind.config.ts",
66
83
  ".prettierrc", ".prettierrc.json", "prettier.config.js",
67
84
  "playwright.config.js", "playwright.config.ts", "cypress.config.js", "cypress.config.ts",
68
85
  "next.config.js", "next.config.mjs", "nuxt.config.ts", "svelte.config.js", "astro.config.mjs",
@@ -76,17 +93,96 @@ const CONFIG_FILES = [
76
93
  export function collectConfigTexts(repoRoot) {
77
94
  const map = new Map();
78
95
  const boundary = createRepoBoundary(repoRoot);
79
- for (const f of CONFIG_FILES) { const t = readRepoText(boundary, f); if (t != null) map.set(f, t); }
96
+ const names = new Set(CONFIG_FILES.map((f) => f.toLowerCase()));
97
+ for (const f of listRepoFiles(repoRoot)) {
98
+ const base = f.slice(f.lastIndexOf("/") + 1).toLowerCase();
99
+ if (!names.has(base) && !/^\.github\/workflows\/.*\.ya?ml$/i.test(f)) continue;
100
+ const t = readRepoText(boundary, f);
101
+ if (t != null) map.set(f, t);
102
+ }
103
+ return map;
104
+ }
105
+
106
+ function readJsonc(text) {
107
+ if (text == null) return null;
80
108
  try {
81
- const workflows = boundary.resolve(".github/workflows");
82
- if (!workflows.ok) throw new Error("workflows directory is outside the repository");
83
- for (const wf of readdirSync(workflows.path)) {
84
- if (!/\.ya?ml$/i.test(wf)) continue;
85
- const t = readRepoText(boundary, `.github/workflows/${wf}`);
86
- if (t != null) map.set(`.github/workflows/${wf}`, t);
109
+ const input = String(text).replace(/^\uFEFF/, "");
110
+ let clean = "", inString = false, escaped = false;
111
+ for (let i = 0; i < input.length; i++) {
112
+ const ch = input[i], next = input[i + 1];
113
+ if (inString) {
114
+ clean += ch;
115
+ if (escaped) escaped = false;
116
+ else if (ch === "\\") escaped = true;
117
+ else if (ch === '"') inString = false;
118
+ continue;
119
+ }
120
+ if (ch === '"') { inString = true; clean += ch; continue; }
121
+ if (ch === "/" && next === "/") {
122
+ while (i < input.length && input[i] !== "\n") i++;
123
+ clean += "\n";
124
+ continue;
125
+ }
126
+ if (ch === "/" && next === "*") {
127
+ i += 2;
128
+ while (i < input.length && !(input[i] === "*" && input[i + 1] === "/")) i++;
129
+ i++;
130
+ continue;
131
+ }
132
+ clean += ch;
87
133
  }
88
- } catch { /* no workflows */ }
89
- return map;
134
+ clean = clean.replace(/,\s*([}\]])/g, "$1");
135
+ return JSON.parse(clean);
136
+ } catch { return null; }
137
+ }
138
+
139
+ const normRoot = (root) => {
140
+ const value = String(root || "").replace(/\\/g, "/").replace(/^\.\//, "").replace(/^\/+|\/+$/g, "");
141
+ return value === "." ? "" : value;
142
+ };
143
+ const inScope = (file, root) => !root || file === root || file.startsWith(`${root}/`);
144
+
145
+ function aliasesForScope(repoRoot, root, files, boundary, scopeRoots) {
146
+ const ownerOf = (file) => scopeRoots.find((candidate) => inScope(file, candidate)) ?? "";
147
+ const configs = files
148
+ .filter((f) => ownerOf(f) === root && /(^|\/)(?:tsconfig|jsconfig)(?:\.[^/]+)?\.json$/i.test(f))
149
+ .filter((f) => normRoot(dirname(f)) === root)
150
+ .sort((a, b) => Number(!/(^|\/)(?:tsconfig|jsconfig)\.json$/i.test(a)) - Number(!/(^|\/)(?:tsconfig|jsconfig)\.json$/i.test(b)) || a.localeCompare(b));
151
+ const aliases = new Map();
152
+ for (const config of configs) {
153
+ const cfg = readJsonc(readRepoText(boundary, config));
154
+ const paths = cfg?.compilerOptions?.paths || {};
155
+ for (const key of Object.keys(paths)) if (!aliases.has(key)) aliases.set(key, {
156
+ key,
157
+ prefix: String(key).replace(/\*.*$/, ""),
158
+ suffix: String(key).includes("*") ? String(key).slice(String(key).indexOf("*") + 1) : "",
159
+ config,
160
+ });
161
+ }
162
+ return [...aliases.values()];
163
+ }
164
+
165
+ // Every package.json defines a dependency scope. The nearest ancestor manifest owns an import, matching
166
+ // npm workspace/package semantics. Aliases are collected from that scope's tsconfig/jsconfig so `@/*`
167
+ // remains local rather than becoming a phantom npm package.
168
+ export function collectPackageScopes(repoRoot, rootPkg = null) {
169
+ const boundary = createRepoBoundary(repoRoot);
170
+ const files = listRepoFiles(repoRoot);
171
+ const manifests = files.filter((f) => /(^|\/)package\.json$/i.test(f));
172
+ if (!manifests.includes("package.json") && rootPkg) manifests.unshift("package.json");
173
+ const uniqueManifests = [...new Set(manifests)];
174
+ const scopeRoots = uniqueManifests
175
+ .map((manifest) => manifest === "package.json" ? "" : normRoot(dirname(manifest)))
176
+ .sort((a, b) => b.length - a.length);
177
+ const scopes = [];
178
+ for (const manifest of uniqueManifests) {
179
+ const root = manifest === "package.json" ? "" : normRoot(dirname(manifest));
180
+ const pkg = manifest === "package.json" && rootPkg ? rootPkg : readRepoJson(boundary, manifest);
181
+ if (!pkg || typeof pkg !== "object") continue;
182
+ scopes.push({ root, manifest, pkg, aliases: aliasesForScope(repoRoot, root, files, boundary, scopeRoots) });
183
+ }
184
+ if (!scopes.some((s) => !s.root)) scopes.push({ root: "", manifest: "package.json", pkg: rootPkg || {}, aliases: aliasesForScope(repoRoot, "", files, boundary, [...scopeRoots, ""]) });
185
+ return scopes.sort((a, b) => b.root.length - a.root.length);
90
186
  }
91
187
 
92
188
  // Monorepo-local package names: "packages/*"-style workspace globs → each child's package.json name.
@@ -111,6 +207,7 @@ export function workspacePkgNames(repoRoot, pkg) {
111
207
  if (p && p.name) names.add(p.name);
112
208
  }
113
209
  }
210
+ for (const scope of collectPackageScopes(repoRoot, pkg)) if (scope.pkg?.name) names.add(scope.pkg.name);
114
211
  return names;
115
212
  }
116
213
 
@@ -1,6 +1,7 @@
1
1
  // internal-audit.reach.js — entry-point discovery + file-level reachability BFS for the internal
2
2
  // audit. Split from internal-audit.js.
3
- import { ENTRY_FILE } from "./dead-check.js";
3
+ import { posix } from "node:path";
4
+ import { ENTRY_FILE, isFrameworkEntryFile } from "./dead-check.js";
4
5
  import { TEST_FILE_RE } from "./internal-audit.collect.js";
5
6
 
6
7
  const isFileNode = (n) => !String(n.id).includes("#");
@@ -8,20 +9,60 @@ const isFileNode = (n) => !String(n.id).includes("#");
8
9
  // Entry set for reachability: conventional entry names + package.json main/module/browser/bin/exports +
9
10
  // html pages (they root classic-script apps) + test files (the runner enters them) + root config files +
10
11
  // dynamic-import targets. Anything reachable from here is "used"; the rest corroborates unused-file.
11
- export function entryFiles(graph, pkg, dynamicTargets) {
12
+ export function entryFiles(graph, pkgOrScopes, dynamicTargets = new Set(), { declaredEntries = [], sources = new Map() } = {}) {
12
13
  const entries = new Set();
13
- const pkgEntries = [];
14
- for (const k of ["main", "module", "browser"]) if (typeof pkg[k] === "string") pkgEntries.push(pkg[k]);
15
- if (pkg.bin) pkgEntries.push(...(typeof pkg.bin === "string" ? [pkg.bin] : Object.values(pkg.bin)));
16
- (function walkExports(e) {
17
- if (typeof e === "string") pkgEntries.push(e);
18
- else if (e && typeof e === "object") Object.values(e).forEach(walkExports);
19
- })(pkg.exports);
20
- const pe = new Set(pkgEntries.map((p) => String(p).replace(/^\.\//, "").replace(/\\/g, "/")));
14
+ const scopes = Array.isArray(pkgOrScopes)
15
+ ? pkgOrScopes
16
+ : [{ root: "", manifest: "package.json", pkg: pkgOrScopes || {} }];
17
+ const fileSet = new Set((graph.nodes || []).filter(isFileNode).map((n) => String(n.source_file || n.id).replace(/\\/g, "/")));
18
+ const pe = new Set();
19
+ const resolveEntry = (root, raw) => {
20
+ let p = String(raw || "").trim().replace(/^['"]|['"]$/g, "").replace(/^file:/, "").replace(/\\/g, "/");
21
+ if (!p || p.startsWith("-") || /^https?:/.test(p)) return "";
22
+ p = p.replace(/^\.\//, "");
23
+ const joined = posix.normalize(root ? posix.join(root, p) : p).replace(/^\.\//, "");
24
+ if (fileSet.has(joined)) return joined;
25
+ for (const ext of [".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx", ".py"]) if (fileSet.has(joined + ext)) return joined + ext;
26
+ return joined;
27
+ };
28
+ for (const scope of scopes) {
29
+ const pkg = scope.pkg || {};
30
+ const root = String(scope.root || "").replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
31
+ const pkgEntries = [];
32
+ for (const k of ["main", "module", "browser"]) if (typeof pkg[k] === "string") pkgEntries.push(pkg[k]);
33
+ if (pkg.bin) pkgEntries.push(...(typeof pkg.bin === "string" ? [pkg.bin] : Object.values(pkg.bin)));
34
+ (function walkExports(e) {
35
+ if (typeof e === "string") pkgEntries.push(e);
36
+ else if (e && typeof e === "object") Object.values(e).forEach(walkExports);
37
+ })(pkg.exports);
38
+ // Script commands are external entry surfaces. Extract only path-shaped source tokens; package names,
39
+ // flags and shell operators are intentionally ignored.
40
+ for (const script of Object.values(pkg.scripts || {})) {
41
+ const re = /(?:^|[\s'"=])((?:\.?\.?\/)?[\w@./-]+\.(?:[cm]?[jt]sx?|py|go))(?=$|[\s'";,)&|])/gi;
42
+ let m;
43
+ while ((m = re.exec(String(script)))) pkgEntries.push(m[1]);
44
+ const runner = /\b(?:node|bun|deno|tsx|ts-node|python\d*|electron)\s+(?!-)([\w@./\\-]+)/gi;
45
+ while ((m = runner.exec(String(script)))) if (/[./\\]/.test(m[1])) pkgEntries.push(m[1]);
46
+ }
47
+ for (const p of pkgEntries) pe.add(resolveEntry(root, p));
48
+ }
21
49
  for (const n of graph.nodes || []) {
22
50
  if (!isFileNode(n)) continue;
23
51
  const f = n.source_file;
24
- if (ENTRY_FILE.test(f) || TEST_FILE_RE.test(f) || /\.html?$/i.test(f) || pe.has(f) || /(^|\/)[^/]*\.config\.[a-z]+$/i.test(f)) entries.add(f);
52
+ if (ENTRY_FILE.test(f) || isFrameworkEntryFile(f) || /\.d\.[cm]?ts$/i.test(f) || TEST_FILE_RE.test(f) || /\.html?$/i.test(f) || pe.has(f) || /(^|\/)[^/]*\.config\.[a-z]+$/i.test(f)) entries.add(f);
53
+ }
54
+ for (const raw of Array.isArray(declaredEntries) ? declaredEntries : [declaredEntries]) {
55
+ const resolved = resolveEntry("", raw);
56
+ if (resolved && fileSet.has(resolved)) entries.add(resolved);
57
+ }
58
+ // Bundled helper programs are often launched by filename (`resolveResource("worker.py")`) rather than
59
+ // imported. A quoted basename reference from another source file is enough to establish that a code file
60
+ // under resources/ is a runtime entry, without treating arbitrary prose/config files as roots.
61
+ for (const file of fileSet) {
62
+ if (!/(^|\/)resources\/.*\.(?:py|[cm]?[jt]s)$/i.test(file)) continue;
63
+ const base = posix.basename(file).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
64
+ const quoted = new RegExp(`["'\x60]${base}["'\x60]`);
65
+ if ([...sources].some(([other, text]) => other !== file && quoted.test(String(text || "")))) entries.add(file);
25
66
  }
26
67
  for (const t of dynamicTargets) entries.add(t);
27
68
  return entries;