weavatrix 0.2.13 → 0.2.14

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 (130) hide show
  1. package/README.md +83 -39
  2. package/SECURITY.md +2 -2
  3. package/docs/releases/v0.2.14.md +93 -0
  4. package/package.json +2 -2
  5. package/skill/SKILL.md +57 -38
  6. package/src/analysis/architecture/contract-graph.js +119 -0
  7. package/src/analysis/architecture/contract-schema.js +110 -0
  8. package/src/analysis/architecture/contract-storage.js +35 -0
  9. package/src/analysis/architecture/contract-verification.js +168 -0
  10. package/src/analysis/architecture-contract.js +16 -343
  11. package/src/analysis/change-classification/diff-parser.js +103 -0
  12. package/src/analysis/change-classification/options.js +40 -0
  13. package/src/analysis/change-classification/symbol-classifier.js +177 -0
  14. package/src/analysis/change-classification.js +120 -519
  15. package/src/analysis/dead-code-review/policy.js +23 -0
  16. package/src/analysis/dead-code-review.js +9 -19
  17. package/src/analysis/dep-check.js +10 -57
  18. package/src/analysis/dep-rules.js +10 -303
  19. package/src/analysis/dependency/scoped-dependencies.js +40 -0
  20. package/src/analysis/endpoints/common.js +62 -0
  21. package/src/analysis/endpoints/extract.js +107 -0
  22. package/src/analysis/endpoints/inventory.js +84 -0
  23. package/src/analysis/endpoints/mounts.js +129 -0
  24. package/src/analysis/endpoints-java.js +80 -3
  25. package/src/analysis/endpoints.js +3 -448
  26. package/src/analysis/git-history/analytics.js +177 -0
  27. package/src/analysis/git-history/collector.js +109 -0
  28. package/src/analysis/git-history/options.js +68 -0
  29. package/src/analysis/git-history.js +128 -577
  30. package/src/analysis/health-capabilities.js +82 -0
  31. package/src/analysis/http-contracts/analysis.js +169 -0
  32. package/src/analysis/http-contracts/client-call-detection.js +81 -0
  33. package/src/analysis/http-contracts/client-call-parser.js +255 -0
  34. package/src/analysis/http-contracts/graph-context.js +143 -0
  35. package/src/analysis/http-contracts/matching.js +61 -0
  36. package/src/analysis/http-contracts/shared.js +86 -0
  37. package/src/analysis/http-contracts.js +6 -822
  38. package/src/analysis/internal-audit/dependency-health.js +111 -0
  39. package/src/analysis/internal-audit/python-manifests.js +65 -0
  40. package/src/analysis/internal-audit/repo-files.js +55 -0
  41. package/src/analysis/internal-audit/supply-chain.js +132 -0
  42. package/src/analysis/internal-audit.collect.js +5 -106
  43. package/src/analysis/internal-audit.run.js +113 -200
  44. package/src/analysis/jvm-dependency-evidence.js +69 -0
  45. package/src/analysis/source-correctness/retry-patterns.js +93 -0
  46. package/src/analysis/source-correctness.js +225 -0
  47. package/src/analysis/structure/dependency-graph.js +156 -0
  48. package/src/analysis/structure/findings.js +142 -0
  49. package/src/graph/builder/go-receiver-resolution.js +112 -0
  50. package/src/graph/builder/js/queries.js +48 -0
  51. package/src/graph/builder/lang-go.js +89 -4
  52. package/src/graph/builder/lang-js.js +4 -44
  53. package/src/graph/builder/pass2-resolution.js +68 -0
  54. package/src/graph/freshness-probe.js +2 -1
  55. package/src/graph/incremental-refresh.js +3 -2
  56. package/src/graph/internal-builder.build.js +22 -183
  57. package/src/graph/internal-builder.pass2.js +161 -0
  58. package/src/graph/internal-builder.resolvers.js +2 -105
  59. package/src/graph/resolvers/rust.js +117 -0
  60. package/src/mcp/actions/advisories.mjs +18 -0
  61. package/src/mcp/actions/graph-lifecycle.mjs +148 -0
  62. package/src/mcp/actions/graph-sync.mjs +195 -0
  63. package/src/mcp/actions/hosted-architecture.mjs +57 -0
  64. package/src/mcp/architecture-bootstrap.mjs +168 -0
  65. package/src/mcp/architecture-starter.mjs +234 -0
  66. package/src/mcp/catalog.mjs +20 -6
  67. package/src/mcp/evidence/bun-lock-graph.mjs +209 -0
  68. package/src/mcp/evidence/package-graph-common.mjs +115 -0
  69. package/src/mcp/evidence/package-lock-graph.mjs +92 -0
  70. package/src/mcp/evidence-snapshot.package-graph.mjs +5 -474
  71. package/src/mcp/graph/context-core.mjs +111 -0
  72. package/src/mcp/graph/context-seeds.mjs +208 -0
  73. package/src/mcp/graph/context-state.mjs +86 -0
  74. package/src/mcp/graph/tools-core.mjs +143 -0
  75. package/src/mcp/graph/tools-query.mjs +223 -0
  76. package/src/mcp/graph-context.mjs +4 -496
  77. package/src/mcp/health/audit-format.mjs +172 -0
  78. package/src/mcp/health/audit.mjs +171 -0
  79. package/src/mcp/health/dead-code.mjs +110 -0
  80. package/src/mcp/health/duplicates.mjs +83 -0
  81. package/src/mcp/health/endpoints.mjs +42 -0
  82. package/src/mcp/health/structure.mjs +219 -0
  83. package/src/mcp/runtime-version.mjs +32 -0
  84. package/src/mcp/server/auto-refresh.mjs +66 -0
  85. package/src/mcp/server/runtime-config.mjs +43 -0
  86. package/src/mcp/server/shutdown.mjs +40 -0
  87. package/src/mcp/sync/evidence-architecture.mjs +54 -0
  88. package/src/mcp/sync/evidence-common.mjs +88 -0
  89. package/src/mcp/sync/evidence-duplicates.mjs +100 -0
  90. package/src/mcp/sync/evidence-health.mjs +56 -0
  91. package/src/mcp/sync/evidence-packages.mjs +95 -0
  92. package/src/mcp/sync/payload-common.mjs +97 -0
  93. package/src/mcp/sync/payload-v2.mjs +53 -0
  94. package/src/mcp/sync/payload-v3.mjs +79 -0
  95. package/src/mcp/sync-evidence.mjs +7 -402
  96. package/src/mcp/sync-payload.mjs +10 -244
  97. package/src/mcp/tools-actions.mjs +18 -414
  98. package/src/mcp/tools-architecture.mjs +18 -70
  99. package/src/mcp/tools-context.mjs +56 -15
  100. package/src/mcp/tools-endpoints.mjs +4 -1
  101. package/src/mcp/tools-graph.mjs +17 -331
  102. package/src/mcp/tools-health.mjs +24 -705
  103. package/src/mcp-server.mjs +35 -146
  104. package/src/precision/lsp-client/constants.js +12 -0
  105. package/src/precision/lsp-client/environment.js +20 -0
  106. package/src/precision/lsp-client/errors.js +15 -0
  107. package/src/precision/lsp-client/lifecycle.js +127 -0
  108. package/src/precision/lsp-client/message-parser.js +81 -0
  109. package/src/precision/lsp-client/protocol.js +212 -0
  110. package/src/precision/lsp-client/registry.js +58 -0
  111. package/src/precision/lsp-client/repo-uri.js +60 -0
  112. package/src/precision/lsp-client/stdio-client.js +151 -0
  113. package/src/precision/lsp-client.js +10 -682
  114. package/src/precision/lsp-overlay/build.js +238 -0
  115. package/src/precision/lsp-overlay/contract.js +61 -0
  116. package/src/precision/lsp-overlay/reference-results.js +108 -0
  117. package/src/precision/lsp-overlay/semantic-inputs.js +62 -0
  118. package/src/precision/lsp-overlay/source-session.js +134 -0
  119. package/src/precision/lsp-overlay/store.js +150 -0
  120. package/src/precision/lsp-overlay/target-index.js +147 -0
  121. package/src/precision/lsp-overlay/target-query.js +55 -0
  122. package/src/precision/lsp-overlay.js +11 -868
  123. package/src/precision/typescript-lsp-provider.js +12 -682
  124. package/src/precision/typescript-provider/client.js +69 -0
  125. package/src/precision/typescript-provider/discovery.js +124 -0
  126. package/src/precision/typescript-provider/project-host.js +249 -0
  127. package/src/precision/typescript-provider/project-safety.js +161 -0
  128. package/src/precision/typescript-provider/reference-usage.js +53 -0
  129. package/src/version.js +7 -0
  130. package/docs/releases/v0.2.13.md +0 -48
