weavatrix 0.1.3 → 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 (78) hide show
  1. package/README.md +168 -63
  2. package/SECURITY.md +25 -8
  3. package/package.json +2 -2
  4. package/skill/SKILL.md +108 -39
  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 +13 -6
  10. package/src/analysis/dead-code-review.js +245 -0
  11. package/src/analysis/dep-check-ecosystems.js +163 -0
  12. package/src/analysis/dep-check.js +69 -147
  13. package/src/analysis/dep-rules.js +47 -31
  14. package/src/analysis/duplicates.compute.js +47 -7
  15. package/src/analysis/endpoints-java.js +186 -0
  16. package/src/analysis/endpoints-rust.js +124 -0
  17. package/src/analysis/endpoints.js +18 -5
  18. package/src/analysis/findings.js +8 -1
  19. package/src/analysis/git-history.js +549 -0
  20. package/src/analysis/git-ref-graph.js +74 -0
  21. package/src/analysis/graph-analysis.aggregate.js +29 -28
  22. package/src/analysis/graph-analysis.edges.js +24 -0
  23. package/src/analysis/graph-analysis.js +1 -1
  24. package/src/analysis/graph-analysis.summaries.js +4 -1
  25. package/src/analysis/http-contracts.js +581 -0
  26. package/src/analysis/internal-audit.collect.js +43 -2
  27. package/src/analysis/internal-audit.reach.js +52 -1
  28. package/src/analysis/internal-audit.run.js +66 -13
  29. package/src/analysis/java-source.js +36 -0
  30. package/src/analysis/package-reachability.js +39 -0
  31. package/src/analysis/static-test-reachability.js +133 -0
  32. package/src/build-graph.js +72 -13
  33. package/src/graph/build-worker.js +3 -4
  34. package/src/graph/builder/lang-java.js +177 -14
  35. package/src/graph/builder/lang-js.js +71 -12
  36. package/src/graph/builder/lang-rust.js +129 -6
  37. package/src/graph/community.js +27 -0
  38. package/src/graph/file-lock.js +69 -0
  39. package/src/graph/freshness-probe.js +141 -0
  40. package/src/graph/graph-filter.js +28 -11
  41. package/src/graph/incremental-refresh.js +232 -0
  42. package/src/graph/internal-builder.barrels.js +169 -0
  43. package/src/graph/internal-builder.build.js +111 -16
  44. package/src/graph/internal-builder.java.js +33 -0
  45. package/src/graph/internal-builder.langs.js +2 -1
  46. package/src/graph/internal-builder.resolvers.js +109 -2
  47. package/src/graph/layout.js +21 -5
  48. package/src/graph/relations.js +4 -0
  49. package/src/graph/repo-registry.js +124 -0
  50. package/src/mcp/catalog.mjs +64 -21
  51. package/src/mcp/evidence-snapshot.architecture.mjs +80 -0
  52. package/src/mcp/evidence-snapshot.common.mjs +160 -0
  53. package/src/mcp/evidence-snapshot.duplicates.mjs +217 -0
  54. package/src/mcp/evidence-snapshot.health.mjs +95 -0
  55. package/src/mcp/evidence-snapshot.inventory.mjs +148 -0
  56. package/src/mcp/evidence-snapshot.mjs +50 -0
  57. package/src/mcp/evidence-snapshot.package-graph.mjs +249 -0
  58. package/src/mcp/evidence-snapshot.structure.mjs +81 -0
  59. package/src/mcp/graph-context.mjs +106 -181
  60. package/src/mcp/graph-diff.mjs +217 -0
  61. package/src/mcp/staleness-notice.mjs +20 -0
  62. package/src/mcp/sync-evidence.mjs +418 -0
  63. package/src/mcp/sync-payload.mjs +194 -8
  64. package/src/mcp/tool-result.mjs +51 -0
  65. package/src/mcp/tools-actions.mjs +114 -33
  66. package/src/mcp/tools-architecture.mjs +144 -0
  67. package/src/mcp/tools-company.mjs +273 -0
  68. package/src/mcp/tools-graph-hubs.mjs +58 -0
  69. package/src/mcp/tools-graph.mjs +36 -68
  70. package/src/mcp/tools-health.mjs +354 -20
  71. package/src/mcp/tools-history.mjs +22 -0
  72. package/src/mcp/tools-impact-change.mjs +261 -0
  73. package/src/mcp/tools-impact.mjs +35 -11
  74. package/src/mcp-server.mjs +100 -17
  75. package/src/mcp-source-tools.mjs +11 -3
  76. package/src/path-classification.js +201 -0
  77. package/src/path-ignore.js +69 -0
  78. package/src/security/typosquat.js +1 -1
