@blzsky/variant 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.
- package/LICENSE +20 -0
- package/README.md +177 -0
- package/dist/build-2LO7GD2S.js +63 -0
- package/dist/check-ZTKGGF4M.js +44 -0
- package/dist/chunk-3E5LBU3O.js +180 -0
- package/dist/chunk-4LLK2S4R.js +630 -0
- package/dist/chunk-6J4XDG44.js +14 -0
- package/dist/chunk-CXNXZLDG.js +13 -0
- package/dist/chunk-DRMASVYN.js +25 -0
- package/dist/chunk-GDSZFIHM.js +29 -0
- package/dist/chunk-ISRF5F3Q.js +68 -0
- package/dist/chunk-KSHWC2XN.js +78 -0
- package/dist/chunk-LJDDS4RR.js +91 -0
- package/dist/chunk-LXKLCLL6.js +91 -0
- package/dist/chunk-MZZOE7L4.js +26 -0
- package/dist/chunk-NSNZOH5P.js +31 -0
- package/dist/chunk-O4LZKXNZ.js +301 -0
- package/dist/chunk-RGD422OM.js +108 -0
- package/dist/chunk-RVXUCJYA.js +81 -0
- package/dist/chunk-SWPVR2DF.js +468 -0
- package/dist/chunk-V22IPJ7A.js +64 -0
- package/dist/chunk-V5CTGXMM.js +256 -0
- package/dist/chunk-VTJ67TT5.js +238 -0
- package/dist/chunk-XTYVVUWW.js +279 -0
- package/dist/chunk-XUBWCTJZ.js +100 -0
- package/dist/chunk-YYGCSKIQ.js +29 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +225 -0
- package/dist/diff-GFI6RAEA.js +60 -0
- package/dist/doctor-GH4RZVEH.js +127 -0
- package/dist/env-QORDD6FQ.js +24 -0
- package/dist/impact-AF453PLK.js +702 -0
- package/dist/index.d.ts +56 -0
- package/dist/index.js +7 -0
- package/dist/init-KJIUNGAN.js +158 -0
- package/dist/insight-DZ5NQY6D.js +29 -0
- package/dist/pr-UFZRZX6S.js +152 -0
- package/dist/run-6PNH4QRA.js +40 -0
- package/dist/symbol-graph-YYUEMZGR.js +22 -0
- package/dist/tsconfig-paths-E3UEDGXZ.js +10 -0
- package/dist/workspace-X5FUBTY3.js +186 -0
- package/package.json +86 -0
|
@@ -0,0 +1,702 @@
|
|
|
1
|
+
import {
|
|
2
|
+
mapLimit
|
|
3
|
+
} from "./chunk-GDSZFIHM.js";
|
|
4
|
+
import {
|
|
5
|
+
getChangedFiles
|
|
6
|
+
} from "./chunk-MZZOE7L4.js";
|
|
7
|
+
import {
|
|
8
|
+
buildImportGraph,
|
|
9
|
+
computeAffectedFiles
|
|
10
|
+
} from "./chunk-XTYVVUWW.js";
|
|
11
|
+
import {
|
|
12
|
+
loadPackageGraph
|
|
13
|
+
} from "./chunk-3E5LBU3O.js";
|
|
14
|
+
import {
|
|
15
|
+
readPathAliases
|
|
16
|
+
} from "./chunk-KSHWC2XN.js";
|
|
17
|
+
import {
|
|
18
|
+
updateSymbolGraph
|
|
19
|
+
} from "./chunk-VTJ67TT5.js";
|
|
20
|
+
import {
|
|
21
|
+
ImpactReportError
|
|
22
|
+
} from "./chunk-RVXUCJYA.js";
|
|
23
|
+
import {
|
|
24
|
+
deriveVerdict,
|
|
25
|
+
verdictText
|
|
26
|
+
} from "./chunk-DRMASVYN.js";
|
|
27
|
+
import {
|
|
28
|
+
createClassifier
|
|
29
|
+
} from "./chunk-LJDDS4RR.js";
|
|
30
|
+
import {
|
|
31
|
+
readFileAtRef,
|
|
32
|
+
readFilesAtRef,
|
|
33
|
+
readHeadSha
|
|
34
|
+
} from "./chunk-XUBWCTJZ.js";
|
|
35
|
+
import {
|
|
36
|
+
classificationLabel,
|
|
37
|
+
plural
|
|
38
|
+
} from "./chunk-CXNXZLDG.js";
|
|
39
|
+
import {
|
|
40
|
+
lines
|
|
41
|
+
} from "./chunk-6J4XDG44.js";
|
|
42
|
+
import {
|
|
43
|
+
getPrinter
|
|
44
|
+
} from "./chunk-V22IPJ7A.js";
|
|
45
|
+
import "./chunk-O4LZKXNZ.js";
|
|
46
|
+
|
|
47
|
+
// src/core/history/impact-log.ts
|
|
48
|
+
import { mkdir, readFile, writeFile } from "fs/promises";
|
|
49
|
+
import path from "path";
|
|
50
|
+
var MAX_IMPACT_RECORDS = 1e3;
|
|
51
|
+
var LOG_FILENAME = "impact.jsonl";
|
|
52
|
+
var RECONCILIATION_FILENAME = "reconciliation.jsonl";
|
|
53
|
+
function defaultHistoryDir(cwd) {
|
|
54
|
+
return path.join(cwd, ".variant", "history");
|
|
55
|
+
}
|
|
56
|
+
async function appendImpactPrediction(historyDir, prediction) {
|
|
57
|
+
return appendRecord(historyDir, LOG_FILENAME, prediction);
|
|
58
|
+
}
|
|
59
|
+
async function appendReconciliation(historyDir, reconciliation) {
|
|
60
|
+
return appendRecord(historyDir, RECONCILIATION_FILENAME, reconciliation);
|
|
61
|
+
}
|
|
62
|
+
async function appendRecord(historyDir, filename, record) {
|
|
63
|
+
try {
|
|
64
|
+
await mkdir(historyDir, { recursive: true });
|
|
65
|
+
const file = path.join(historyDir, filename);
|
|
66
|
+
let lines2 = [];
|
|
67
|
+
try {
|
|
68
|
+
lines2 = (await readFile(file, "utf8")).split("\n").filter((l) => l.trim() !== "");
|
|
69
|
+
} catch {
|
|
70
|
+
}
|
|
71
|
+
lines2.push(JSON.stringify(record));
|
|
72
|
+
if (lines2.length > MAX_IMPACT_RECORDS) {
|
|
73
|
+
lines2 = lines2.slice(-MAX_IMPACT_RECORDS);
|
|
74
|
+
}
|
|
75
|
+
await writeFile(file, `${lines2.join("\n")}
|
|
76
|
+
`);
|
|
77
|
+
return true;
|
|
78
|
+
} catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
async function readImpactPredictions(historyDir) {
|
|
83
|
+
return readRecords(historyDir, LOG_FILENAME);
|
|
84
|
+
}
|
|
85
|
+
async function readRecords(historyDir, filename) {
|
|
86
|
+
let raw;
|
|
87
|
+
try {
|
|
88
|
+
raw = await readFile(path.join(historyDir, filename), "utf8");
|
|
89
|
+
} catch {
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
92
|
+
const out = [];
|
|
93
|
+
for (const line of raw.split("\n")) {
|
|
94
|
+
const trimmed = line.trim();
|
|
95
|
+
if (trimmed === "") continue;
|
|
96
|
+
try {
|
|
97
|
+
out.push(JSON.parse(trimmed));
|
|
98
|
+
} catch {
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// src/core/semantic/blast-radius.ts
|
|
105
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
106
|
+
import path2 from "path";
|
|
107
|
+
var TS_FILE = /\.(?:ts|tsx|mts|cts)$/;
|
|
108
|
+
var CLASSIFY_CONCURRENCY = 16;
|
|
109
|
+
function isAnalyzable(file) {
|
|
110
|
+
return TS_FILE.test(file) && !file.endsWith(".d.ts");
|
|
111
|
+
}
|
|
112
|
+
async function batchedReaderFor(cwd, baseRef, relPaths) {
|
|
113
|
+
const batch = await readFilesAtRef(cwd, baseRef, relPaths);
|
|
114
|
+
if (batch === null) return (rel) => readFileAtRef(cwd, baseRef, rel);
|
|
115
|
+
return async (rel) => batch.get(rel) ?? null;
|
|
116
|
+
}
|
|
117
|
+
async function traceBlastRadius(cwd, options = {}) {
|
|
118
|
+
const baseRef = options.baseRef ?? "HEAD~1";
|
|
119
|
+
const changedFiles = options.changedFiles ?? await getChangedFiles({ cwd, baseRef });
|
|
120
|
+
if (changedFiles === null) return null;
|
|
121
|
+
const packageDirs = packageDirsFrom(cwd, options.packageGraph);
|
|
122
|
+
let importGraph = options.importGraph;
|
|
123
|
+
if (importGraph === void 0) {
|
|
124
|
+
const [{ graph: symbolGraph }, pathAliases] = await Promise.all([
|
|
125
|
+
updateSymbolGraph(
|
|
126
|
+
cwd,
|
|
127
|
+
options.graphDir === void 0 ? {} : { graphDir: options.graphDir }
|
|
128
|
+
),
|
|
129
|
+
options.pathAliases ?? readPathAliases(cwd)
|
|
130
|
+
]);
|
|
131
|
+
importGraph = buildImportGraph(symbolGraph, {
|
|
132
|
+
packageDirs,
|
|
133
|
+
pathAliases,
|
|
134
|
+
packageExports: packageExportsFrom(options.packageGraph)
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
const files = changedFiles.map(toPosix).sort();
|
|
138
|
+
const analyzable = files.filter(isAnalyzable);
|
|
139
|
+
const readBefore = options.readBefore ?? await batchedReaderFor(cwd, baseRef, analyzable);
|
|
140
|
+
const readAfter = options.readAfter ?? (async (rel) => {
|
|
141
|
+
try {
|
|
142
|
+
return await readFile2(path2.join(cwd, rel), "utf8");
|
|
143
|
+
} catch {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
const classify = createClassifier();
|
|
148
|
+
const changed = await mapLimit(files, CLASSIFY_CONCURRENCY, async (file) => {
|
|
149
|
+
if (!isAnalyzable(file)) {
|
|
150
|
+
return {
|
|
151
|
+
filePath: file,
|
|
152
|
+
classification: "unanalyzed",
|
|
153
|
+
impactedSymbols: [],
|
|
154
|
+
propagates: false,
|
|
155
|
+
notes: [`${file}: not a TypeScript source; change not analyzed`]
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
const [before, after] = await Promise.all([
|
|
159
|
+
readBefore(file),
|
|
160
|
+
readAfter(file)
|
|
161
|
+
]);
|
|
162
|
+
return toFileImpact(
|
|
163
|
+
await classify({
|
|
164
|
+
filePath: file,
|
|
165
|
+
before: before ?? "",
|
|
166
|
+
after: after ?? ""
|
|
167
|
+
})
|
|
168
|
+
);
|
|
169
|
+
});
|
|
170
|
+
return assembleBlastRadius(baseRef, changed, importGraph, {
|
|
171
|
+
packageDirs,
|
|
172
|
+
...options.tasks === void 0 ? {} : { tasks: options.tasks }
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
function assembleBlastRadius(baseRef, changed, importGraph, options = {}) {
|
|
176
|
+
const notes = /* @__PURE__ */ new Set();
|
|
177
|
+
const affected = /* @__PURE__ */ new Set();
|
|
178
|
+
const firstHop = /* @__PURE__ */ new Set();
|
|
179
|
+
for (const impact of changed) {
|
|
180
|
+
for (const note of impact.notes) notes.add(note);
|
|
181
|
+
if (impact.classification === "non-impacting") continue;
|
|
182
|
+
affected.add(impact.filePath);
|
|
183
|
+
if (!impact.propagates) continue;
|
|
184
|
+
for (const dependent of importGraph.dependents.get(impact.filePath) ?? []) {
|
|
185
|
+
const edge = importGraph.edges.get(dependent)?.get(impact.filePath);
|
|
186
|
+
if (edge === void 0) {
|
|
187
|
+
firstHop.add(dependent);
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (edge.dynamic) {
|
|
191
|
+
notes.add(
|
|
192
|
+
`${dependent}: dynamic import of ${impact.filePath} \u2014 names unknowable`
|
|
193
|
+
);
|
|
194
|
+
firstHop.add(dependent);
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (edge.names.has("*")) {
|
|
198
|
+
firstHop.add(dependent);
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
if (impact.impactedSymbols.some((s) => edge.names.has(s))) {
|
|
202
|
+
firstHop.add(dependent);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
for (const file of computeAffectedFiles(firstHop, importGraph)) {
|
|
207
|
+
affected.add(file);
|
|
208
|
+
}
|
|
209
|
+
for (const file of affected) {
|
|
210
|
+
const fileUnresolved = importGraph.unresolved.get(file);
|
|
211
|
+
if (fileUnresolved !== void 0 && fileUnresolved.size > 0) {
|
|
212
|
+
notes.add(
|
|
213
|
+
`${file}: unresolved imports (${[...fileUnresolved].sort().join(", ")})`
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
const packageDirs = options.packageDirs ?? {};
|
|
218
|
+
const affectedPackages = /* @__PURE__ */ new Set();
|
|
219
|
+
for (const file of affected) {
|
|
220
|
+
const pkg = fileToPackage(file, packageDirs);
|
|
221
|
+
if (pkg !== void 0) affectedPackages.add(pkg);
|
|
222
|
+
}
|
|
223
|
+
const affectedTasks = [];
|
|
224
|
+
for (const taskName of Object.keys(options.tasks ?? {})) {
|
|
225
|
+
const sep = taskName.lastIndexOf(":");
|
|
226
|
+
if (sep === -1) continue;
|
|
227
|
+
if (affectedPackages.has(taskName.slice(0, sep))) {
|
|
228
|
+
affectedTasks.push(taskName);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
const noteList = [...notes].sort();
|
|
232
|
+
const confidence = Math.max(
|
|
233
|
+
0.3,
|
|
234
|
+
Math.round((1 - noteList.length * 0.1) * 100) / 100
|
|
235
|
+
);
|
|
236
|
+
return {
|
|
237
|
+
baseRef,
|
|
238
|
+
changed,
|
|
239
|
+
affectedFiles: [...affected].sort(),
|
|
240
|
+
affectedPackages: [...affectedPackages].sort(),
|
|
241
|
+
affectedTasks: affectedTasks.sort(),
|
|
242
|
+
confidence,
|
|
243
|
+
notes: noteList
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
function toFileImpact(result) {
|
|
247
|
+
const impactedSymbols = [
|
|
248
|
+
...result.exportedSymbols.removed,
|
|
249
|
+
...result.exportedSymbols.changed.filter((c) => c.kind !== "body").map((c) => c.name)
|
|
250
|
+
].sort();
|
|
251
|
+
return {
|
|
252
|
+
filePath: result.filePath,
|
|
253
|
+
classification: result.classification,
|
|
254
|
+
impactedSymbols,
|
|
255
|
+
propagates: result.classification === "breaking",
|
|
256
|
+
notes: result.confidenceNotes.map((n) => `${result.filePath}: ${n}`)
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
function packageDirsFrom(cwd, packageGraph) {
|
|
260
|
+
const out = {};
|
|
261
|
+
for (const pkg of packageGraph?.packages ?? []) {
|
|
262
|
+
out[pkg.manifest.name] = toPosix(path2.relative(cwd, pkg.dir));
|
|
263
|
+
}
|
|
264
|
+
return out;
|
|
265
|
+
}
|
|
266
|
+
function packageExportsFrom(packageGraph) {
|
|
267
|
+
const out = {};
|
|
268
|
+
for (const pkg of packageGraph?.packages ?? []) {
|
|
269
|
+
if (pkg.manifest.exports !== void 0) {
|
|
270
|
+
out[pkg.manifest.name] = pkg.manifest.exports;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return out;
|
|
274
|
+
}
|
|
275
|
+
function fileToPackage(file, packageDirs) {
|
|
276
|
+
let bestName;
|
|
277
|
+
let bestLength = -1;
|
|
278
|
+
for (const [name, dir] of Object.entries(packageDirs)) {
|
|
279
|
+
const prefix = `${dir}/`;
|
|
280
|
+
if (file.startsWith(prefix) && prefix.length > bestLength) {
|
|
281
|
+
bestName = name;
|
|
282
|
+
bestLength = prefix.length;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return bestName;
|
|
286
|
+
}
|
|
287
|
+
function toPosix(p) {
|
|
288
|
+
return p.replace(/\\/g, "/");
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// src/core/semantic/test-impact.ts
|
|
292
|
+
function defaultIsTestFile(file) {
|
|
293
|
+
return /(^|\/)__tests__\//.test(file) || /\.(test|spec)\.(ts|tsx|mts|cts)$/.test(file);
|
|
294
|
+
}
|
|
295
|
+
var TEST_CONFIG_FILE = /(^|\/)(package\.json|pnpm-lock\.yaml|package-lock\.json|yarn\.lock|tsconfig[^/]*\.json|(vitest|jest|playwright|vite)\.config\.[^/.]+(\.[^/]+)?)$/;
|
|
296
|
+
function buildTestTrace(graph, options = {}) {
|
|
297
|
+
const isTest = options.isTestFile ?? defaultIsTestFile;
|
|
298
|
+
const closures = /* @__PURE__ */ new Map();
|
|
299
|
+
for (const file of [...graph.imports.keys()].sort()) {
|
|
300
|
+
if (isTest(file)) closures.set(file, closureOf(file, graph));
|
|
301
|
+
}
|
|
302
|
+
return { closures };
|
|
303
|
+
}
|
|
304
|
+
function buildCoverageMap(trace) {
|
|
305
|
+
const testsFor = /* @__PURE__ */ new Map();
|
|
306
|
+
for (const [test, closure] of trace.closures) {
|
|
307
|
+
for (const file of closure) {
|
|
308
|
+
let tests = testsFor.get(file);
|
|
309
|
+
if (tests === void 0) {
|
|
310
|
+
tests = /* @__PURE__ */ new Set();
|
|
311
|
+
testsFor.set(file, tests);
|
|
312
|
+
}
|
|
313
|
+
tests.add(test);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return { testsFor };
|
|
317
|
+
}
|
|
318
|
+
function computeTestImpact(radius, graph, options = {}) {
|
|
319
|
+
const trace = buildTestTrace(graph, options);
|
|
320
|
+
const coverage = buildCoverageMap(trace);
|
|
321
|
+
const allTests = [...trace.closures.keys()];
|
|
322
|
+
const notes = /* @__PURE__ */ new Set();
|
|
323
|
+
let selectAll = false;
|
|
324
|
+
for (const impact of radius.changed) {
|
|
325
|
+
if (impact.classification === "unanalyzed" && TEST_CONFIG_FILE.test(impact.filePath)) {
|
|
326
|
+
selectAll = true;
|
|
327
|
+
notes.add(
|
|
328
|
+
`${impact.filePath}: build/test configuration changed \u2014 running all tests`
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
const affected = /* @__PURE__ */ new Set();
|
|
333
|
+
if (selectAll) {
|
|
334
|
+
for (const test of allTests) affected.add(test);
|
|
335
|
+
} else {
|
|
336
|
+
for (const file of radius.affectedFiles) {
|
|
337
|
+
for (const test of coverage.testsFor.get(file) ?? []) {
|
|
338
|
+
affected.add(test);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
let blindSpots = 0;
|
|
343
|
+
for (const test of affected) {
|
|
344
|
+
const closure = trace.closures.get(test);
|
|
345
|
+
if (closure === void 0) continue;
|
|
346
|
+
for (const file of closure) {
|
|
347
|
+
const unresolvedForFile = graph.unresolved.get(file);
|
|
348
|
+
if (unresolvedForFile !== void 0 && unresolvedForFile.size > 0) {
|
|
349
|
+
blindSpots++;
|
|
350
|
+
break;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
if (blindSpots > 0) {
|
|
355
|
+
notes.add(
|
|
356
|
+
`${blindSpots} selected test file(s) have unresolved imports in their closure \u2014 fixtures or assets may be missed`
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
const noteList = [...notes].sort();
|
|
360
|
+
const confidence = Math.max(
|
|
361
|
+
0.3,
|
|
362
|
+
Math.round((radius.confidence - noteList.length * 0.1) * 100) / 100
|
|
363
|
+
);
|
|
364
|
+
return {
|
|
365
|
+
affectedTests: [...affected].sort(),
|
|
366
|
+
totalTests: allTests.length,
|
|
367
|
+
selectAll,
|
|
368
|
+
confidence,
|
|
369
|
+
notes: noteList
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
async function traceTestImpact(cwd, options = {}) {
|
|
373
|
+
let importGraph = options.importGraph;
|
|
374
|
+
if (importGraph === void 0) {
|
|
375
|
+
const [{ graph: symbolGraph }, pathAliases] = await Promise.all([
|
|
376
|
+
updateSymbolGraph(
|
|
377
|
+
cwd,
|
|
378
|
+
options.graphDir === void 0 ? {} : { graphDir: options.graphDir }
|
|
379
|
+
),
|
|
380
|
+
options.pathAliases ?? readPathAliases(cwd)
|
|
381
|
+
]);
|
|
382
|
+
importGraph = buildImportGraph(symbolGraph, {
|
|
383
|
+
packageDirs: packageDirsFrom(cwd, options.packageGraph),
|
|
384
|
+
pathAliases,
|
|
385
|
+
packageExports: packageExportsFrom(options.packageGraph)
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
const radius = await traceBlastRadius(cwd, { ...options, importGraph });
|
|
389
|
+
if (radius === null) return null;
|
|
390
|
+
return {
|
|
391
|
+
radius,
|
|
392
|
+
tests: computeTestImpact(
|
|
393
|
+
radius,
|
|
394
|
+
importGraph,
|
|
395
|
+
options.isTestFile === void 0 ? {} : { isTestFile: options.isTestFile }
|
|
396
|
+
)
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
function closureOf(start, graph) {
|
|
400
|
+
const closure = /* @__PURE__ */ new Set([start]);
|
|
401
|
+
let frontier = /* @__PURE__ */ new Set([start]);
|
|
402
|
+
while (frontier.size > 0) {
|
|
403
|
+
const next = /* @__PURE__ */ new Set();
|
|
404
|
+
for (const file of frontier) {
|
|
405
|
+
for (const target of graph.imports.get(file) ?? []) {
|
|
406
|
+
if (!closure.has(target)) {
|
|
407
|
+
closure.add(target);
|
|
408
|
+
next.add(target);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
frontier = next;
|
|
413
|
+
}
|
|
414
|
+
return closure;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// src/core/impact/predict.ts
|
|
418
|
+
var DEFAULT_IMPACT_BASE_REF = "HEAD~1";
|
|
419
|
+
async function predictImpact(cwd, options = {}) {
|
|
420
|
+
const baseRef = options.base ?? DEFAULT_IMPACT_BASE_REF;
|
|
421
|
+
const packageGraph = await loadPackageGraph(cwd).catch(() => void 0);
|
|
422
|
+
const result = await traceTestImpact(cwd, {
|
|
423
|
+
baseRef,
|
|
424
|
+
...packageGraph !== void 0 && { packageGraph },
|
|
425
|
+
...options.changedFiles !== void 0 && {
|
|
426
|
+
changedFiles: options.changedFiles
|
|
427
|
+
},
|
|
428
|
+
...options.readBefore !== void 0 && { readBefore: options.readBefore }
|
|
429
|
+
});
|
|
430
|
+
if (result === null) return null;
|
|
431
|
+
const verdict = deriveVerdict(
|
|
432
|
+
result.radius.changed.map((change) => change.classification),
|
|
433
|
+
{ forceBuild: result.tests.selectAll }
|
|
434
|
+
);
|
|
435
|
+
const headSha = options.headSha !== void 0 ? options.headSha : await readHeadSha(cwd);
|
|
436
|
+
const historyLogged = await appendImpactPrediction(defaultHistoryDir(cwd), {
|
|
437
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
438
|
+
baseRef,
|
|
439
|
+
headSha,
|
|
440
|
+
changedFiles: result.radius.changed.map((change) => change.filePath),
|
|
441
|
+
affectedFiles: result.radius.affectedFiles.length,
|
|
442
|
+
affectedPackages: result.radius.affectedPackages,
|
|
443
|
+
affectedTests: result.tests.affectedTests,
|
|
444
|
+
totalTests: result.tests.totalTests,
|
|
445
|
+
selectAll: result.tests.selectAll,
|
|
446
|
+
verdict,
|
|
447
|
+
confidence: result.tests.confidence,
|
|
448
|
+
notes: [...result.radius.notes, ...result.tests.notes]
|
|
449
|
+
});
|
|
450
|
+
return { baseRef, result, verdict, historyLogged };
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// src/core/impact/verify.ts
|
|
454
|
+
import { readFile as readFile3 } from "fs/promises";
|
|
455
|
+
import path3 from "path";
|
|
456
|
+
function toRelativePosix(cwd, testPath) {
|
|
457
|
+
const relative = path3.isAbsolute(testPath) ? path3.relative(cwd, testPath) : testPath;
|
|
458
|
+
return relative.replace(/\\/g, "/");
|
|
459
|
+
}
|
|
460
|
+
function parseFailedTests(cwd, raw) {
|
|
461
|
+
let parsed;
|
|
462
|
+
try {
|
|
463
|
+
parsed = JSON.parse(raw);
|
|
464
|
+
} catch {
|
|
465
|
+
throw new ImpactReportError("it is not valid JSON");
|
|
466
|
+
}
|
|
467
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
468
|
+
throw new ImpactReportError("it is not a JSON object");
|
|
469
|
+
}
|
|
470
|
+
const results = parsed.testResults;
|
|
471
|
+
if (!Array.isArray(results)) {
|
|
472
|
+
throw new ImpactReportError("it has no `testResults` array");
|
|
473
|
+
}
|
|
474
|
+
const failed = [];
|
|
475
|
+
for (const entry of results) {
|
|
476
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
477
|
+
const { name, status } = entry;
|
|
478
|
+
if (status !== "failed" || typeof name !== "string") continue;
|
|
479
|
+
failed.push(toRelativePosix(cwd, name));
|
|
480
|
+
}
|
|
481
|
+
return [...new Set(failed)].sort();
|
|
482
|
+
}
|
|
483
|
+
function reconcile(prediction, failedTests) {
|
|
484
|
+
const selected = new Set(prediction.affectedTests);
|
|
485
|
+
const caught = [];
|
|
486
|
+
const falseSkips = [];
|
|
487
|
+
for (const test of failedTests) {
|
|
488
|
+
if (prediction.selectAll || selected.has(test)) caught.push(test);
|
|
489
|
+
else falseSkips.push(test);
|
|
490
|
+
}
|
|
491
|
+
return {
|
|
492
|
+
caught,
|
|
493
|
+
falseSkips,
|
|
494
|
+
falseSkipRate: failedTests.length === 0 ? 0 : falseSkips.length / failedTests.length
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
function selectPrediction(predictions, headSha) {
|
|
498
|
+
if (headSha !== void 0) {
|
|
499
|
+
for (let i = predictions.length - 1; i >= 0; i--) {
|
|
500
|
+
const candidate = predictions[i];
|
|
501
|
+
if (candidate?.headSha === headSha) {
|
|
502
|
+
return { prediction: candidate, matchedBy: "head-sha" };
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
return null;
|
|
506
|
+
}
|
|
507
|
+
const latest = predictions[predictions.length - 1];
|
|
508
|
+
return latest === void 0 ? null : { prediction: latest, matchedBy: "most-recent" };
|
|
509
|
+
}
|
|
510
|
+
async function verifyImpact(cwd, reportPath, options = {}) {
|
|
511
|
+
const historyDir = defaultHistoryDir(cwd);
|
|
512
|
+
const predictions = options.predictions ?? await readImpactPredictions(historyDir);
|
|
513
|
+
const match = selectPrediction(predictions, options.headSha);
|
|
514
|
+
if (match === null) return null;
|
|
515
|
+
const raw = await readFile3(path3.resolve(cwd, reportPath), "utf8");
|
|
516
|
+
const failedTests = parseFailedTests(cwd, raw);
|
|
517
|
+
const { caught, falseSkips, falseSkipRate } = reconcile(
|
|
518
|
+
match.prediction,
|
|
519
|
+
failedTests
|
|
520
|
+
);
|
|
521
|
+
const historyLogged = options.log === false ? true : await appendReconciliation(historyDir, {
|
|
522
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
523
|
+
headSha: match.prediction.headSha,
|
|
524
|
+
baseRef: match.prediction.baseRef,
|
|
525
|
+
predictedTests: match.prediction.affectedTests.length,
|
|
526
|
+
totalTests: match.prediction.totalTests,
|
|
527
|
+
failedTests: failedTests.length,
|
|
528
|
+
caught: caught.length,
|
|
529
|
+
falseSkips: falseSkips.length
|
|
530
|
+
});
|
|
531
|
+
return {
|
|
532
|
+
prediction: match.prediction,
|
|
533
|
+
matchedBy: match.matchedBy,
|
|
534
|
+
failedTests,
|
|
535
|
+
caught,
|
|
536
|
+
falseSkips,
|
|
537
|
+
falseSkipRate,
|
|
538
|
+
historyLogged
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// src/cli/render/impact.ts
|
|
543
|
+
var MAX_LISTED_FILES = 20;
|
|
544
|
+
var count = (value) => value.toLocaleString("en-US");
|
|
545
|
+
var NO_PREDICTION_MESSAGE = "impact verify: no logged prediction to reconcile against. Run `variant impact` before the test run, or pass --head-sha for a specific commit.";
|
|
546
|
+
var NO_CHANGED_FILES_MESSAGE = "impact: could not determine changed files (is this a git repository with at least one prior commit?). Pass --base <ref> against a valid ref.";
|
|
547
|
+
function renderImpactJson(report, printer = getPrinter()) {
|
|
548
|
+
const { result, ...rest } = report;
|
|
549
|
+
lines(
|
|
550
|
+
printer,
|
|
551
|
+
JSON.stringify(
|
|
552
|
+
{ ...rest, radius: result.radius, tests: result.tests },
|
|
553
|
+
null,
|
|
554
|
+
2
|
|
555
|
+
)
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
function renderImpact(report, printer = getPrinter()) {
|
|
559
|
+
const { radius, tests } = report.result;
|
|
560
|
+
lines(
|
|
561
|
+
printer,
|
|
562
|
+
"",
|
|
563
|
+
`Base ref: ${report.baseRef}`,
|
|
564
|
+
"",
|
|
565
|
+
`You changed ${plural(radius.changed.length, "file")}.`
|
|
566
|
+
);
|
|
567
|
+
for (const impact of radius.changed.slice(0, MAX_LISTED_FILES)) {
|
|
568
|
+
const symbols = impact.impactedSymbols.length > 0 ? ` (${impact.impactedSymbols.join(", ")})` : "";
|
|
569
|
+
lines(
|
|
570
|
+
printer,
|
|
571
|
+
` ${classificationLabel(impact.classification)} ${impact.filePath}${symbols}`
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
if (radius.changed.length > MAX_LISTED_FILES) {
|
|
575
|
+
lines(
|
|
576
|
+
printer,
|
|
577
|
+
` \u2026 and ${count(radius.changed.length - MAX_LISTED_FILES)} more`
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
const packages = radius.affectedPackages.length > 0 ? `, ${plural(radius.affectedPackages.length, "package")} (${radius.affectedPackages.join(", ")})` : "";
|
|
581
|
+
lines(
|
|
582
|
+
printer,
|
|
583
|
+
"",
|
|
584
|
+
`Impact: ${plural(radius.affectedFiles.length, "file")}${packages}`
|
|
585
|
+
);
|
|
586
|
+
const skipped = tests.totalTests - tests.affectedTests.length;
|
|
587
|
+
lines(
|
|
588
|
+
printer,
|
|
589
|
+
"",
|
|
590
|
+
`Run: ${plural(tests.affectedTests.length, "test file")}`,
|
|
591
|
+
`Skip: ${plural(skipped, "test file")} (of ${count(tests.totalTests)} total)`,
|
|
592
|
+
"",
|
|
593
|
+
`Verdict: ${verdictText(report.verdict)}`,
|
|
594
|
+
`Confidence: ${Math.round(tests.confidence * 100)}% (report-only \u2014 run the full suite; skipping unlocks after shadow-mode validation)`
|
|
595
|
+
);
|
|
596
|
+
const notes = [...radius.notes, ...tests.notes];
|
|
597
|
+
if (notes.length > 0) {
|
|
598
|
+
lines(printer, "", "Notes:");
|
|
599
|
+
for (const note of notes) lines(printer, ` - ${note}`);
|
|
600
|
+
}
|
|
601
|
+
if (!report.historyLogged) {
|
|
602
|
+
lines(
|
|
603
|
+
printer,
|
|
604
|
+
"",
|
|
605
|
+
"(warning: could not write .variant/history/impact.jsonl \u2014 shadow-mode logging skipped)"
|
|
606
|
+
);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
function renderImpactVerifyJson(result, printer = getPrinter()) {
|
|
610
|
+
const { prediction, ...rest } = result;
|
|
611
|
+
lines(
|
|
612
|
+
printer,
|
|
613
|
+
JSON.stringify(
|
|
614
|
+
{ ...rest, predictedAt: prediction.at, baseRef: prediction.baseRef },
|
|
615
|
+
null,
|
|
616
|
+
2
|
|
617
|
+
)
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
function renderImpactVerify(result, printer = getPrinter()) {
|
|
621
|
+
const { prediction } = result;
|
|
622
|
+
lines(
|
|
623
|
+
printer,
|
|
624
|
+
"",
|
|
625
|
+
`Prediction: ${prediction.at} (${prediction.headSha ?? "no commit recorded"})`,
|
|
626
|
+
`Matched by: ${result.matchedBy === "head-sha" ? "head SHA" : "most recent \u2014 verify this is the right run"}`,
|
|
627
|
+
"",
|
|
628
|
+
`Predicted: ${count(prediction.affectedTests.length)} of ${count(prediction.totalTests)} test files${prediction.selectAll ? " (selected all)" : ""}`,
|
|
629
|
+
`Failed: ${plural(result.failedTests.length, "test file")}`
|
|
630
|
+
);
|
|
631
|
+
if (result.failedTests.length === 0) {
|
|
632
|
+
lines(
|
|
633
|
+
printer,
|
|
634
|
+
"",
|
|
635
|
+
"No failures in this run, so it neither confirms nor refutes the prediction."
|
|
636
|
+
);
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
lines(
|
|
640
|
+
printer,
|
|
641
|
+
"",
|
|
642
|
+
`Caught: ${count(result.caught.length)} (inside the predicted set)`,
|
|
643
|
+
`False skips: ${count(result.falseSkips.length)} (would have been missed)`
|
|
644
|
+
);
|
|
645
|
+
if (result.falseSkips.length > 0) {
|
|
646
|
+
lines(printer, "", "False skips:");
|
|
647
|
+
for (const test of result.falseSkips.slice(0, MAX_LISTED_FILES)) {
|
|
648
|
+
lines(printer, ` ${test}`);
|
|
649
|
+
}
|
|
650
|
+
if (result.falseSkips.length > MAX_LISTED_FILES) {
|
|
651
|
+
lines(
|
|
652
|
+
printer,
|
|
653
|
+
` \u2026 and ${count(result.falseSkips.length - MAX_LISTED_FILES)} more`
|
|
654
|
+
);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
lines(
|
|
658
|
+
printer,
|
|
659
|
+
"",
|
|
660
|
+
`False-skip rate this run: ${(result.falseSkipRate * 100).toFixed(1)}%`,
|
|
661
|
+
"One run is not a rate. Reconcile many before reading anything into it."
|
|
662
|
+
);
|
|
663
|
+
if (!result.historyLogged) {
|
|
664
|
+
lines(
|
|
665
|
+
printer,
|
|
666
|
+
"",
|
|
667
|
+
"(warning: could not write .variant/history/reconciliation.jsonl)"
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// src/cli/commands/impact.ts
|
|
673
|
+
async function registerImpactAction(opts = {}) {
|
|
674
|
+
const report = await predictImpact(process.cwd(), opts);
|
|
675
|
+
if (report === null) {
|
|
676
|
+
lines(getPrinter(), NO_CHANGED_FILES_MESSAGE);
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
if (opts.json === true) {
|
|
680
|
+
renderImpactJson(report);
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
renderImpact(report);
|
|
684
|
+
}
|
|
685
|
+
async function registerImpactVerifyAction(reportPath, opts = {}) {
|
|
686
|
+
const result = await verifyImpact(process.cwd(), reportPath, {
|
|
687
|
+
...opts.headSha !== void 0 && { headSha: opts.headSha }
|
|
688
|
+
});
|
|
689
|
+
if (result === null) {
|
|
690
|
+
lines(getPrinter(), NO_PREDICTION_MESSAGE);
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
if (opts.json === true) {
|
|
694
|
+
renderImpactVerifyJson(result);
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
renderImpactVerify(result);
|
|
698
|
+
}
|
|
699
|
+
export {
|
|
700
|
+
registerImpactAction,
|
|
701
|
+
registerImpactVerifyAction
|
|
702
|
+
};
|