@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
@@ -0,0 +1,707 @@
1
+ import {
2
+ packageManagerOf
3
+ } from "./chunk-SGP5JX6I.js";
4
+ import {
5
+ PROSE_FILE,
6
+ analyzeImpact,
7
+ defaultIsTestFile,
8
+ deriveVerdict,
9
+ invalidatesAllTests,
10
+ isJavaScriptTestFile,
11
+ packageRootOf,
12
+ verdictText
13
+ } from "./chunk-KCEGA56S.js";
14
+ import {
15
+ computeAffectedFiles,
16
+ importersOfUnindexed
17
+ } from "./chunk-IFFZ46YS.js";
18
+ import "./chunk-GDSZFIHM.js";
19
+ import {
20
+ classificationLabel
21
+ } from "./chunk-CXNXZLDG.js";
22
+ import "./chunk-DMDAJPFJ.js";
23
+ import "./chunk-3E5LBU3O.js";
24
+ import {
25
+ changeBaseDeps,
26
+ resolvePullRequestBase
27
+ } from "./chunk-H5C4SE73.js";
28
+ import "./chunk-X3VVGTEV.js";
29
+ import "./chunk-NFFXYYM6.js";
30
+ import {
31
+ classifyFileAgainstRef
32
+ } from "./chunk-YII4TAKS.js";
33
+ import {
34
+ assertBaseRef
35
+ } from "./chunk-MAK3HG46.js";
36
+ import {
37
+ lines
38
+ } from "./chunk-6J4XDG44.js";
39
+ import {
40
+ getPrinter
41
+ } from "./chunk-EISTWXDW.js";
42
+ import {
43
+ listChangedFilesSinceMergeBase
44
+ } from "./chunk-RSIOYQOQ.js";
45
+ import {
46
+ isStarReexportKey
47
+ } from "./chunk-DSQDMFQ6.js";
48
+ import {
49
+ GitRefError
50
+ } from "./chunk-ACTY42RE.js";
51
+
52
+ // src/cli/commands/pr.ts
53
+ import { writeFile } from "fs/promises";
54
+ import path2 from "path";
55
+
56
+ // src/core/pr/check.ts
57
+ var DEFAULT_PR_BASE_REF = "main";
58
+ var TS_FILE = /\.tsx?$/;
59
+ async function listBranchChanges(cwd, baseRef) {
60
+ await assertBaseRef(cwd, baseRef);
61
+ const changed = await listChangedFilesSinceMergeBase(cwd, baseRef);
62
+ if (changed === null) {
63
+ throw new GitRefError(`"${baseRef}" has no merge base with HEAD`);
64
+ }
65
+ return changed;
66
+ }
67
+ async function runPrCheck(cwd, options = {}) {
68
+ const baseRef = options.changedFiles === void 0 ? await resolvePullRequestBase(options.base, changeBaseDeps(cwd)) : options.base ?? DEFAULT_PR_BASE_REF;
69
+ const changed = options.changedFiles ?? await listBranchChanges(cwd, baseRef);
70
+ const tsFiles = changed.filter((file) => TS_FILE.test(file));
71
+ const files = [];
72
+ for (const relPath of tsFiles) {
73
+ files.push(await classifyFileAgainstRef(cwd, relPath, baseRef));
74
+ }
75
+ return {
76
+ baseRef,
77
+ changedFiles: changed,
78
+ tsFilesChanged: tsFiles.length,
79
+ files,
80
+ verdict: deriveVerdict(files.map((file) => file.classification))
81
+ };
82
+ }
83
+
84
+ // src/core/plan/plan.ts
85
+ import path from "path";
86
+
87
+ // src/core/graph/import-paths.ts
88
+ function importersOf(graph, file) {
89
+ return graph.imports.has(file) ? graph.dependents.get(file) ?? [] : importersOfUnindexed(graph, file);
90
+ }
91
+ function pathsToward(graph, targets) {
92
+ const next = /* @__PURE__ */ new Map();
93
+ let frontier = [...new Set(targets)].sort();
94
+ for (const target of frontier) next.set(target, null);
95
+ while (frontier.length > 0) {
96
+ const reached = [];
97
+ for (const file of frontier) {
98
+ for (const importer of [...importersOf(graph, file)].sort()) {
99
+ if (next.has(importer)) continue;
100
+ next.set(importer, file);
101
+ reached.push(importer);
102
+ }
103
+ }
104
+ frontier = reached;
105
+ }
106
+ return (from) => {
107
+ if (!next.has(from)) return null;
108
+ const chain = [from];
109
+ let hop = next.get(from);
110
+ while (hop !== null && hop !== void 0) {
111
+ chain.push(hop);
112
+ hop = next.get(hop);
113
+ }
114
+ return chain;
115
+ };
116
+ }
117
+
118
+ // src/core/plan/checks.ts
119
+ var KINDS = [
120
+ ["typecheck", /^(?:type-?check|check-?types|tsc|types)$/],
121
+ ["build", /^build$/],
122
+ ["lint", /^lint$/],
123
+ ["e2e", /^(?:e2e|test[:-]e2e|e2e[:-]test)$/]
124
+ ];
125
+ var ORDER = {
126
+ typecheck: 0,
127
+ build: 1,
128
+ lint: 2,
129
+ e2e: 3
130
+ };
131
+ function commandFor(pkg, script, style) {
132
+ const manager = style.packageManager ?? "npm";
133
+ if (pkg.dir === "" || pkg.name === void 0) {
134
+ const prefix = pkg.dir === "" ? "" : `cd ${pkg.dir} && `;
135
+ return `${prefix}${manager} run ${script}`;
136
+ }
137
+ if (style.taskRunner === "turborepo") {
138
+ return `turbo run ${script} --filter=${pkg.name}`;
139
+ }
140
+ if (style.taskRunner === "nx") return `nx run ${pkg.name}:${script}`;
141
+ if (manager === "pnpm") return `pnpm --filter ${pkg.name} run ${script}`;
142
+ if (manager === "yarn") return `yarn workspace ${pkg.name} run ${script}`;
143
+ if (manager === "bun") return `bun run --filter ${pkg.name} ${script}`;
144
+ return `npm run ${script} --workspace ${pkg.name}`;
145
+ }
146
+ var FANS_OUT = /\b(?:turbo|nx|lerna)\b|\s(?:-r|--recursive|--filter|--workspaces|-ws)\b|\bworkspaces\s+foreach\b/;
147
+ function planChecks(packages, style, options = {}) {
148
+ const covered = /* @__PURE__ */ new Set();
149
+ const checks = [];
150
+ for (const pkg of packages) {
151
+ for (const script of Object.keys(pkg.scripts).sort()) {
152
+ const kind = KINDS.find(([, pattern]) => pattern.test(script))?.[0];
153
+ if (kind === void 0) continue;
154
+ if (options.everyPackage === true && pkg.dir === "" && FANS_OUT.test(` ${pkg.scripts[script] ?? ""}`)) {
155
+ covered.add(kind);
156
+ }
157
+ checks.push({
158
+ kind,
159
+ package: pkg.name ?? pkg.dir,
160
+ dir: pkg.dir,
161
+ script,
162
+ command: commandFor(pkg, script, style)
163
+ });
164
+ }
165
+ }
166
+ return checks.filter((check) => check.dir === "" || !covered.has(check.kind)).sort(
167
+ (a, b) => ORDER[a.kind] - ORDER[b.kind] || a.dir.localeCompare(b.dir) || a.script.localeCompare(b.script)
168
+ );
169
+ }
170
+
171
+ // src/core/plan/plan.ts
172
+ var RISK_ORDER = {
173
+ high: 0,
174
+ medium: 1,
175
+ low: 2,
176
+ unrated: 3,
177
+ none: 4
178
+ };
179
+ var HOUSEKEEPING_FILE = /(?:^|\/)(?:\.github|\.vscode|\.husky)\/|(?:^|\/)(?:\.gitignore|\.gitattributes|\.editorconfig|LICENSE[^/]*)$/;
180
+ function nearest(file, candidates) {
181
+ let best;
182
+ for (const candidate of candidates) {
183
+ const inside = candidate.dir === "" || file.startsWith(`${candidate.dir}/`);
184
+ if (inside && (best === void 0 || candidate.dir.length > best.dir.length)) {
185
+ best = candidate;
186
+ }
187
+ }
188
+ return best;
189
+ }
190
+ var END_TO_END_FILE = /\.spec\.[cm]?[jt]sx?$|(?:^|\/)e2e\//;
191
+ function runnerOf(file, roots) {
192
+ const unit = nearest(
193
+ file,
194
+ roots.filter((root) => root.runner !== "playwright")
195
+ );
196
+ const browser = nearest(
197
+ file,
198
+ roots.filter((root) => root.runner === "playwright")
199
+ );
200
+ if (browser === void 0 || !END_TO_END_FILE.test(file)) return unit;
201
+ return unit === void 0 || browser.dir.length >= unit.dir.length ? browser : unit;
202
+ }
203
+ function scriptFor(pkg, runner, file) {
204
+ const names = Object.keys(pkg.scripts).sort();
205
+ if (runner === "unknown") return names.includes("test") ? "test" : void 0;
206
+ const invoking = names.filter(
207
+ (name) => new RegExp(`\\b${runner}\\b`).test(pkg.scripts[name] ?? "")
208
+ );
209
+ const kind = /\.(integration|e2e)\./.exec(file)?.[1];
210
+ const byKind = kind === void 0 ? void 0 : invoking.find(
211
+ (name) => `${name} ${pkg.scripts[name] ?? ""}`.includes(kind)
212
+ );
213
+ if (byKind !== void 0) return byKind;
214
+ if (invoking.includes("test")) return "test";
215
+ if (invoking.length === 1) return invoking[0];
216
+ return runner !== "playwright" && invoking.length === 0 && names.includes("test") ? "test" : void 0;
217
+ }
218
+ function reachOf(file, graph) {
219
+ const seeds = graph.imports.has(file) ? /* @__PURE__ */ new Set([file]) : importersOfUnindexed(graph, file);
220
+ const reach = computeAffectedFiles(seeds, graph);
221
+ reach.delete(file);
222
+ return reach;
223
+ }
224
+ function riskOf(change, unverified, isTest) {
225
+ if (change.classification === "non-impacting") {
226
+ return { risk: "none", reason: "comments or formatting only" };
227
+ }
228
+ if (PROSE_FILE.test(change.filePath)) {
229
+ return { risk: "none", reason: "documentation" };
230
+ }
231
+ if (invalidatesAllTests(change.filePath)) {
232
+ return {
233
+ risk: "medium",
234
+ reason: "configuration every test and build runs under"
235
+ };
236
+ }
237
+ if (HOUSEKEEPING_FILE.test(change.filePath)) {
238
+ return {
239
+ risk: "unrated",
240
+ reason: "repository housekeeping, which no test can reach"
241
+ };
242
+ }
243
+ if (unverified) return { risk: "high", reason: "no test reaches it" };
244
+ if (isTest) {
245
+ return { risk: "low", reason: "a changed test, which the plan runs" };
246
+ }
247
+ if (change.additive) {
248
+ return { risk: "low", reason: "only adds exports, and tests reach it" };
249
+ }
250
+ if (change.classification === "breaking" && change.crossesInto.length > 0) {
251
+ return {
252
+ risk: "high",
253
+ reason: "its exports changed, and other packages import it"
254
+ };
255
+ }
256
+ if (change.classification === "breaking") {
257
+ return { risk: "medium", reason: "its exports changed" };
258
+ }
259
+ if (change.classification === "unanalyzed") {
260
+ return { risk: "medium", reason: "not a file variant can read" };
261
+ }
262
+ if (change.crossesInto.length > 0) {
263
+ return {
264
+ risk: "medium",
265
+ reason: "implementation changed, and other packages import it"
266
+ };
267
+ }
268
+ return { risk: "low", reason: "implementation only, and tests reach it" };
269
+ }
270
+ function assemblePlan(inputs) {
271
+ const { radius, tests, graph } = inputs;
272
+ const packageDirs = inputs.packages.map((pkg) => pkg.dir);
273
+ const nameOf = new Map(
274
+ inputs.packages.map((pkg) => [pkg.dir, pkg.name ?? pkg.dir])
275
+ );
276
+ const isTest = (file) => graph.imports.has(file) && defaultIsTestFile(file);
277
+ const unselected = new Set(tests.unselectedTests);
278
+ const unverifiedFiles = /* @__PURE__ */ new Set();
279
+ const changes = radius.changed.map((impact) => {
280
+ const reach = reachOf(impact.filePath, graph);
281
+ const home = packageRootOf(impact.filePath, packageDirs);
282
+ const crossesInto = /* @__PURE__ */ new Set();
283
+ let reaching = isTest(impact.filePath) ? 1 : 0;
284
+ for (const file of reach) {
285
+ if (isTest(file)) reaching++;
286
+ const dir = packageRootOf(file, packageDirs);
287
+ if (dir !== home) crossesInto.add(nameOf.get(dir) ?? dir);
288
+ }
289
+ const facts = {
290
+ filePath: impact.filePath,
291
+ classification: impact.classification,
292
+ symbols: impact.impactedSymbols.filter(
293
+ (name) => !isStarReexportKey(name)
294
+ ),
295
+ additive: impact.classification === "breaking" && impact.impactedSymbols.length === 0 && impact.ungated !== true,
296
+ dependents: reach.size,
297
+ crossesInto: [...crossesInto].sort(),
298
+ tests: reaching
299
+ };
300
+ const deleted = impact.classification !== "unanalyzed" && !graph.imports.has(impact.filePath);
301
+ const unverified = unselected.has(impact.filePath) || reaching === 0 && !deleted;
302
+ const scored = riskOf(facts, unverified, isTest(impact.filePath));
303
+ if (scored.risk === "high" && unverified) {
304
+ unverifiedFiles.add(impact.filePath);
305
+ }
306
+ return { ...facts, ...scored };
307
+ });
308
+ const rank = (change) => change.risk === "none" ? 3 : change.risk === "unrated" ? 2 : invalidatesAllTests(change.filePath) ? 1 : 0;
309
+ changes.sort(
310
+ (a, b) => rank(a) - rank(b) || RISK_ORDER[a.risk] - RISK_ORDER[b.risk] || a.filePath.localeCompare(b.filePath)
311
+ );
312
+ const seeds = radius.changed.filter((impact) => impact.classification !== "non-impacting").map((impact) => impact.filePath);
313
+ const chainFrom = pathsToward(graph, seeds);
314
+ const why = {};
315
+ const runs = /* @__PURE__ */ new Map();
316
+ for (const test of tests.affectedTests) {
317
+ const chain = chainFrom(test);
318
+ if (chain !== null) why[test] = chain;
319
+ const root = runnerOf(test, inputs.runnerRoots);
320
+ const owner = nearest(test, inputs.packages);
321
+ const script = owner === void 0 ? void 0 : scriptFor(owner, root?.runner ?? "unknown", test);
322
+ const run = {
323
+ runner: root?.runner ?? "unknown",
324
+ dir: root?.dir ?? "",
325
+ files: [],
326
+ ...owner !== void 0 && script !== void 0 && {
327
+ command: commandFor(owner, script, inputs.style)
328
+ }
329
+ };
330
+ const key = `${run.runner}\0${run.dir}\0${run.command ?? ""}`;
331
+ const existing = runs.get(key) ?? run;
332
+ existing.files.push(test);
333
+ runs.set(key, existing);
334
+ }
335
+ const widened = [];
336
+ for (const impact of radius.changed) {
337
+ if (invalidatesAllTests(impact.filePath)) {
338
+ widened.push(
339
+ `${impact.filePath} changed: every test and every package's checks are in the plan, because it configures all of them`
340
+ );
341
+ } else if (impact.ungated === true || impact.impactedSymbols.some(isStarReexportKey)) {
342
+ widened.push(
343
+ `${impact.filePath}: every file importing it is affected, whichever names it takes, because the file does not parse or re-exports with \`export *\``
344
+ );
345
+ }
346
+ }
347
+ const affectedDirs = new Set(
348
+ radius.affectedFiles.map((file) => packageRootOf(file, packageDirs))
349
+ );
350
+ const checked = inputs.packages.filter(
351
+ (pkg) => tests.selectAll || affectedDirs.has(pkg.dir)
352
+ );
353
+ const notVerified = radius.changed.filter((impact) => unverifiedFiles.has(impact.filePath)).map((impact) => {
354
+ const direct = graph.imports.has(impact.filePath) ? graph.dependents.get(impact.filePath) ?? [] : importersOfUnindexed(graph, impact.filePath);
355
+ return {
356
+ filePath: impact.filePath,
357
+ symbols: impact.impactedSymbols.filter(
358
+ (name) => !isStarReexportKey(name)
359
+ ),
360
+ usedBy: [...direct].sort(),
361
+ reason: isJavaScriptTestFile(impact.filePath) ? "a changed test in JavaScript, which variant does not index: run it yourself" : "no test reaches it: it is untested, or used in a way variant cannot follow"
362
+ };
363
+ });
364
+ return {
365
+ baseRef: inputs.baseRef,
366
+ baseLabel: inputs.baseLabel,
367
+ changes,
368
+ tests: {
369
+ selected: tests.affectedTests.length,
370
+ total: tests.totalTests,
371
+ all: tests.selectAll,
372
+ runs: [...runs.values()].sort(
373
+ (a, b) => a.dir.localeCompare(b.dir) || a.runner.localeCompare(b.runner)
374
+ ),
375
+ why
376
+ },
377
+ checks: planChecks(checked, inputs.style, {
378
+ everyPackage: tests.selectAll
379
+ }),
380
+ widened,
381
+ notVerified,
382
+ resolution: tests.resolution,
383
+ notes: [...radius.notes, ...tests.notes],
384
+ repositoryNotes: [...radius.repositoryNotes, ...tests.repositoryNotes]
385
+ };
386
+ }
387
+ var RUNNER_CONFIGS = [
388
+ // `vitest.integration.config.ts` is as much a Vitest config as the default.
389
+ [/^vitest(?:\.[\w-]+)?\.(?:config|workspace|projects)\./, "vitest"],
390
+ [/^jest(?:\.[\w-]+)?\.config\./, "jest"],
391
+ [/^playwright(?:\.[\w-]+)?\.config\./, "playwright"]
392
+ ];
393
+ async function findRunnerRoots(cwd) {
394
+ const fg = (await import("fast-glob")).default;
395
+ const found = await fg(
396
+ [
397
+ "**/vitest*.{config,workspace,projects}.*",
398
+ "**/jest*.config.*",
399
+ "**/playwright*.config.*"
400
+ ],
401
+ {
402
+ cwd,
403
+ onlyFiles: true,
404
+ ignore: ["**/node_modules/**", "**/dist/**", ".git/**", ".variant/**"]
405
+ }
406
+ );
407
+ const roots = [];
408
+ for (const file of found.map((entry) => entry.replace(/\\/g, "/")).sort()) {
409
+ const name = path.posix.basename(file);
410
+ const runner = RUNNER_CONFIGS.find(([pattern]) => pattern.test(name))?.[1];
411
+ if (runner === void 0) continue;
412
+ const dir = path.posix.dirname(file);
413
+ roots.push({ dir: dir === "." ? "" : dir, runner });
414
+ }
415
+ return roots;
416
+ }
417
+ var RUNNER_PACKAGES = [
418
+ ["vitest", "vitest"],
419
+ ["jest", "jest"],
420
+ ["@playwright/test", "playwright"]
421
+ ];
422
+ function runnersDeclared(dir, manifest) {
423
+ const declared = { ...manifest.dependencies, ...manifest.devDependencies };
424
+ return RUNNER_PACKAGES.filter(([name]) => name in declared).map(
425
+ ([, runner]) => ({ dir, runner })
426
+ );
427
+ }
428
+ async function taskRunnerOf(cwd) {
429
+ const { access } = await import("fs/promises");
430
+ const exists = (file) => access(path.join(cwd, file)).then(
431
+ () => true,
432
+ () => false
433
+ );
434
+ if (await exists("turbo.json")) return "turborepo";
435
+ return await exists("nx.json") ? "nx" : null;
436
+ }
437
+ async function readRootPackage(cwd) {
438
+ const { readFile } = await import("fs/promises");
439
+ try {
440
+ const manifest = JSON.parse(
441
+ await readFile(path.join(cwd, "package.json"), "utf8")
442
+ );
443
+ return {
444
+ scripts: {
445
+ name: manifest.name,
446
+ dir: "",
447
+ scripts: manifest.scripts ?? {}
448
+ },
449
+ runners: runnersDeclared("", manifest)
450
+ };
451
+ } catch {
452
+ return null;
453
+ }
454
+ }
455
+ async function buildVerificationPlan(cwd, options = {}) {
456
+ const [{ base, result, packageGraph }, runnerRoots, root, manager, runner] = await Promise.all([
457
+ analyzeImpact(cwd, options),
458
+ findRunnerRoots(cwd),
459
+ readRootPackage(cwd),
460
+ packageManagerOf(cwd),
461
+ taskRunnerOf(cwd)
462
+ ]);
463
+ const style = { packageManager: manager, taskRunner: runner };
464
+ const packages = root === null ? [] : [root.scripts];
465
+ const declared = [...root?.runners ?? []];
466
+ for (const pkg of packageGraph?.packages ?? []) {
467
+ const dir = path.relative(cwd, pkg.dir).replace(/\\/g, "/");
468
+ if (dir === "") continue;
469
+ packages.push({
470
+ name: pkg.manifest.name,
471
+ dir,
472
+ scripts: pkg.manifest.scripts ?? {}
473
+ });
474
+ declared.push(...runnersDeclared(dir, pkg.manifest));
475
+ }
476
+ return assemblePlan({
477
+ baseRef: base.ref,
478
+ baseLabel: base.label,
479
+ radius: result.radius,
480
+ tests: result.tests,
481
+ graph: result.graph,
482
+ packages,
483
+ runnerRoots: [...runnerRoots, ...declared],
484
+ style
485
+ });
486
+ }
487
+
488
+ // src/core/pr/report.ts
489
+ async function buildPrReport(cwd, options = {}) {
490
+ const check = await runPrCheck(cwd, options);
491
+ const plan = await buildVerificationPlan(cwd, {
492
+ base: check.baseRef,
493
+ changedFiles: check.changedFiles
494
+ });
495
+ return { generatedAt: (/* @__PURE__ */ new Date()).toISOString(), check, plan };
496
+ }
497
+ function formatPrReportJson(report) {
498
+ return JSON.stringify(report, null, 2);
499
+ }
500
+ var MAX_ROWS = 25;
501
+ var MAX_INLINE = 5;
502
+ var count = (value, noun) => `${value} ${noun}${value === 1 ? "" : "s"}`;
503
+ var code = (text) => `\`${text}\``;
504
+ function inline(items) {
505
+ const shown = items.slice(0, MAX_INLINE).map(code).join(", ");
506
+ return items.length > MAX_INLINE ? `${shown} and ${items.length - MAX_INLINE} more` : shown;
507
+ }
508
+ function capped(rows, noun) {
509
+ if (rows.length <= MAX_ROWS) return [...rows];
510
+ return [
511
+ ...rows.slice(0, MAX_ROWS),
512
+ `_\u2026and ${count(rows.length - MAX_ROWS, noun)} more, in the JSON report._`
513
+ ];
514
+ }
515
+ var RUNNER_NAME = {
516
+ vitest: "Vitest",
517
+ jest: "Jest",
518
+ playwright: "Playwright",
519
+ unknown: "Tests"
520
+ };
521
+ function changeRows(plan) {
522
+ return plan.changes.map((change) => {
523
+ const kind = change.additive ? "new exports" : change.classification;
524
+ const what = change.symbols.length > 0 ? `${kind}: ${inline(change.symbols)}` : kind;
525
+ const reach = [count(change.dependents, "file")];
526
+ if (change.crossesInto.length > 0) {
527
+ reach.push(`into ${inline(change.crossesInto)}`);
528
+ }
529
+ reach.push(count(change.tests, "test file"));
530
+ const reached = change.dependents === 0 && change.tests === 0 && change.risk !== "high" ? "\u2014" : reach.join(", ");
531
+ return `| **${change.risk}** | ${code(change.filePath)} | ${what} | ${reached} | ${change.reason} |`;
532
+ });
533
+ }
534
+ function testLines(plan) {
535
+ const { tests } = plan;
536
+ if (tests.total === 0) {
537
+ return ["**Tests:** variant found no TypeScript test files here.", ""];
538
+ }
539
+ const lines2 = [
540
+ tests.all ? `**Tests:** all ${count(tests.total, "test file")}.` : `**Tests:** ${tests.selected} of ${count(tests.total, "test file")}.`,
541
+ ""
542
+ ];
543
+ for (const run of tests.runs) {
544
+ const where = run.dir === "" ? "" : ` in ${code(run.dir)}`;
545
+ const how = run.command === void 0 ? "" : `, run by ${code(run.command)}`;
546
+ lines2.push(
547
+ `- ${RUNNER_NAME[run.runner]}${where}: ${count(run.files.length, "file")}${how}`
548
+ );
549
+ }
550
+ if (tests.runs.some((run) => run.runner === "jest")) {
551
+ lines2.push(
552
+ "",
553
+ "variant has no Jest adapter yet, so Jest runs are not compared with this plan."
554
+ );
555
+ }
556
+ if (tests.runs.length > 0) lines2.push("");
557
+ const chains = Object.values(tests.why);
558
+ if (chains.length > 0) {
559
+ lines2.push(
560
+ "<details><summary>Why these tests</summary>",
561
+ "",
562
+ "Each line is the import chain from a test to a file this change touches.",
563
+ "",
564
+ ...capped(
565
+ chains.map(
566
+ (chain) => chain.length === 1 ? `- ${code(chain[0] ?? "")} is itself changed` : `- ${chain.map(code).join(" \u2192 ")}`
567
+ ),
568
+ "test"
569
+ ),
570
+ "",
571
+ "</details>",
572
+ ""
573
+ );
574
+ }
575
+ return lines2;
576
+ }
577
+ function formatPrReportMarkdown(report) {
578
+ const { plan } = report;
579
+ const lines2 = [
580
+ "## Variant PR Report",
581
+ "",
582
+ `${count(plan.changes.length, "changed file")} against ${code(plan.baseRef)}.`,
583
+ ""
584
+ ];
585
+ if (plan.changes.length === 0) {
586
+ lines2.push("_Nothing changed against the base._", "");
587
+ return lines2.join("\n");
588
+ }
589
+ lines2.push(
590
+ "### What changed",
591
+ "",
592
+ "| Risk | File | Change | Reached by | Why |",
593
+ "|------|------|--------|------------|-----|",
594
+ ...capped(changeRows(plan), "file"),
595
+ "",
596
+ "### What to verify",
597
+ "",
598
+ ...testLines(plan)
599
+ );
600
+ if (plan.checks.length > 0) {
601
+ lines2.push(
602
+ "**Checks** in the packages this change affects:",
603
+ "",
604
+ ...capped(
605
+ plan.checks.map((check) => `- ${check.kind}: ${code(check.command)}`),
606
+ "check"
607
+ ),
608
+ ""
609
+ );
610
+ }
611
+ if (plan.widened.length > 0) {
612
+ lines2.push(
613
+ "**Wider than the import graph:**",
614
+ "",
615
+ ...plan.widened.map((reason) => `- ${reason}`),
616
+ ""
617
+ );
618
+ }
619
+ lines2.push("### Not verified", "");
620
+ if (plan.notVerified.length === 0) {
621
+ lines2.push(
622
+ plan.tests.total === 0 ? "_With no test files found, nothing here is verified by a test._" : `_Every changed source file is reached by at least one test in the plan.${plan.changes.some(
623
+ (change) => change.tests === 0 && change.risk !== "none"
624
+ ) ? " Configuration and housekeeping files are not counted: no test imports them." : ""}_`,
625
+ ""
626
+ );
627
+ } else {
628
+ lines2.push(
629
+ ...capped(
630
+ plan.notVerified.map((entry) => {
631
+ const names = entry.symbols.length > 0 ? ` (${inline(entry.symbols)})` : "";
632
+ const users = entry.usedBy.length > 0 ? ` Imported by ${inline(entry.usedBy)}.` : " Nothing variant indexes imports it.";
633
+ return `- ${code(entry.filePath)}${names}: ${entry.reason}.${users}`;
634
+ }),
635
+ "file"
636
+ ),
637
+ ""
638
+ );
639
+ }
640
+ lines2.push(
641
+ `<sub>Graph resolution: ${plan.resolution}. It says how much of this change variant could follow, not how safe the change is. Reaching a file is not the same as testing what changed in it.</sub>`,
642
+ ""
643
+ );
644
+ if (plan.notes.length > 0) {
645
+ lines2.push(
646
+ "<details><summary>Notes</summary>",
647
+ "",
648
+ ...plan.notes.map((note) => `- ${note}`),
649
+ "",
650
+ "</details>",
651
+ ""
652
+ );
653
+ }
654
+ return lines2.join("\n");
655
+ }
656
+
657
+ // src/cli/render/pr.ts
658
+ function symbolSummary(symbols) {
659
+ const changes = [];
660
+ if (symbols.added.length > 0) changes.push(`+${symbols.added.length} added`);
661
+ if (symbols.removed.length > 0) {
662
+ changes.push(`-${symbols.removed.length} removed`);
663
+ }
664
+ if (symbols.changed.length > 0) {
665
+ changes.push(`~${symbols.changed.length} changed`);
666
+ }
667
+ return changes.length > 0 ? ` (${changes.join(", ")})` : "";
668
+ }
669
+ function renderPrCheck(result, printer = getPrinter()) {
670
+ lines(
671
+ printer,
672
+ "",
673
+ `Base ref: ${result.baseRef}`,
674
+ `Changed .ts files: ${result.tsFilesChanged}`
675
+ );
676
+ if (result.files.length > 0) {
677
+ lines(printer, "", "File classifications:");
678
+ for (const file of result.files) {
679
+ lines(
680
+ printer,
681
+ ` ${classificationLabel(file.classification)} ${file.filePath}${symbolSummary(file.exportedSymbols)}`
682
+ );
683
+ }
684
+ }
685
+ lines(printer, "", `Verdict: ${verdictText(result.verdict)}`);
686
+ }
687
+
688
+ // src/cli/commands/pr.ts
689
+ async function registerPrCheckAction(opts) {
690
+ renderPrCheck(await runPrCheck(process.cwd(), opts));
691
+ }
692
+ async function registerPrReportAction(opts) {
693
+ const cwd = process.cwd();
694
+ const report = await buildPrReport(cwd, opts);
695
+ const content = opts.markdown ? formatPrReportMarkdown(report) : formatPrReportJson(report);
696
+ if (opts.output === void 0) {
697
+ lines(getPrinter(), content);
698
+ return;
699
+ }
700
+ await writeFile(path2.resolve(cwd, opts.output), content, "utf8");
701
+ const kind = opts.markdown ? "Markdown report" : "Report";
702
+ lines(getPrinter(), `${kind} written to ${opts.output}`);
703
+ }
704
+ export {
705
+ registerPrCheckAction,
706
+ registerPrReportAction
707
+ };