@@ -0,0 +1,94 @@
1
+ // Deterministic health-debt comparison. Supply-chain findings are deliberately excluded: an
2
+ // immutable source checkout has no trustworthy per-repository OSV refresh stamp or installed-package
3
+ // malware state, so calling those findings new/fixed would manufacture certainty.
4
+ const OPTIONAL_CATEGORIES = new Set(["vulnerability", "malware"]);
5
+
6
+ const normalizePath = (value) => String(value || "").trim()
7
+ .replace(/\\/g, "/")
8
+ .replace(/^\.\//, "")
9
+ .replace(/\/{2,}/g, "/")
10
+ .replace(/\/$/, "");
11
+
12
+ export function normalizeAuditScopeFiles(input, maxFiles = 500) {
13
+ if (!Array.isArray(input)) return { ok: true, files: null };
14
+ const files = [];
15
+ const seen = new Set();
16
+ for (const raw of input) {
17
+ const file = normalizePath(raw);
18
+ if (!file || file.includes("\0") || file.startsWith("/") || /^[A-Za-z]:\//.test(file) || file === ".." || file.startsWith("../") || file.includes("/../") || file.startsWith("-")) {
19
+ return { ok: false, files: [], error: `changed_files contains an invalid repo-relative path: ${String(raw || "(empty)")}` };
20
+ }
21
+ if (!seen.has(file)) { seen.add(file); files.push(file); }
22
+ if (files.length > maxFiles) return { ok: false, files: [], error: `changed_files exceeds the ${maxFiles}-file safety bound` };
23
+ }
24
+ if (!files.length) return { ok: false, files: [], error: "changed_files was provided but empty" };
25
+ return { ok: true, files: files.sort((a, b) => a.localeCompare(b)) };
26
+ }
27
+
28
+ export function auditFindingFiles(finding) {
29
+ const values = [finding?.file, finding?.manifest, finding?.graphNodeId];
30
+ for (const evidence of finding?.evidence || []) values.push(evidence?.file);
31
+ return [...new Set(values
32
+ .map((value) => normalizePath(String(value || "").split("#", 1)[0]))
33
+ .filter(Boolean))]
34
+ .sort((a, b) => a.localeCompare(b));
35
+ }
36
+
37
+ export function findingTouchesAuditScope(finding, changedFiles) {
38
+ if (changedFiles == null) return true;
39
+ const scope = changedFiles instanceof Set ? changedFiles : new Set(changedFiles.map(normalizePath));
40
+ return auditFindingFiles(finding).some((file) => scope.has(file));
41
+ }
42
+
43
+ export function scopeAuditFindings(findings, changedFiles) {
44
+ const scope = changedFiles == null ? null : new Set(changedFiles.map(normalizePath));
45
+ return (findings || []).filter((finding) => findingTouchesAuditScope(finding, scope));
46
+ }
47
+
48
+ const comparable = (findings) => (findings || []).filter((finding) => !OPTIONAL_CATEGORIES.has(finding.category));
49
+ const optional = (findings) => (findings || []).filter((finding) => OPTIONAL_CATEGORIES.has(finding.category));
50
+
51
+ // Findings already carry stable IDs from makeFinding(). Compare globally first, then apply the
52
+ // changed-file scope. This prevents pre-existing debt in an edited file from becoming "new".
53
+ export function compareAuditDebt(currentAudit, baselineAudit, changedFiles = null, { completeChangeSet = false } = {}) {
54
+ const current = comparable(currentAudit?.findings);
55
+ const baseline = comparable(baselineAudit?.findings);
56
+ const currentIds = new Set(current.map((finding) => String(finding.id)));
57
+ const baselineIds = new Set(baseline.map((finding) => String(finding.id)));
58
+ const scopedCurrent = scopeAuditFindings(current, changedFiles);
59
+ const scopedBaseline = scopeAuditFindings(baseline, changedFiles);
60
+ const globalFresh = current.filter((finding) => !baselineIds.has(String(finding.id)));
61
+ const globalFixedFindings = baseline.filter((finding) => !currentIds.has(String(finding.id)));
62
+ // A complete, automatically derived Git diff is the causal boundary: a new manifest-level finding
63
+ // can be caused by editing only its former importer, so path intersection would hide it. Explicit
64
+ // changed_files may be a subset of other working-tree changes and therefore stays path-scoped.
65
+ const fresh = completeChangeSet ? globalFresh : scopedCurrent.filter((finding) => !baselineIds.has(String(finding.id)));
66
+ const existing = scopedCurrent.filter((finding) => baselineIds.has(String(finding.id)));
67
+ const fixed = completeChangeSet ? globalFixedFindings : scopedBaseline.filter((finding) => !currentIds.has(String(finding.id)));
68
+ const all = [...fresh, ...existing].sort((a, b) => String(a.id).localeCompare(String(b.id)));
69
+ const globalNew = globalFresh.length;
70
+ const globalExisting = current.length - globalNew;
71
+ const globalFixed = globalFixedFindings.length;
72
+ return {
73
+ scope: {strategy: completeChangeSet ? "complete-change-set-causality" : "path-intersection"},
74
+ new: fresh,
75
+ existing,
76
+ fixed,
77
+ all: all.map((finding) => ({ ...finding, debtState: baselineIds.has(String(finding.id)) ? "existing" : "new" })),
78
+ optional: {
79
+ current: optional(currentAudit?.findings),
80
+ baseline: optional(baselineAudit?.findings),
81
+ checks: ["osv", "malware"].map((name) => ({
82
+ name,
83
+ status: "UNCOMPARABLE",
84
+ current: currentAudit?.checks?.[name]?.status || "ERROR",
85
+ baseline: baselineAudit?.checks?.[name]?.status || "ERROR",
86
+ reason: "The immutable source checkout cannot reproduce the current advisory-refresh or installed-package scan state.",
87
+ })),
88
+ },
89
+ totals: {
90
+ scope: { new: fresh.length, existing: existing.length, fixed: fixed.length, active: fresh.length + existing.length },
91
+ repository: { new: globalNew, existing: globalExisting, fixed: globalFixed, active: current.length },
92
+ },
93
+ };
94
+ }
@@ -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
+ }