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