weavatrix 0.1.4 → 0.2.0

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 (70) hide show
  1. package/README.md +138 -53
  2. package/SECURITY.md +25 -8
  3. package/package.json +2 -2
  4. package/skill/SKILL.md +92 -30
  5. package/src/analysis/architecture-contract.js +343 -0
  6. package/src/analysis/audit-debt.js +94 -0
  7. package/src/analysis/change-classification.js +509 -0
  8. package/src/analysis/cycle-route.js +14 -0
  9. package/src/analysis/dead-check.js +5 -3
  10. package/src/analysis/dead-code-review.js +245 -0
  11. package/src/analysis/dep-check-ecosystems.js +6 -0
  12. package/src/analysis/dep-check.js +40 -6
  13. package/src/analysis/dep-rules.js +12 -9
  14. package/src/analysis/duplicates.compute.js +47 -7
  15. package/src/analysis/endpoints-java.js +186 -0
  16. package/src/analysis/endpoints.js +8 -2
  17. package/src/analysis/findings.js +8 -1
  18. package/src/analysis/git-history.js +549 -0
  19. package/src/analysis/git-ref-graph.js +74 -0
  20. package/src/analysis/graph-analysis.aggregate.js +2 -2
  21. package/src/analysis/graph-analysis.edges.js +3 -0
  22. package/src/analysis/graph-analysis.summaries.js +1 -1
  23. package/src/analysis/http-contracts.js +581 -0
  24. package/src/analysis/internal-audit.collect.js +3 -2
  25. package/src/analysis/internal-audit.reach.js +52 -1
  26. package/src/analysis/internal-audit.run.js +58 -10
  27. package/src/analysis/java-source.js +36 -0
  28. package/src/analysis/package-reachability.js +39 -0
  29. package/src/analysis/static-test-reachability.js +133 -0
  30. package/src/build-graph.js +72 -13
  31. package/src/graph/build-worker.js +3 -4
  32. package/src/graph/builder/lang-js.js +71 -12
  33. package/src/graph/community.js +10 -0
  34. package/src/graph/file-lock.js +69 -0
  35. package/src/graph/freshness-probe.js +141 -0
  36. package/src/graph/graph-filter.js +28 -11
  37. package/src/graph/incremental-refresh.js +232 -0
  38. package/src/graph/internal-builder.barrels.js +169 -0
  39. package/src/graph/internal-builder.build.js +82 -11
  40. package/src/graph/internal-builder.langs.js +2 -1
  41. package/src/graph/layout.js +21 -5
  42. package/src/graph/repo-registry.js +124 -0
  43. package/src/mcp/catalog.mjs +62 -19
  44. package/src/mcp/evidence-snapshot.architecture.mjs +80 -0
  45. package/src/mcp/evidence-snapshot.common.mjs +160 -0
  46. package/src/mcp/evidence-snapshot.duplicates.mjs +217 -0
  47. package/src/mcp/evidence-snapshot.health.mjs +95 -0
  48. package/src/mcp/evidence-snapshot.inventory.mjs +148 -0
  49. package/src/mcp/evidence-snapshot.mjs +50 -0
  50. package/src/mcp/evidence-snapshot.package-graph.mjs +249 -0
  51. package/src/mcp/evidence-snapshot.structure.mjs +81 -0
  52. package/src/mcp/graph-context.mjs +100 -16
  53. package/src/mcp/graph-diff.mjs +74 -20
  54. package/src/mcp/staleness-notice.mjs +20 -0
  55. package/src/mcp/sync-evidence.mjs +418 -0
  56. package/src/mcp/sync-payload.mjs +164 -0
  57. package/src/mcp/tool-result.mjs +51 -0
  58. package/src/mcp/tools-actions.mjs +111 -30
  59. package/src/mcp/tools-architecture.mjs +144 -0
  60. package/src/mcp/tools-company.mjs +273 -0
  61. package/src/mcp/tools-graph.mjs +25 -14
  62. package/src/mcp/tools-health.mjs +336 -14
  63. package/src/mcp/tools-history.mjs +22 -0
  64. package/src/mcp/tools-impact-change.mjs +261 -0
  65. package/src/mcp/tools-impact.mjs +25 -6
  66. package/src/mcp-server.mjs +100 -17
  67. package/src/mcp-source-tools.mjs +11 -3
  68. package/src/path-classification.js +201 -0
  69. package/src/path-ignore.js +69 -0
  70. package/src/security/typosquat.js +1 -1