@@ -1,534 +1,135 @@
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
- import { createPathClassifier, hasPathClass } from "../path-classification.js";
8
-
9
- const DEFAULT_LIMITS = Object.freeze({
10
- maxDiffBytes: 2 * 1024 * 1024,
11
- maxFiles: 500,
12
- maxChangedLines: 20_000,
13
- maxLineLength: 4_000,
14
- maxSymbolsPerFile: 250,
15
- maxSeeds: 1_000,
16
- });
17
- const CLASS_RANK = Object.freeze({
18
- "metadata-only": 0,
19
- "test-only": 0,
20
- added: 1,
21
- "body-changed": 2,
22
- "signature-changed": 3,
23
- removed: 4,
24
- unknown: 5,
25
- });
26
- const VERDICT_RANK = Object.freeze({ LOW: 0, MEDIUM: 1, HIGH: 2 });
27
- const STRUCTURAL_RELATIONS = new Set(["contains", "method"]);
28
-
29
- const normalizePath = (value) => String(value || "").replace(/\\/g, "/").replace(/^\.\//, "");
30
- const endpoint = (value) => String(value && typeof value === "object" ? value.id : value || "");
31
- const lineNumber = (value) => Number((String(value || "").match(/(?:^L|@)(\d+)$/) || [])[1] || 0);
32
- const bareLabel = (value) => String(value || "").replace(/\(.*$/, "").replace(/[^A-Za-z0-9_$].*$/, "").trim();
33
- const boundedNumber = (value, fallback, min, max) => Math.max(min, Math.min(max, Number(value) || fallback));
34
-
35
- function limitsOf(value = {}) {
36
- return {
37
- maxDiffBytes: boundedNumber(value.maxDiffBytes, DEFAULT_LIMITS.maxDiffBytes, 1_024, DEFAULT_LIMITS.maxDiffBytes),
38
- maxFiles: boundedNumber(value.maxFiles, DEFAULT_LIMITS.maxFiles, 1, DEFAULT_LIMITS.maxFiles),
39
- maxChangedLines: boundedNumber(value.maxChangedLines, DEFAULT_LIMITS.maxChangedLines, 10, DEFAULT_LIMITS.maxChangedLines),
40
- maxLineLength: boundedNumber(value.maxLineLength, DEFAULT_LIMITS.maxLineLength, 80, DEFAULT_LIMITS.maxLineLength),
41
- maxSymbolsPerFile: boundedNumber(value.maxSymbolsPerFile, DEFAULT_LIMITS.maxSymbolsPerFile, 1, DEFAULT_LIMITS.maxSymbolsPerFile),
42
- maxSeeds: boundedNumber(value.maxSeeds, DEFAULT_LIMITS.maxSeeds, 1, DEFAULT_LIMITS.maxSeeds),
43
- };
44
- }
45
-
46
- function decodeGitQuoted(value) {
47
- const input = String(value || "").trim();
48
- if (!input.startsWith('"')) return input.split("\t", 1)[0];
49
- try { return JSON.parse(input); } catch { /* Git also emits octal escapes, which JSON rejects. */ }
50
- const bytes = [];
51
- for (let index = 1; index < input.length - 1; index += 1) {
52
- const char = input[index];
53
- if (char !== "\\") {
54
- bytes.push(...Buffer.from(char));
55
- continue;
56
- }
57
- const next = input[++index] || "";
58
- if (/[0-7]/.test(next)) {
59
- let octal = next;
60
- while (octal.length < 3 && /[0-7]/.test(input[index + 1] || "")) octal += input[++index];
61
- bytes.push(parseInt(octal, 8));
62
- } else {
63
- const escapes = { n: 10, r: 13, t: 9, b: 8, f: 12, v: 11, "\\": 92, '"': 34 };
64
- bytes.push(escapes[next] ?? next.charCodeAt(0));
65
- }
66
- }
67
- return Buffer.from(bytes).toString("utf8");
68
- }
69
-
70
- function diffPath(raw, prefix) {
71
- const decoded = decodeGitQuoted(raw);
72
- if (!decoded || decoded === "/dev/null") return null;
73
- return normalizePath(decoded.startsWith(`${prefix}/`) ? decoded.slice(2) : decoded);
74
- }
75
-
76
- function headerPaths(line) {
77
- const match = /^diff --git ("(?:\\.|[^"])*"|\S+) ("(?:\\.|[^"])*"|\S+)$/.exec(line);
78
- if (!match) return { oldPath: null, newPath: null };
79
- return { oldPath: diffPath(match[1], "a"), newPath: diffPath(match[2], "b") };
80
- }
81
-
82
- const emptyFile = (paths = {}) => ({
83
- oldPath: paths.oldPath || null,
84
- newPath: paths.newPath || null,
85
- newFile: false,
86
- deletedFile: false,
87
- renamed: false,
88
- binary: false,
89
- hunks: [],
90
- additions: [],
91
- removals: [],
92
- });
93
-
94
- // Parse ordinary unified output, expecting --unified=0 but safely accepting context lines too.
95
- export function parseZeroContextDiff(diffText, options = {}) {
96
- const limits = limitsOf(options);
97
- const original = String(diffText ?? "");
98
- const byteLength = Buffer.byteLength(original);
99
- const oversized = byteLength > limits.maxDiffBytes;
100
- const text = oversized ? original.slice(0, limits.maxDiffBytes) : original;
101
- const files = [];
102
- let file = null;
103
- let hunk = null;
104
- let changedLines = 0;
105
- let truncated = oversized;
106
- const finish = () => {
107
- if (!file) return;
108
- if ((file.oldPath || file.newPath) && files.length < limits.maxFiles) files.push(file);
109
- else if (files.length >= limits.maxFiles) truncated = true;
110
- file = null;
111
- hunk = null;
112
- };
113
- const addChange = (kind, line, oldLine, newLine, mappedNewLine) => {
114
- changedLines++;
115
- if (changedLines > limits.maxChangedLines) { truncated = true; return; }
116
- const change = {
117
- kind,
118
- text: String(line).slice(0, limits.maxLineLength),
119
- ...(oldLine != null ? { oldLine } : {}),
120
- ...(newLine != null ? { newLine } : {}),
121
- mappedNewLine,
122
- };
123
- (kind === "added" ? file.additions : file.removals).push(change);
124
- };
125
-
126
- for (const line of text.split(/\r?\n/)) {
127
- if (line.startsWith("diff --git ")) {
128
- finish();
129
- file = emptyFile(headerPaths(line));
130
- continue;
131
- }
132
- if (!file && line.startsWith("--- ")) file = emptyFile();
133
- if (!file) continue;
134
- if (line.startsWith("new file mode ")) { file.newFile = true; continue; }
135
- if (line.startsWith("deleted file mode ")) { file.deletedFile = true; continue; }
136
- if (line.startsWith("rename from ")) { file.oldPath = normalizePath(decodeGitQuoted(line.slice(12))); file.renamed = true; continue; }
137
- if (line.startsWith("rename to ")) { file.newPath = normalizePath(decodeGitQuoted(line.slice(10))); file.renamed = true; continue; }
138
- if (line.startsWith("Binary files ") || line === "GIT binary patch") { file.binary = true; continue; }
139
- if (!hunk && line.startsWith("--- ")) {
140
- file.oldPath = diffPath(line.slice(4), "a");
141
- if (!file.oldPath) file.newFile = true;
142
- continue;
143
- }
144
- if (!hunk && line.startsWith("+++ ")) {
145
- file.newPath = diffPath(line.slice(4), "b");
146
- if (!file.newPath) file.deletedFile = true;
147
- continue;
148
- }
149
- const match = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(line);
150
- if (match) {
151
- hunk = {
152
- oldStart: Number(match[1]), oldCount: match[2] == null ? 1 : Number(match[2]),
153
- newStart: Number(match[3]), newCount: match[4] == null ? 1 : Number(match[4]),
154
- oldCursor: Number(match[1]), newCursor: Number(match[3]),
155
- };
156
- file.hunks.push({ oldStart: hunk.oldStart, oldCount: hunk.oldCount, newStart: hunk.newStart, newCount: hunk.newCount });
157
- continue;
1
+ // Symbol-aware git-diff classification for change_impact.
2
+ import {spawnSync} from 'node:child_process'
3
+ import {childProcessEnv} from '../child-env.js'
4
+ import {createPathClassifier} from '../path-classification.js'
5
+ import {parseZeroContextDiff} from './change-classification/diff-parser.js'
6
+ import {
7
+ CHANGE_CLASSIFICATION_LIMITS,
8
+ CHANGE_CLASS_RANK,
9
+ changeLimits,
10
+ normalizeChangePath,
11
+ uniqueChangeSeeds,
12
+ } from './change-classification/options.js'
13
+ import {
14
+ analyzeParsedFile,
15
+ classifyTestSurface,
16
+ indexChangeGraph,
17
+ unknownChangedFile,
18
+ } from './change-classification/symbol-classifier.js'
19
+
20
+ export {parseZeroContextDiff} from './change-classification/diff-parser.js'
21
+ export {CHANGE_CLASSIFICATION_LIMITS} from './change-classification/options.js'
22
+
23
+ const VERDICT_RANK = Object.freeze({LOW: 0, MEDIUM: 1, HIGH: 2})
24
+
25
+ function runGitDiff(repoRoot, base, limits) {
26
+ const args = ['-C', repoRoot, 'diff', '--no-ext-diff', '--find-renames', '--no-color', '--unified=0', String(base), '--']
27
+ const result = spawnSync('git', args, {
28
+ encoding: 'utf8', windowsHide: true, timeout: 12_000,
29
+ maxBuffer: limits.maxDiffBytes + 1, env: childProcessEnv(),
30
+ })
31
+ if (result.status === 0) return {available: true, text: String(result.stdout || ''), error: null}
32
+ const oversized = result.error?.code === 'ENOBUFS' || Buffer.byteLength(String(result.stdout || '')) > limits.maxDiffBytes
33
+ return {
34
+ available: false,
35
+ text: String(result.stdout || ''),
36
+ oversized,
37
+ error: oversized
38
+ ? 'git diff exceeded the byte limit'
39
+ : String(result.stderr || result.error?.message || 'git diff unavailable').trim(),
158
40
  }
159
- if (!hunk || line.startsWith("\\ No newline")) continue;
160
- if (line.startsWith("+")) {
161
- addChange("added", line.slice(1), null, hunk.newCursor, hunk.newCursor);
162
- hunk.newCursor++;
163
- } else if (line.startsWith("-")) {
164
- addChange("removed", line.slice(1), hunk.oldCursor, null, hunk.newCursor);
165
- hunk.oldCursor++;
166
- } else {
167
- hunk.oldCursor++;
168
- hunk.newCursor++;
169
- }
170
- }
171
- finish();
172
- return {
173
- files,
174
- byteLength,
175
- changedLines: Math.min(changedLines, limits.maxChangedLines),
176
- truncated,
177
- oversized,
178
- limits,
179
- };
180
41
  }
181
42
 
182
- function graphIndex(graph, limits) {
183
- const byFile = new Map();
184
- for (const node of graph?.nodes || []) {
185
- const file = normalizePath(node?.source_file || (!String(node?.id || "").includes("#") ? node?.id : ""));
186
- if (!file) continue;
187
- if (!byFile.has(file)) byFile.set(file, { path: file, fileNodeId: null, symbols: [] });
188
- const record = byFile.get(file);
189
- if (!String(node.id).includes("#")) record.fileNodeId = String(node.id);
190
- else if (record.symbols.length < limits.maxSymbolsPerFile) {
191
- const start = lineNumber(node.source_location) || lineNumber(node.id);
192
- if (!start) continue;
193
- record.symbols.push({
194
- id: String(node.id),
195
- label: String(node.label || node.id),
196
- start,
197
- end: lineNumber(node.source_end),
198
- exported: node.exported === true,
199
- symbolKind: node.symbol_kind || null,
200
- });
43
+ const validExplicitFiles = (files) => [...new Set((Array.isArray(files) ? files : [])
44
+ .map(normalizeChangePath)
45
+ .filter((file) => file && !file.startsWith('../') && !file.includes('/../') && !file.startsWith('-')))]
46
+ .sort((a, b) => a.localeCompare(b))
47
+
48
+ function collectDiffInput({diffText, repoRoot, base, explicitFiles, limits}) {
49
+ if (typeof diffText === 'string') return {source: 'provided-diff', available: true, text: diffText, reason: '', oversized: false}
50
+ if (repoRoot && base) {
51
+ const result = runGitDiff(repoRoot, base, limits)
52
+ return {source: 'git-diff', available: result.available, text: result.text, reason: result.error || '', oversized: result.oversized === true}
201
53
  }
202
- }
203
- for (const record of byFile.values()) {
204
- record.symbols.sort((a, b) => a.start - b.start || a.id.localeCompare(b.id));
205
- for (let index = 0; index < record.symbols.length; index += 1) {
206
- const symbol = record.symbols[index];
207
- if (!symbol.end || symbol.end < symbol.start) {
208
- const next = record.symbols[index + 1]?.start;
209
- symbol.end = next ? Math.max(symbol.start, next - 1) : symbol.start + 400;
210
- }
211
- symbol.end = Math.min(symbol.end, symbol.start + 2_000);
54
+ return {
55
+ source: 'files-only', available: false, text: '', oversized: false,
56
+ reason: 'no unified diff was provided and no repoRoot/base pair was available',
212
57
  }
213
- }
214
- return byFile;
215
- }
216
-
217
- function isMetadataLine(text) {
218
- const value = String(text || "").trim();
219
- return !value || /^(?:\/\/|\/\*|\*|\*\/|#(?!include\b)|<!--|-->|"""|''')/.test(value);
220
- }
221
-
222
- function signatureText(text, symbol) {
223
- const value = String(text || "").trim();
224
- const name = bareLabel(symbol.label);
225
- const hasName = name && new RegExp(`\\b${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i").test(value);
226
- 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);
227
- }
228
-
229
- function signaturePosition(change, symbol) {
230
- const candidates = change.kind === "removed" ? [change.mappedNewLine, change.oldLine] : [change.newLine];
231
- if (candidates.some((line) => line === symbol.start)) return true;
232
- const nearStart = candidates.some((line) => Number.isFinite(line) && line > symbol.start && line <= symbol.start + 4);
233
- if (!nearStart) return false;
234
- const value = String(change.text || "").trim();
235
- // Multiline TS/Python/Java/Go parameter/type clauses. Keep statement keywords out so an ordinary
236
- // first body line is not promoted to a public-contract change.
237
- if (/^(?:return|throw|yield|if|for|while|switch|match|const|let|var|this\.|self\.|[A-Za-z_$][\w$]*\s*=)/.test(value)) return false;
238
- return /^(?:[A-Za-z_$][\w$]*\??\s*:\s*[^;]+[,)]?|[A-Za-z_$][\w$<>,.?\[\] :*&]+\s+[A-Za-z_$][\w$]*\s*[,)]|[),:<>{}\[\]|&?]+)$/.test(value);
239
- }
240
-
241
- function moduleSignatureText(text) {
242
- 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 || ""));
243
58
  }
244
59
 
245
- function chooseSymbol(record, change) {
246
- if (!record?.symbols?.length) return null;
247
- const lines = change.kind === "removed"
248
- ? [...new Set([change.mappedNewLine, change.oldLine].filter((line) => Number.isFinite(line) && line > 0))]
249
- : [change.newLine];
250
- const candidates = [];
251
- for (const symbol of record.symbols) {
252
- if (!lines.some((line) => line >= symbol.start && line <= symbol.end)) continue;
253
- const label = bareLabel(symbol.label);
254
- const mentions = label && new RegExp(`\\b${label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`).test(change.text);
255
- candidates.push({ symbol, mentions, span: symbol.end - symbol.start });
256
- }
257
- 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;
60
+ function classificationReasons(counts, {available, unavailableReason, truncated, includeAddedSeeds}) {
61
+ const reasons = []
62
+ if (!available) reasons.push(`Diff unavailable: ${unavailableReason || 'unknown error'}; using conservative file/symbol seeds.`)
63
+ if (truncated) reasons.push('Diff exceeded a safety bound; incomplete evidence is classified HIGH/unknown.')
64
+ 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.`)
65
+ if (counts['body-changed']) reasons.push(`${counts['body-changed']} file(s) contain mapped executable body changes.`)
66
+ if (counts.added && !includeAddedSeeds) reasons.push(`${counts.added} purely additive file change(s) create no dependent seeds by default.`)
67
+ if (counts['metadata-only']) reasons.push(`${counts['metadata-only']} metadata-only file change(s) create no dependent seeds.`)
68
+ if (counts['test-only']) reasons.push(`${counts['test-only']} test-only file change(s) are labelled explicitly and create no product blast-radius seeds.`)
69
+ if (counts.unknown) reasons.push(`${counts.unknown} file change(s) remain unknown and are seeded conservatively.`)
70
+ if (!reasons.length) reasons.push('No changed files were present in the supplied diff.')
71
+ return reasons
258
72
  }
259
73
 
260
- function strongest(classes) {
261
- return [...classes].sort((a, b) => (CLASS_RANK[b] ?? 5) - (CLASS_RANK[a] ?? 5) || a.localeCompare(b))[0] || "metadata-only";
262
- }
263
-
264
- function uniqueSorted(values, limit) {
265
- const all = [...new Set(values.filter(Boolean).map(String))].sort((a, b) => a.localeCompare(b));
266
- return { items: all.slice(0, limit), truncated: all.length > limit, total: all.length };
267
- }
268
-
269
- function analyzeParsedFile(parsed, indexed, { includeAddedSeeds }) {
270
- const path = parsed.newPath || parsed.oldPath || "(unknown)";
271
- const record = indexed.get(parsed.newPath) || indexed.get(parsed.oldPath) || null;
272
- const grouped = new Map();
273
- const unmapped = [];
274
- for (const change of [...parsed.additions, ...parsed.removals]) {
275
- const symbol = chooseSymbol(record, change);
276
- if (!symbol) { unmapped.push(change); continue; }
277
- if (!grouped.has(symbol.id)) grouped.set(symbol.id, { symbol, additions: [], removals: [] });
278
- grouped.get(symbol.id)[change.kind === "added" ? "additions" : "removals"].push(change);
279
- }
280
-
281
- const symbols = [];
282
- const seedIds = [];
283
- for (const group of [...grouped.values()].sort((a, b) => a.symbol.start - b.symbol.start || a.symbol.id.localeCompare(b.symbol.id))) {
284
- const addedCode = group.additions.filter((change) => !isMetadataLine(change.text));
285
- const removedCode = group.removals.filter((change) => !isMetadataLine(change.text));
286
- const addedDeclaration = addedCode.some((change) => signaturePosition(change, group.symbol) || signatureText(change.text, group.symbol));
287
- const removedDeclaration = removedCode.some((change) => signaturePosition(change, group.symbol) || signatureText(change.text, group.symbol));
288
- let classification;
289
- if (parsed.newFile || (addedDeclaration && !removedCode.length)) classification = "added";
290
- else if (parsed.deletedFile || (removedDeclaration && !addedCode.length)) classification = "removed";
291
- else if (addedDeclaration || removedDeclaration) classification = "signature-changed";
292
- else if (addedCode.length || removedCode.length) classification = "body-changed";
293
- else classification = "metadata-only";
294
-
295
- const reasons = {
296
- added: "new declaration; existing callers cannot depend on it yet",
297
- removed: "declaration removed; existing callers may break",
298
- "signature-changed": "declaration/signature line changed",
299
- "body-changed": "executable lines changed inside the symbol body",
300
- "metadata-only": "only comment/blank lines changed in this symbol",
301
- };
302
- const symbolSeeds = [];
303
- if (classification === "added") {
304
- if (includeAddedSeeds) symbolSeeds.push(group.symbol.id);
305
- } else if (classification !== "metadata-only") {
306
- symbolSeeds.push(group.symbol.id);
307
- if ((classification === "removed" || classification === "signature-changed") && group.symbol.exported && record?.fileNodeId) symbolSeeds.push(record.fileNodeId);
308
- }
309
- seedIds.push(...symbolSeeds);
310
- symbols.push({
311
- id: group.symbol.id,
312
- label: group.symbol.label,
313
- start: group.symbol.start,
314
- end: group.symbol.end,
315
- exported: group.symbol.exported,
316
- classification,
317
- reason: reasons[classification],
318
- addedLines: group.additions.map((change) => change.newLine).filter(Boolean),
319
- removedLines: group.removals.map((change) => change.oldLine).filter(Boolean),
320
- seedIds: [...new Set(symbolSeeds)].sort(),
321
- });
322
- }
323
-
324
- const unmappedCode = unmapped.filter((change) => !isMetadataLine(change.text));
325
- const unmappedMetadata = unmapped.length > 0 && !unmappedCode.length;
326
- let classification;
327
- let reason;
328
- if (parsed.binary) {
329
- classification = "unknown";
330
- reason = "binary diff has no line-level evidence";
331
- } else if (parsed.deletedFile) {
332
- classification = "removed";
333
- reason = "file removed";
334
- } else if (parsed.renamed) {
335
- classification = "signature-changed";
336
- reason = "file rename changes module identity";
337
- } else if (parsed.newFile) {
338
- classification = "added";
339
- reason = "new file; no existing dependent can target it yet";
340
- } else if (unmappedCode.some((change) => moduleSignatureText(change.text))) {
341
- classification = "signature-changed";
342
- reason = "module import/export surface changed outside a mapped symbol";
343
- } else if (unmappedCode.length) {
344
- classification = "unknown";
345
- reason = "executable diff lines could not be mapped to a graph symbol";
346
- } else if (symbols.length) {
347
- classification = strongest(symbols.map((symbol) => symbol.classification));
348
- reason = symbols.length === 1 ? symbols[0].reason : `${symbols.length} mapped symbols; strongest change is ${classification}`;
349
- } else if (unmappedMetadata || parsed.hunks.length || parsed.additions.length || parsed.removals.length) {
350
- classification = "metadata-only";
351
- reason = "only comment/blank metadata changed outside symbols";
352
- } else {
353
- classification = "metadata-only";
354
- reason = "file metadata changed without textual hunks";
355
- }
356
-
357
- if (parsed.binary || parsed.deletedFile || parsed.renamed || classification === "unknown") {
358
- if (record?.fileNodeId) seedIds.push(record.fileNodeId);
359
- if (parsed.binary || parsed.deletedFile || classification === "unknown") seedIds.push(...(record?.symbols || []).map((symbol) => symbol.id));
360
- } else if (classification === "signature-changed" && !symbols.length && record?.fileNodeId) {
361
- seedIds.push(record.fileNodeId);
362
- } else if (classification === "added" && includeAddedSeeds) {
363
- if (record?.fileNodeId) seedIds.push(record.fileNodeId);
364
- seedIds.push(...symbols.map((symbol) => symbol.id));
365
- }
366
-
367
- return {
368
- path,
369
- oldPath: parsed.oldPath,
370
- newPath: parsed.newPath,
371
- classification,
372
- reason,
373
- binary: parsed.binary,
374
- renamed: parsed.renamed,
375
- addedLines: parsed.additions.length,
376
- removedLines: parsed.removals.length,
377
- symbols,
378
- seedIds: [...new Set(seedIds)].sort(),
379
- };
380
- }
381
-
382
- function unknownFile(path, indexed, reason) {
383
- const normalized = normalizePath(path);
384
- const record = indexed.get(normalized);
385
- return {
386
- path: normalized,
387
- oldPath: normalized,
388
- newPath: normalized,
389
- classification: "unknown",
390
- reason,
391
- binary: false,
392
- renamed: false,
393
- addedLines: 0,
394
- removedLines: 0,
395
- symbols: [],
396
- seedIds: [record?.fileNodeId, ...(record?.symbols || []).map((symbol) => symbol.id)].filter(Boolean).sort(),
397
- };
398
- }
399
-
400
- function classifyTestSurface(file, pathClassifier) {
401
- const explanation = pathClassifier.explain(file.path);
402
- if (!hasPathClass(explanation, "test", "e2e")) return file;
403
- const surface = explanation.classes.includes("e2e") ? "e2e" : "test";
404
- return {
405
- ...file,
406
- classification: "test-only",
407
- changeClassification: file.classification,
408
- reason: `${surface} path; excluded from the product blast-radius seed set`,
409
- pathClasses: explanation.classes,
410
- seedIds: [],
411
- };
412
- }
413
-
414
- function runGitDiff(repoRoot, base, _files, limits) {
415
- const args = ["-C", repoRoot, "diff", "--no-ext-diff", "--find-renames", "--no-color", "--unified=0", String(base), "--"];
416
- const result = spawnSync("git", args, {
417
- encoding: "utf8",
418
- windowsHide: true,
419
- timeout: 12_000,
420
- maxBuffer: limits.maxDiffBytes + 1,
421
- env: childProcessEnv(),
422
- });
423
- if (result.status === 0) return { available: true, text: String(result.stdout || ""), error: null };
424
- const oversized = result.error?.code === "ENOBUFS" || Buffer.byteLength(String(result.stdout || "")) > limits.maxDiffBytes;
425
- 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() };
426
- }
427
-
428
- const validExplicitFiles = (files) => [...new Set((Array.isArray(files) ? files : [])
429
- .map(normalizePath)
430
- .filter((file) => file && !file.startsWith("../") && !file.includes("/../") && !file.startsWith("-")))]
431
- .sort((a, b) => a.localeCompare(b));
432
-
433
74
  export function classifyChangeImpact({
434
- repoRoot = "",
435
- graph = {},
436
- base = "",
437
- diffText,
438
- files = [],
439
- includeAddedSeeds = false,
440
- limits: requestedLimits = {},
75
+ repoRoot = '', graph = {}, base = '', diffText, files = [], includeAddedSeeds = false,
76
+ limits: requestedLimits = {},
441
77
  } = {}) {
442
- const limits = limitsOf(requestedLimits);
443
- const explicitFiles = validExplicitFiles(files);
444
- let source = "provided-diff";
445
- let available = typeof diffText === "string";
446
- let text = available ? diffText : "";
447
- let unavailableReason = "";
448
- let gitOversized = false;
449
- if (!available && repoRoot && base) {
450
- source = "git-diff";
451
- const result = runGitDiff(repoRoot, base, explicitFiles, limits);
452
- available = result.available;
453
- text = result.text;
454
- unavailableReason = result.error || "";
455
- gitOversized = result.oversized === true;
456
- } else if (!available) {
457
- source = "files-only";
458
- unavailableReason = "no unified diff was provided and no repoRoot/base pair was available";
459
- }
460
-
461
- const indexed = graphIndex(graph, limits);
462
- const pathClassifier = createPathClassifier(repoRoot);
463
- const parsed = available ? parseZeroContextDiff(text, limits) : { files: [], changedLines: 0, byteLength: 0, truncated: gitOversized, oversized: gitOversized, limits };
464
- const analyzed = parsed.files.map((file) => classifyTestSurface(
465
- analyzeParsedFile(file, indexed, { includeAddedSeeds }),
466
- pathClassifier,
467
- ));
468
- const represented = new Set(analyzed.flatMap((file) => [file.oldPath, file.newPath].filter(Boolean)));
469
- for (const file of explicitFiles) {
470
- if (!represented.has(file)) analyzed.push(classifyTestSurface(
471
- unknownFile(file, indexed, available ? "explicitly changed file had no textual hunk" : unavailableReason),
472
- pathClassifier,
473
- ));
474
- }
475
- if (!available && !explicitFiles.length) analyzed.push(unknownFile("(diff unavailable)", indexed, unavailableReason));
476
- analyzed.sort((a, b) => a.path.localeCompare(b.path));
78
+ const limits = changeLimits(requestedLimits)
79
+ const explicitFiles = validExplicitFiles(files)
80
+ const input = collectDiffInput({diffText, repoRoot, base, explicitFiles, limits})
81
+ const indexed = indexChangeGraph(graph, limits)
82
+ const pathClassifier = createPathClassifier(repoRoot)
83
+ const parsed = input.available
84
+ ? parseZeroContextDiff(input.text, limits)
85
+ : {files: [], changedLines: 0, byteLength: 0, truncated: input.oversized, oversized: input.oversized, limits}
86
+ const analyzed = parsed.files.map((file) => classifyTestSurface(analyzeParsedFile(file, indexed, {includeAddedSeeds}), pathClassifier))
87
+ const represented = new Set(analyzed.flatMap((file) => [file.oldPath, file.newPath].filter(Boolean)))
88
+ for (const file of explicitFiles) if (!represented.has(file)) analyzed.push(classifyTestSurface(
89
+ unknownChangedFile(file, indexed, input.available ? 'explicitly changed file had no textual hunk' : input.reason),
90
+ pathClassifier,
91
+ ))
92
+ if (!input.available && !explicitFiles.length) analyzed.push(unknownChangedFile('(diff unavailable)', indexed, input.reason))
93
+ analyzed.sort((a, b) => a.path.localeCompare(b.path))
94
+
95
+ const incomplete = parsed.truncated || parsed.oversized || input.oversized
96
+ if (incomplete) for (const file of analyzed) {
97
+ if (file.classification === 'test-only') continue
98
+ file.classification = 'unknown'
99
+ file.reason = 'diff was truncated/oversized; symbol-level classification is incomplete'
100
+ const record = indexed.get(file.newPath) || indexed.get(file.oldPath)
101
+ file.seedIds = [...new Set([record?.fileNodeId, ...(record?.symbols || []).map((symbol) => symbol.id), ...file.seedIds].filter(Boolean))].sort()
102
+ }
477
103
 
478
- if (parsed.truncated || parsed.oversized || gitOversized) {
104
+ const seeds = uniqueChangeSeeds(analyzed.flatMap((file) => file.seedIds), limits.maxSeeds)
105
+ let verdict = 'LOW'
479
106
  for (const file of analyzed) {
480
- if (file.classification === "test-only") continue;
481
- file.classification = "unknown";
482
- file.reason = "diff was truncated/oversized; symbol-level classification is incomplete";
483
- const record = indexed.get(file.newPath) || indexed.get(file.oldPath);
484
- file.seedIds = [...new Set([record?.fileNodeId, ...(record?.symbols || []).map((symbol) => symbol.id), ...file.seedIds].filter(Boolean))].sort();
107
+ const next = ['removed', 'signature-changed', 'unknown'].includes(file.classification)
108
+ ? 'HIGH' : file.classification === 'body-changed' ? 'MEDIUM' : 'LOW'
109
+ if (VERDICT_RANK[next] > VERDICT_RANK[verdict]) verdict = next
110
+ }
111
+ if (!input.available || parsed.truncated || seeds.truncated) verdict = 'HIGH'
112
+ const counts = Object.fromEntries(Object.keys(CHANGE_CLASS_RANK)
113
+ .map((name) => [name, analyzed.filter((file) => file.classification === name).length]))
114
+ return {
115
+ ok: input.available && !parsed.truncated && !seeds.truncated,
116
+ source: input.source,
117
+ verdict,
118
+ reasons: classificationReasons(counts, {
119
+ available: input.available,
120
+ unavailableReason: input.reason,
121
+ truncated: incomplete,
122
+ includeAddedSeeds,
123
+ }),
124
+ seedIds: seeds.items,
125
+ files: analyzed,
126
+ summary: {
127
+ files: analyzed.length,
128
+ symbols: analyzed.reduce((sum, file) => sum + file.symbols.length, 0),
129
+ counts,
130
+ seeds: seeds.items.length,
131
+ totalSeedsBeforeCap: seeds.total,
132
+ },
133
+ bounds: {...limits, diffBytes: parsed.byteLength, changedLines: parsed.changedLines, truncated: !!parsed.truncated || seeds.truncated},
485
134
  }
486
- }
487
-
488
- const rawSeeds = analyzed.flatMap((file) => file.seedIds);
489
- const seeds = uniqueSorted(rawSeeds, limits.maxSeeds);
490
- let verdict = "LOW";
491
- for (const file of analyzed) {
492
- const next = ["removed", "signature-changed", "unknown"].includes(file.classification)
493
- ? "HIGH"
494
- : file.classification === "body-changed" ? "MEDIUM" : "LOW";
495
- if (VERDICT_RANK[next] > VERDICT_RANK[verdict]) verdict = next;
496
- }
497
- if (!available || parsed.truncated || seeds.truncated) verdict = "HIGH";
498
-
499
- const counts = Object.fromEntries(Object.keys(CLASS_RANK).map((name) => [name, analyzed.filter((file) => file.classification === name).length]));
500
- const reasons = [];
501
- if (!available) reasons.push(`Diff unavailable: ${unavailableReason || "unknown error"}; using conservative file/symbol seeds.`);
502
- if (parsed.truncated || parsed.oversized || gitOversized) reasons.push("Diff exceeded a safety bound; incomplete evidence is classified HIGH/unknown.");
503
- 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.`);
504
- if (counts["body-changed"]) reasons.push(`${counts["body-changed"]} file(s) contain mapped executable body changes.`);
505
- if (counts.added && !includeAddedSeeds) reasons.push(`${counts.added} purely additive file change(s) create no dependent seeds by default.`);
506
- if (counts["metadata-only"]) reasons.push(`${counts["metadata-only"]} metadata-only file change(s) create no dependent seeds.`);
507
- if (counts["test-only"]) reasons.push(`${counts["test-only"]} test-only file change(s) are labelled explicitly and create no product blast-radius seeds.`);
508
- if (counts.unknown) reasons.push(`${counts.unknown} file change(s) remain unknown and are seeded conservatively.`);
509
- if (!reasons.length) reasons.push("No changed files were present in the supplied diff.");
510
-
511
- return {
512
- ok: available && !parsed.truncated && !seeds.truncated,
513
- source,
514
- verdict,
515
- reasons,
516
- seedIds: seeds.items,
517
- files: analyzed,
518
- summary: {
519
- files: analyzed.length,
520
- symbols: analyzed.reduce((sum, file) => sum + file.symbols.length, 0),
521
- counts,
522
- seeds: seeds.items.length,
523
- totalSeedsBeforeCap: seeds.total,
524
- },
525
- bounds: {
526
- ...limits,
527
- diffBytes: parsed.byteLength,
528
- changedLines: parsed.changedLines,
529
- truncated: !!parsed.truncated || seeds.truncated,
530
- },
531
- };
532
135
  }
533
-
534
- export { DEFAULT_LIMITS as CHANGE_CLASSIFICATION_LIMITS };