@@ -0,0 +1,509 @@
1
+ // Symbol-aware git-diff classification for change_impact. This module only decides WHAT changed and
2
+ // which exact graph nodes are legitimate reverse-impact seeds; tools-impact owns traversal/formatting.
3
+ // Pure additions intentionally produce no seeds by default, preventing a new export in a busy API file
4
+ // from flooding the report with every legacy importer of that file.
5
+ import { spawnSync } from "node:child_process";
6
+ import { childProcessEnv } from "../child-env.js";
7
+
8
+ const DEFAULT_LIMITS = Object.freeze({
9
+ maxDiffBytes: 2 * 1024 * 1024,
10
+ maxFiles: 500,
11
+ maxChangedLines: 20_000,
12
+ maxLineLength: 4_000,
13
+ maxSymbolsPerFile: 250,
14
+ maxSeeds: 1_000,
15
+ });
16
+ const CLASS_RANK = Object.freeze({
17
+ "metadata-only": 0,
18
+ added: 1,
19
+ "body-changed": 2,
20
+ "signature-changed": 3,
21
+ removed: 4,
22
+ unknown: 5,
23
+ });
24
+ const VERDICT_RANK = Object.freeze({ LOW: 0, MEDIUM: 1, HIGH: 2 });
25
+ const STRUCTURAL_RELATIONS = new Set(["contains", "method"]);
26
+
27
+ const normalizePath = (value) => String(value || "").replace(/\\/g, "/").replace(/^\.\//, "");
28
+ const endpoint = (value) => String(value && typeof value === "object" ? value.id : value || "");
29
+ const lineNumber = (value) => Number((String(value || "").match(/(?:^L|@)(\d+)$/) || [])[1] || 0);
30
+ const bareLabel = (value) => String(value || "").replace(/\(.*$/, "").replace(/[^A-Za-z0-9_$].*$/, "").trim();
31
+ const boundedNumber = (value, fallback, min, max) => Math.max(min, Math.min(max, Number(value) || fallback));
32
+
33
+ function limitsOf(value = {}) {
34
+ return {
35
+ maxDiffBytes: boundedNumber(value.maxDiffBytes, DEFAULT_LIMITS.maxDiffBytes, 1_024, DEFAULT_LIMITS.maxDiffBytes),
36
+ maxFiles: boundedNumber(value.maxFiles, DEFAULT_LIMITS.maxFiles, 1, DEFAULT_LIMITS.maxFiles),
37
+ maxChangedLines: boundedNumber(value.maxChangedLines, DEFAULT_LIMITS.maxChangedLines, 10, DEFAULT_LIMITS.maxChangedLines),
38
+ maxLineLength: boundedNumber(value.maxLineLength, DEFAULT_LIMITS.maxLineLength, 80, DEFAULT_LIMITS.maxLineLength),
39
+ maxSymbolsPerFile: boundedNumber(value.maxSymbolsPerFile, DEFAULT_LIMITS.maxSymbolsPerFile, 1, DEFAULT_LIMITS.maxSymbolsPerFile),
40
+ maxSeeds: boundedNumber(value.maxSeeds, DEFAULT_LIMITS.maxSeeds, 1, DEFAULT_LIMITS.maxSeeds),
41
+ };
42
+ }
43
+
44
+ function decodeGitQuoted(value) {
45
+ const input = String(value || "").trim();
46
+ if (!input.startsWith('"')) return input.split("\t", 1)[0];
47
+ try { return JSON.parse(input); } catch { /* Git also emits octal escapes, which JSON rejects. */ }
48
+ const bytes = [];
49
+ for (let index = 1; index < input.length - 1; index += 1) {
50
+ const char = input[index];
51
+ if (char !== "\\") {
52
+ bytes.push(...Buffer.from(char));
53
+ continue;
54
+ }
55
+ const next = input[++index] || "";
56
+ if (/[0-7]/.test(next)) {
57
+ let octal = next;
58
+ while (octal.length < 3 && /[0-7]/.test(input[index + 1] || "")) octal += input[++index];
59
+ bytes.push(parseInt(octal, 8));
60
+ } else {
61
+ const escapes = { n: 10, r: 13, t: 9, b: 8, f: 12, v: 11, "\\": 92, '"': 34 };
62
+ bytes.push(escapes[next] ?? next.charCodeAt(0));
63
+ }
64
+ }
65
+ return Buffer.from(bytes).toString("utf8");
66
+ }
67
+
68
+ function diffPath(raw, prefix) {
69
+ const decoded = decodeGitQuoted(raw);
70
+ if (!decoded || decoded === "/dev/null") return null;
71
+ return normalizePath(decoded.startsWith(`${prefix}/`) ? decoded.slice(2) : decoded);
72
+ }
73
+
74
+ function headerPaths(line) {
75
+ const match = /^diff --git ("(?:\\.|[^"])*"|\S+) ("(?:\\.|[^"])*"|\S+)$/.exec(line);
76
+ if (!match) return { oldPath: null, newPath: null };
77
+ return { oldPath: diffPath(match[1], "a"), newPath: diffPath(match[2], "b") };
78
+ }
79
+
80
+ const emptyFile = (paths = {}) => ({
81
+ oldPath: paths.oldPath || null,
82
+ newPath: paths.newPath || null,
83
+ newFile: false,
84
+ deletedFile: false,
85
+ renamed: false,
86
+ binary: false,
87
+ hunks: [],
88
+ additions: [],
89
+ removals: [],
90
+ });
91
+
92
+ // Parse ordinary unified output, expecting --unified=0 but safely accepting context lines too.
93
+ export function parseZeroContextDiff(diffText, options = {}) {
94
+ const limits = limitsOf(options);
95
+ const original = String(diffText ?? "");
96
+ const byteLength = Buffer.byteLength(original);
97
+ const oversized = byteLength > limits.maxDiffBytes;
98
+ const text = oversized ? original.slice(0, limits.maxDiffBytes) : original;
99
+ const files = [];
100
+ let file = null;
101
+ let hunk = null;
102
+ let changedLines = 0;
103
+ let truncated = oversized;
104
+ const finish = () => {
105
+ if (!file) return;
106
+ if ((file.oldPath || file.newPath) && files.length < limits.maxFiles) files.push(file);
107
+ else if (files.length >= limits.maxFiles) truncated = true;
108
+ file = null;
109
+ hunk = null;
110
+ };
111
+ const addChange = (kind, line, oldLine, newLine, mappedNewLine) => {
112
+ changedLines++;
113
+ if (changedLines > limits.maxChangedLines) { truncated = true; return; }
114
+ const change = {
115
+ kind,
116
+ text: String(line).slice(0, limits.maxLineLength),
117
+ ...(oldLine != null ? { oldLine } : {}),
118
+ ...(newLine != null ? { newLine } : {}),
119
+ mappedNewLine,
120
+ };
121
+ (kind === "added" ? file.additions : file.removals).push(change);
122
+ };
123
+
124
+ for (const line of text.split(/\r?\n/)) {
125
+ if (line.startsWith("diff --git ")) {
126
+ finish();
127
+ file = emptyFile(headerPaths(line));
128
+ continue;
129
+ }
130
+ if (!file && line.startsWith("--- ")) file = emptyFile();
131
+ if (!file) continue;
132
+ if (line.startsWith("new file mode ")) { file.newFile = true; continue; }
133
+ if (line.startsWith("deleted file mode ")) { file.deletedFile = true; continue; }
134
+ if (line.startsWith("rename from ")) { file.oldPath = normalizePath(decodeGitQuoted(line.slice(12))); file.renamed = true; continue; }
135
+ if (line.startsWith("rename to ")) { file.newPath = normalizePath(decodeGitQuoted(line.slice(10))); file.renamed = true; continue; }
136
+ if (line.startsWith("Binary files ") || line === "GIT binary patch") { file.binary = true; continue; }
137
+ if (!hunk && line.startsWith("--- ")) {
138
+ file.oldPath = diffPath(line.slice(4), "a");
139
+ if (!file.oldPath) file.newFile = true;
140
+ continue;
141
+ }
142
+ if (!hunk && line.startsWith("+++ ")) {
143
+ file.newPath = diffPath(line.slice(4), "b");
144
+ if (!file.newPath) file.deletedFile = true;
145
+ continue;
146
+ }
147
+ const match = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(line);
148
+ if (match) {
149
+ hunk = {
150
+ oldStart: Number(match[1]), oldCount: match[2] == null ? 1 : Number(match[2]),
151
+ newStart: Number(match[3]), newCount: match[4] == null ? 1 : Number(match[4]),
152
+ oldCursor: Number(match[1]), newCursor: Number(match[3]),
153
+ };
154
+ file.hunks.push({ oldStart: hunk.oldStart, oldCount: hunk.oldCount, newStart: hunk.newStart, newCount: hunk.newCount });
155
+ continue;
156
+ }
157
+ if (!hunk || line.startsWith("\\ No newline")) continue;
158
+ if (line.startsWith("+")) {
159
+ addChange("added", line.slice(1), null, hunk.newCursor, hunk.newCursor);
160
+ hunk.newCursor++;
161
+ } else if (line.startsWith("-")) {
162
+ addChange("removed", line.slice(1), hunk.oldCursor, null, hunk.newCursor);
163
+ hunk.oldCursor++;
164
+ } else {
165
+ hunk.oldCursor++;
166
+ hunk.newCursor++;
167
+ }
168
+ }
169
+ finish();
170
+ return {
171
+ files,
172
+ byteLength,
173
+ changedLines: Math.min(changedLines, limits.maxChangedLines),
174
+ truncated,
175
+ oversized,
176
+ limits,
177
+ };
178
+ }
179
+
180
+ function graphIndex(graph, limits) {
181
+ const byFile = new Map();
182
+ for (const node of graph?.nodes || []) {
183
+ const file = normalizePath(node?.source_file || (!String(node?.id || "").includes("#") ? node?.id : ""));
184
+ if (!file) continue;
185
+ if (!byFile.has(file)) byFile.set(file, { path: file, fileNodeId: null, symbols: [] });
186
+ const record = byFile.get(file);
187
+ if (!String(node.id).includes("#")) record.fileNodeId = String(node.id);
188
+ else if (record.symbols.length < limits.maxSymbolsPerFile) {
189
+ const start = lineNumber(node.source_location) || lineNumber(node.id);
190
+ if (!start) continue;
191
+ record.symbols.push({
192
+ id: String(node.id),
193
+ label: String(node.label || node.id),
194
+ start,
195
+ end: lineNumber(node.source_end),
196
+ exported: node.exported === true,
197
+ symbolKind: node.symbol_kind || null,
198
+ });
199
+ }
200
+ }
201
+ for (const record of byFile.values()) {
202
+ record.symbols.sort((a, b) => a.start - b.start || a.id.localeCompare(b.id));
203
+ for (let index = 0; index < record.symbols.length; index += 1) {
204
+ const symbol = record.symbols[index];
205
+ if (!symbol.end || symbol.end < symbol.start) {
206
+ const next = record.symbols[index + 1]?.start;
207
+ symbol.end = next ? Math.max(symbol.start, next - 1) : symbol.start + 400;
208
+ }
209
+ symbol.end = Math.min(symbol.end, symbol.start + 2_000);
210
+ }
211
+ }
212
+ return byFile;
213
+ }
214
+
215
+ function isMetadataLine(text) {
216
+ const value = String(text || "").trim();
217
+ return !value || /^(?:\/\/|\/\*|\*|\*\/|#(?!include\b)|<!--|-->|"""|''')/.test(value);
218
+ }
219
+
220
+ function signatureText(text, symbol) {
221
+ const value = String(text || "").trim();
222
+ const name = bareLabel(symbol.label);
223
+ const hasName = name && new RegExp(`\\b${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i").test(value);
224
+ return hasName && /\b(?:export|default|declare|abstract|async|function|class|interface|type|enum|const|let|var|def|func|fn|struct|trait|impl|public|private|protected|static)\b/.test(value);
225
+ }
226
+
227
+ function signaturePosition(change, symbol) {
228
+ const candidates = change.kind === "removed" ? [change.mappedNewLine, change.oldLine] : [change.newLine];
229
+ if (candidates.some((line) => line === symbol.start)) return true;
230
+ const nearStart = candidates.some((line) => Number.isFinite(line) && line > symbol.start && line <= symbol.start + 4);
231
+ if (!nearStart) return false;
232
+ const value = String(change.text || "").trim();
233
+ // Multiline TS/Python/Java/Go parameter/type clauses. Keep statement keywords out so an ordinary
234
+ // first body line is not promoted to a public-contract change.
235
+ if (/^(?:return|throw|yield|if|for|while|switch|match|const|let|var|this\.|self\.|[A-Za-z_$][\w$]*\s*=)/.test(value)) return false;
236
+ return /^(?:[A-Za-z_$][\w$]*\??\s*:\s*[^;]+[,)]?|[A-Za-z_$][\w$<>,.?\[\] :*&]+\s+[A-Za-z_$][\w$]*\s*[,)]|[),:<>{}\[\]|&?]+)$/.test(value);
237
+ }
238
+
239
+ function moduleSignatureText(text) {
240
+ return /^\s*(?:import\b|export\s+(?:\*|\{)|(?:const|let|var)\s+\w+\s*=\s*require\b|using\b|package\b|#include\b|mod\b|pub\s+use\b)/.test(String(text || ""));
241
+ }
242
+
243
+ function chooseSymbol(record, change) {
244
+ if (!record?.symbols?.length) return null;
245
+ const lines = change.kind === "removed"
246
+ ? [...new Set([change.mappedNewLine, change.oldLine].filter((line) => Number.isFinite(line) && line > 0))]
247
+ : [change.newLine];
248
+ const candidates = [];
249
+ for (const symbol of record.symbols) {
250
+ if (!lines.some((line) => line >= symbol.start && line <= symbol.end)) continue;
251
+ const label = bareLabel(symbol.label);
252
+ const mentions = label && new RegExp(`\\b${label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`).test(change.text);
253
+ candidates.push({ symbol, mentions, span: symbol.end - symbol.start });
254
+ }
255
+ return candidates.sort((a, b) => Number(b.mentions) - Number(a.mentions) || a.span - b.span || a.symbol.id.localeCompare(b.symbol.id))[0]?.symbol || null;
256
+ }
257
+
258
+ function strongest(classes) {
259
+ return [...classes].sort((a, b) => (CLASS_RANK[b] ?? 5) - (CLASS_RANK[a] ?? 5) || a.localeCompare(b))[0] || "metadata-only";
260
+ }
261
+
262
+ function uniqueSorted(values, limit) {
263
+ const all = [...new Set(values.filter(Boolean).map(String))].sort((a, b) => a.localeCompare(b));
264
+ return { items: all.slice(0, limit), truncated: all.length > limit, total: all.length };
265
+ }
266
+
267
+ function analyzeParsedFile(parsed, indexed, { includeAddedSeeds }) {
268
+ const path = parsed.newPath || parsed.oldPath || "(unknown)";
269
+ const record = indexed.get(parsed.newPath) || indexed.get(parsed.oldPath) || null;
270
+ const grouped = new Map();
271
+ const unmapped = [];
272
+ for (const change of [...parsed.additions, ...parsed.removals]) {
273
+ const symbol = chooseSymbol(record, change);
274
+ if (!symbol) { unmapped.push(change); continue; }
275
+ if (!grouped.has(symbol.id)) grouped.set(symbol.id, { symbol, additions: [], removals: [] });
276
+ grouped.get(symbol.id)[change.kind === "added" ? "additions" : "removals"].push(change);
277
+ }
278
+
279
+ const symbols = [];
280
+ const seedIds = [];
281
+ for (const group of [...grouped.values()].sort((a, b) => a.symbol.start - b.symbol.start || a.symbol.id.localeCompare(b.symbol.id))) {
282
+ const addedCode = group.additions.filter((change) => !isMetadataLine(change.text));
283
+ const removedCode = group.removals.filter((change) => !isMetadataLine(change.text));
284
+ const addedDeclaration = addedCode.some((change) => signaturePosition(change, group.symbol) || signatureText(change.text, group.symbol));
285
+ const removedDeclaration = removedCode.some((change) => signaturePosition(change, group.symbol) || signatureText(change.text, group.symbol));
286
+ let classification;
287
+ if (parsed.newFile || (addedDeclaration && !removedCode.length)) classification = "added";
288
+ else if (parsed.deletedFile || (removedDeclaration && !addedCode.length)) classification = "removed";
289
+ else if (addedDeclaration || removedDeclaration) classification = "signature-changed";
290
+ else if (addedCode.length || removedCode.length) classification = "body-changed";
291
+ else classification = "metadata-only";
292
+
293
+ const reasons = {
294
+ added: "new declaration; existing callers cannot depend on it yet",
295
+ removed: "declaration removed; existing callers may break",
296
+ "signature-changed": "declaration/signature line changed",
297
+ "body-changed": "executable lines changed inside the symbol body",
298
+ "metadata-only": "only comment/blank lines changed in this symbol",
299
+ };
300
+ const symbolSeeds = [];
301
+ if (classification === "added") {
302
+ if (includeAddedSeeds) symbolSeeds.push(group.symbol.id);
303
+ } else if (classification !== "metadata-only") {
304
+ symbolSeeds.push(group.symbol.id);
305
+ if ((classification === "removed" || classification === "signature-changed") && group.symbol.exported && record?.fileNodeId) symbolSeeds.push(record.fileNodeId);
306
+ }
307
+ seedIds.push(...symbolSeeds);
308
+ symbols.push({
309
+ id: group.symbol.id,
310
+ label: group.symbol.label,
311
+ start: group.symbol.start,
312
+ end: group.symbol.end,
313
+ exported: group.symbol.exported,
314
+ classification,
315
+ reason: reasons[classification],
316
+ addedLines: group.additions.map((change) => change.newLine).filter(Boolean),
317
+ removedLines: group.removals.map((change) => change.oldLine).filter(Boolean),
318
+ seedIds: [...new Set(symbolSeeds)].sort(),
319
+ });
320
+ }
321
+
322
+ const unmappedCode = unmapped.filter((change) => !isMetadataLine(change.text));
323
+ const unmappedMetadata = unmapped.length > 0 && !unmappedCode.length;
324
+ let classification;
325
+ let reason;
326
+ if (parsed.binary) {
327
+ classification = "unknown";
328
+ reason = "binary diff has no line-level evidence";
329
+ } else if (parsed.deletedFile) {
330
+ classification = "removed";
331
+ reason = "file removed";
332
+ } else if (parsed.renamed) {
333
+ classification = "signature-changed";
334
+ reason = "file rename changes module identity";
335
+ } else if (parsed.newFile) {
336
+ classification = "added";
337
+ reason = "new file; no existing dependent can target it yet";
338
+ } else if (unmappedCode.some((change) => moduleSignatureText(change.text))) {
339
+ classification = "signature-changed";
340
+ reason = "module import/export surface changed outside a mapped symbol";
341
+ } else if (unmappedCode.length) {
342
+ classification = "unknown";
343
+ reason = "executable diff lines could not be mapped to a graph symbol";
344
+ } else if (symbols.length) {
345
+ classification = strongest(symbols.map((symbol) => symbol.classification));
346
+ reason = symbols.length === 1 ? symbols[0].reason : `${symbols.length} mapped symbols; strongest change is ${classification}`;
347
+ } else if (unmappedMetadata || parsed.hunks.length || parsed.additions.length || parsed.removals.length) {
348
+ classification = "metadata-only";
349
+ reason = "only comment/blank metadata changed outside symbols";
350
+ } else {
351
+ classification = "metadata-only";
352
+ reason = "file metadata changed without textual hunks";
353
+ }
354
+
355
+ if (parsed.binary || parsed.deletedFile || parsed.renamed || classification === "unknown") {
356
+ if (record?.fileNodeId) seedIds.push(record.fileNodeId);
357
+ if (parsed.binary || parsed.deletedFile || classification === "unknown") seedIds.push(...(record?.symbols || []).map((symbol) => symbol.id));
358
+ } else if (classification === "signature-changed" && !symbols.length && record?.fileNodeId) {
359
+ seedIds.push(record.fileNodeId);
360
+ } else if (classification === "added" && includeAddedSeeds) {
361
+ if (record?.fileNodeId) seedIds.push(record.fileNodeId);
362
+ seedIds.push(...symbols.map((symbol) => symbol.id));
363
+ }
364
+
365
+ return {
366
+ path,
367
+ oldPath: parsed.oldPath,
368
+ newPath: parsed.newPath,
369
+ classification,
370
+ reason,
371
+ binary: parsed.binary,
372
+ renamed: parsed.renamed,
373
+ addedLines: parsed.additions.length,
374
+ removedLines: parsed.removals.length,
375
+ symbols,
376
+ seedIds: [...new Set(seedIds)].sort(),
377
+ };
378
+ }
379
+
380
+ function unknownFile(path, indexed, reason) {
381
+ const normalized = normalizePath(path);
382
+ const record = indexed.get(normalized);
383
+ return {
384
+ path: normalized,
385
+ oldPath: normalized,
386
+ newPath: normalized,
387
+ classification: "unknown",
388
+ reason,
389
+ binary: false,
390
+ renamed: false,
391
+ addedLines: 0,
392
+ removedLines: 0,
393
+ symbols: [],
394
+ seedIds: [record?.fileNodeId, ...(record?.symbols || []).map((symbol) => symbol.id)].filter(Boolean).sort(),
395
+ };
396
+ }
397
+
398
+ function runGitDiff(repoRoot, base, _files, limits) {
399
+ const args = ["-C", repoRoot, "diff", "--no-ext-diff", "--find-renames", "--no-color", "--unified=0", String(base), "--"];
400
+ const result = spawnSync("git", args, {
401
+ encoding: "utf8",
402
+ windowsHide: true,
403
+ timeout: 12_000,
404
+ maxBuffer: limits.maxDiffBytes + 1,
405
+ env: childProcessEnv(),
406
+ });
407
+ if (result.status === 0) return { available: true, text: String(result.stdout || ""), error: null };
408
+ const oversized = result.error?.code === "ENOBUFS" || Buffer.byteLength(String(result.stdout || "")) > limits.maxDiffBytes;
409
+ return { available: false, text: String(result.stdout || ""), oversized, error: oversized ? "git diff exceeded the byte limit" : String(result.stderr || result.error?.message || "git diff unavailable").trim() };
410
+ }
411
+
412
+ const validExplicitFiles = (files) => [...new Set((Array.isArray(files) ? files : [])
413
+ .map(normalizePath)
414
+ .filter((file) => file && !file.startsWith("../") && !file.includes("/../") && !file.startsWith("-")))]
415
+ .sort((a, b) => a.localeCompare(b));
416
+
417
+ export function classifyChangeImpact({
418
+ repoRoot = "",
419
+ graph = {},
420
+ base = "",
421
+ diffText,
422
+ files = [],
423
+ includeAddedSeeds = false,
424
+ limits: requestedLimits = {},
425
+ } = {}) {
426
+ const limits = limitsOf(requestedLimits);
427
+ const explicitFiles = validExplicitFiles(files);
428
+ let source = "provided-diff";
429
+ let available = typeof diffText === "string";
430
+ let text = available ? diffText : "";
431
+ let unavailableReason = "";
432
+ let gitOversized = false;
433
+ if (!available && repoRoot && base) {
434
+ source = "git-diff";
435
+ const result = runGitDiff(repoRoot, base, explicitFiles, limits);
436
+ available = result.available;
437
+ text = result.text;
438
+ unavailableReason = result.error || "";
439
+ gitOversized = result.oversized === true;
440
+ } else if (!available) {
441
+ source = "files-only";
442
+ unavailableReason = "no unified diff was provided and no repoRoot/base pair was available";
443
+ }
444
+
445
+ const indexed = graphIndex(graph, limits);
446
+ const parsed = available ? parseZeroContextDiff(text, limits) : { files: [], changedLines: 0, byteLength: 0, truncated: gitOversized, oversized: gitOversized, limits };
447
+ const analyzed = parsed.files.map((file) => analyzeParsedFile(file, indexed, { includeAddedSeeds }));
448
+ const represented = new Set(analyzed.flatMap((file) => [file.oldPath, file.newPath].filter(Boolean)));
449
+ for (const file of explicitFiles) {
450
+ if (!represented.has(file)) analyzed.push(unknownFile(file, indexed, available ? "explicitly changed file had no textual hunk" : unavailableReason));
451
+ }
452
+ if (!available && !explicitFiles.length) analyzed.push(unknownFile("(diff unavailable)", indexed, unavailableReason));
453
+ analyzed.sort((a, b) => a.path.localeCompare(b.path));
454
+
455
+ if (parsed.truncated || parsed.oversized || gitOversized) {
456
+ for (const file of analyzed) {
457
+ file.classification = "unknown";
458
+ file.reason = "diff was truncated/oversized; symbol-level classification is incomplete";
459
+ const record = indexed.get(file.newPath) || indexed.get(file.oldPath);
460
+ file.seedIds = [...new Set([record?.fileNodeId, ...(record?.symbols || []).map((symbol) => symbol.id), ...file.seedIds].filter(Boolean))].sort();
461
+ }
462
+ }
463
+
464
+ const rawSeeds = analyzed.flatMap((file) => file.seedIds);
465
+ const seeds = uniqueSorted(rawSeeds, limits.maxSeeds);
466
+ let verdict = "LOW";
467
+ for (const file of analyzed) {
468
+ const next = ["removed", "signature-changed", "unknown"].includes(file.classification)
469
+ ? "HIGH"
470
+ : file.classification === "body-changed" ? "MEDIUM" : "LOW";
471
+ if (VERDICT_RANK[next] > VERDICT_RANK[verdict]) verdict = next;
472
+ }
473
+ if (!available || parsed.truncated || seeds.truncated) verdict = "HIGH";
474
+
475
+ const counts = Object.fromEntries(Object.keys(CLASS_RANK).map((name) => [name, analyzed.filter((file) => file.classification === name).length]));
476
+ const reasons = [];
477
+ if (!available) reasons.push(`Diff unavailable: ${unavailableReason || "unknown error"}; using conservative file/symbol seeds.`);
478
+ if (parsed.truncated || parsed.oversized || gitOversized) reasons.push("Diff exceeded a safety bound; incomplete evidence is classified HIGH/unknown.");
479
+ if (counts.removed || counts["signature-changed"]) reasons.push(`${counts.removed} removed and ${counts["signature-changed"]} signature/module-surface file change(s) can break existing callers.`);
480
+ if (counts["body-changed"]) reasons.push(`${counts["body-changed"]} file(s) contain mapped executable body changes.`);
481
+ if (counts.added && !includeAddedSeeds) reasons.push(`${counts.added} purely additive file change(s) create no dependent seeds by default.`);
482
+ if (counts["metadata-only"]) reasons.push(`${counts["metadata-only"]} metadata-only file change(s) create no dependent seeds.`);
483
+ if (counts.unknown) reasons.push(`${counts.unknown} file change(s) remain unknown and are seeded conservatively.`);
484
+ if (!reasons.length) reasons.push("No changed files were present in the supplied diff.");
485
+
486
+ return {
487
+ ok: available && !parsed.truncated && !seeds.truncated,
488
+ source,
489
+ verdict,
490
+ reasons,
491
+ seedIds: seeds.items,
492
+ files: analyzed,
493
+ summary: {
494
+ files: analyzed.length,
495
+ symbols: analyzed.reduce((sum, file) => sum + file.symbols.length, 0),
496
+ counts,
497
+ seeds: seeds.items.length,
498
+ totalSeedsBeforeCap: seeds.total,
499
+ },
500
+ bounds: {
501
+ ...limits,
502
+ diffBytes: parsed.byteLength,
503
+ changedLines: parsed.changedLines,
504
+ truncated: !!parsed.truncated || seeds.truncated,
505
+ },
506
+ };
507
+ }
508
+
509
+ export { DEFAULT_LIMITS as CHANGE_CLASSIFICATION_LIMITS };
@@ -0,0 +1,14 @@
1
+ export const MAX_REPRESENTATIVE_CYCLE_FILES = 64;
2
+
3
+ // Keep the complete closed route for normal cycles. Very large SCCs still get a closed, deterministic
4
+ // head/tail route without letting one finding consume the whole audit response.
5
+ export function formatRepresentativeCycle(cycle, maxFiles = MAX_REPRESENTATIVE_CYCLE_FILES) {
6
+ const raw = Array.isArray(cycle) ? cycle.map(String).filter(Boolean) : [];
7
+ if (!raw.length) return "";
8
+ const closed = raw.length > 1 && raw[0] === raw[raw.length - 1] ? raw : [...raw, raw[0]];
9
+ const cap = Math.max(4, Number(maxFiles) || MAX_REPRESENTATIVE_CYCLE_FILES);
10
+ if (closed.length <= cap + 1) return closed.join(" → ");
11
+ const side = Math.max(2, Math.floor(cap / 2));
12
+ const omitted = closed.length - side * 2;
13
+ return `${closed.slice(0, side).join(" → ")} → … ${omitted} file(s) omitted … → ${closed.slice(-side).join(" → ")}`;
14
+ }
@@ -9,13 +9,15 @@ import { readFileSync } from "node:fs";
9
9
  import { posix } from "node:path";
10
10
  import { createRepoBoundary } from "../repo-path.js";
11
11
  import { isStructuralRelation } from "../graph/relations.js";
12
+ import { createPathClassifier, hasPathClass } from "../path-classification.js";
12
13
 
13
14
  const IDENT_RE = /[A-Za-z_$][\w$]*/g;
14
15
  const bareName = (label) => String(label || "").replace(/\s*\(.*$/, "").replace(/[()]/g, "").trim();
15
16
  // entry surfaces are never dead even with no inbound edge (framework/CLI/HTTP enter them externally).
16
17
  // Exported for internal-audit.js (reachability entry set) — keep the two in lockstep.
17
18
  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;
18
- const TEST_FILE = /(^|[\\/])[^\\/]*[._-](test|spec)\.[a-z0-9]+$|(^|[\\/])(test|tests|__tests__|spec)[\\/]/i;
19
+ const defaultPathClassifier = createPathClassifier(null);
20
+ const isTestFile = (file) => hasPathClass(defaultPathClassifier.explain(file), "test", "e2e");
19
21
 
20
22
  // Framework-owned entry modules are invoked by convention rather than a source import. Keep this narrow:
21
23
  // these are Next.js App/Pages Router surfaces and framework metadata files, not every file under `app/`.
@@ -144,7 +146,7 @@ export function computeDead(graph, sources, { entrySet = new Set() } = {}) {
144
146
  const deadSymbols = [];
145
147
  for (const n of symById.values()) {
146
148
  if (isReferenced(n)) continue;
147
- const test = TEST_FILE.test(n.source_file);
149
+ const test = isTestFile(n.source_file);
148
150
  deadSymbols.push({ id: n.id, file: n.source_file, label: n.label, test, reason: "no inbound edge and name unreferenced outside its file" });
149
151
  }
150
152
  const deadSet = new Set(deadSymbols.map((s) => s.id));
@@ -202,7 +204,7 @@ export function computeUnusedExports(graph, sources, { dynamicTargets = new Set(
202
204
  for (const { n, nm } of candidates) {
203
205
  const occ = occursIn.get(nm);
204
206
  if (occ && [...occ].some((f) => f !== n.source_file)) continue; // referenced beyond its own file → alive
205
- out.push({ id: n.id, file: n.source_file, label: n.label, test: TEST_FILE.test(n.source_file), reason: "exported but never imported or referenced outside its own file" });
207
+ out.push({ id: n.id, file: n.source_file, label: n.label, test: isTestFile(n.source_file), reason: "exported but never imported or referenced outside its own file" });
206
208
  }
207
209
  return out;
208
210
  }