plumbbob 0.8.13 → 0.8.17

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 (93) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/dist/lib/agents.js +1 -1
  3. package/dist/lib/check.js +9 -6
  4. package/dist/lib/intent.js +20 -0
  5. package/dist/lib/orient.js +4 -3
  6. package/dist/verbs/checkpoint.js +30 -11
  7. package/dist/verbs/finish.js +24 -8
  8. package/node_modules/checkride/CHANGELOG.md +234 -0
  9. package/node_modules/checkride/README.md +134 -31
  10. package/node_modules/checkride/dist/adapters.d.ts +50 -1
  11. package/node_modules/checkride/dist/adapters.d.ts.map +1 -1
  12. package/node_modules/checkride/dist/adapters.js +123 -7
  13. package/node_modules/checkride/dist/adapters.js.map +1 -1
  14. package/node_modules/checkride/dist/agent-setup/hook.d.ts.map +1 -1
  15. package/node_modules/checkride/dist/agent-setup/hook.js +15 -1
  16. package/node_modules/checkride/dist/agent-setup/hook.js.map +1 -1
  17. package/node_modules/checkride/dist/agent-setup/index.d.ts +0 -1
  18. package/node_modules/checkride/dist/agent-setup/index.d.ts.map +1 -1
  19. package/node_modules/checkride/dist/agent-setup/index.js.map +1 -1
  20. package/node_modules/checkride/dist/baseline/fallow.d.ts +64 -0
  21. package/node_modules/checkride/dist/baseline/fallow.d.ts.map +1 -0
  22. package/node_modules/checkride/dist/baseline/fallow.js +256 -0
  23. package/node_modules/checkride/dist/baseline/fallow.js.map +1 -0
  24. package/node_modules/checkride/dist/baseline/fingerprint.d.ts +4 -6
  25. package/node_modules/checkride/dist/baseline/fingerprint.d.ts.map +1 -1
  26. package/node_modules/checkride/dist/baseline/fingerprint.js +9 -4
  27. package/node_modules/checkride/dist/baseline/fingerprint.js.map +1 -1
  28. package/node_modules/checkride/dist/baseline/index.d.ts +4 -5
  29. package/node_modules/checkride/dist/baseline/index.d.ts.map +1 -1
  30. package/node_modules/checkride/dist/baseline/index.js +2 -2
  31. package/node_modules/checkride/dist/baseline/index.js.map +1 -1
  32. package/node_modules/checkride/dist/{baseline/command.d.ts → baseline-command.d.ts} +11 -9
  33. package/node_modules/checkride/dist/baseline-command.d.ts.map +1 -0
  34. package/node_modules/checkride/dist/{baseline/command.js → baseline-command.js} +9 -7
  35. package/node_modules/checkride/dist/baseline-command.js.map +1 -0
  36. package/node_modules/checkride/dist/cli.d.ts.map +1 -1
  37. package/node_modules/checkride/dist/cli.js +95 -8
  38. package/node_modules/checkride/dist/cli.js.map +1 -1
  39. package/node_modules/checkride/dist/config.d.ts +37 -12
  40. package/node_modules/checkride/dist/config.d.ts.map +1 -1
  41. package/node_modules/checkride/dist/config.js +268 -60
  42. package/node_modules/checkride/dist/config.js.map +1 -1
  43. package/node_modules/checkride/dist/digest/digest.d.ts +0 -6
  44. package/node_modules/checkride/dist/digest/digest.d.ts.map +1 -1
  45. package/node_modules/checkride/dist/digest/digest.js +3 -3
  46. package/node_modules/checkride/dist/digest/digest.js.map +1 -1
  47. package/node_modules/checkride/dist/digest/index.d.ts +1 -2
  48. package/node_modules/checkride/dist/digest/index.d.ts.map +1 -1
  49. package/node_modules/checkride/dist/digest/index.js +1 -1
  50. package/node_modules/checkride/dist/digest/index.js.map +1 -1
  51. package/node_modules/checkride/dist/doctor.d.ts +14 -1
  52. package/node_modules/checkride/dist/doctor.d.ts.map +1 -1
  53. package/node_modules/checkride/dist/doctor.js +124 -55
  54. package/node_modules/checkride/dist/doctor.js.map +1 -1
  55. package/node_modules/checkride/dist/init.d.ts +6 -0
  56. package/node_modules/checkride/dist/init.d.ts.map +1 -1
  57. package/node_modules/checkride/dist/init.js +347 -121
  58. package/node_modules/checkride/dist/init.js.map +1 -1
  59. package/node_modules/checkride/dist/links.d.ts.map +1 -1
  60. package/node_modules/checkride/dist/links.js +8 -9
  61. package/node_modules/checkride/dist/links.js.map +1 -1
  62. package/node_modules/checkride/dist/orchestrator.d.ts +56 -1
  63. package/node_modules/checkride/dist/orchestrator.d.ts.map +1 -1
  64. package/node_modules/checkride/dist/orchestrator.js +506 -138
  65. package/node_modules/checkride/dist/orchestrator.js.map +1 -1
  66. package/node_modules/checkride/dist/pack.d.ts +76 -0
  67. package/node_modules/checkride/dist/pack.d.ts.map +1 -0
  68. package/node_modules/checkride/dist/pack.js +217 -0
  69. package/node_modules/checkride/dist/pack.js.map +1 -0
  70. package/node_modules/checkride/dist/pm/translate.d.ts +18 -10
  71. package/node_modules/checkride/dist/pm/translate.d.ts.map +1 -1
  72. package/node_modules/checkride/dist/pm/translate.js +27 -13
  73. package/node_modules/checkride/dist/pm/translate.js.map +1 -1
  74. package/node_modules/checkride/dist/smoke.d.ts +86 -0
  75. package/node_modules/checkride/dist/smoke.d.ts.map +1 -0
  76. package/node_modules/checkride/dist/smoke.js +346 -0
  77. package/node_modules/checkride/dist/smoke.js.map +1 -0
  78. package/node_modules/checkride/dist/snippets.d.ts +215 -0
  79. package/node_modules/checkride/dist/snippets.d.ts.map +1 -0
  80. package/node_modules/checkride/dist/snippets.js +360 -0
  81. package/node_modules/checkride/dist/snippets.js.map +1 -0
  82. package/node_modules/checkride/package.json +4 -2
  83. package/node_modules/checkride/schema/checkride.config.schema.json +11 -6
  84. package/package.json +3 -2
  85. package/skills/pb-build/SKILL.md +7 -0
  86. package/skills/pb-park/SKILL.md +3 -2
  87. package/skills/pb-plan/SKILL.md +30 -9
  88. package/skills/pb-refine/SKILL.md +21 -6
  89. package/skills/pb-step/SKILL.md +10 -5
  90. package/skills/pb-verify/SKILL.md +12 -1
  91. package/templates/intent.md +55 -11
  92. package/node_modules/checkride/dist/baseline/command.d.ts.map +0 -1
  93. package/node_modules/checkride/dist/baseline/command.js.map +0 -1
@@ -7,16 +7,20 @@
7
7
  * diagnostics — agents read the per-tool JSON directly.
8
8
  */
9
9
  import { spawn } from 'node:child_process';
10
- import { mkdir } from 'node:fs/promises';
10
+ import { mkdir, rm } from 'node:fs/promises';
11
+ import { cpus } from 'node:os';
11
12
  import { join } from 'node:path';
12
13
  import { performance } from 'node:perf_hooks';
13
14
  import { ADAPTERS, SCHEMA_VERSION, SLOTS } from './adapters.js';
14
15
  import { writeFileAtomic } from './atomic.js';
15
- import { applyBaseline, BASELINE_FILE, baselinesEqual, countBaselineKeys, fingerprint, loadBaseline, ratchet, writeBaseline, } from './baseline/index.js';
16
+ import { applyBaseline, BASELINE_FILE, baselinesEqual, countBaselineKeys, fallowVerdict, fingerprint, loadBaseline, ratchet, writeBaseline, } from './baseline/index.js';
16
17
  import { loadConfig, resolveChecks } from './config.js';
17
18
  import { writeDigest } from './digest/index.js';
18
19
  import { checkLinks } from './links.js';
20
+ import { checkPack } from './pack.js';
19
21
  import { detectPackageManager, isAvailableUnder, translateExec } from './pm/index.js';
22
+ import { checkSmoke } from './smoke.js';
23
+ import { checkSnippets } from './snippets.js';
20
24
  /**
21
25
  * Port of the interim `select_checks`: only/skip/opt-in selection by slot name.
22
26
  * An opt-in slot runs when `--all`/`--include` names it, or when it was
@@ -37,6 +41,25 @@ export function selectChecks(resolved, flags) {
37
41
  return true;
38
42
  });
39
43
  }
44
+ /**
45
+ * Reject unknown slot names in `--only`/`--skip`/`--include`. A typo like
46
+ * `--only lints` used to slip through `selectChecks` as a filter that matched
47
+ * nothing, silently disabling the gate — the worst kind of vacuous green in a
48
+ * definition-of-done check. It is a usage error instead (thrown here, surfaced
49
+ * as exit 2 at the CLI). The valid set is every resolved slot: the catalogue
50
+ * slots plus any config custom-check names.
51
+ */
52
+ function validateSelection(resolved, flags) {
53
+ const valid = new Set(resolved.map((r) => r.slot));
54
+ for (const flag of ['only', 'skip', 'include']) {
55
+ const unknown = (flags[flag] ?? []).filter((name) => !valid.has(name));
56
+ if (unknown.length === 0)
57
+ continue;
58
+ const named = unknown.map((n) => `'${n}'`).join(', ');
59
+ const noun = unknown.length > 1 ? 'slots' : 'slot';
60
+ throw new Error(`unknown ${noun} ${named} in --${flag}. Valid slots: ${[...valid].join(', ')}.`);
61
+ }
62
+ }
40
63
  /** Append an adapter's `changedArgs` under `--changed` (otherwise base args). */
41
64
  export function runtimeArgs(adapter, changed) {
42
65
  if (changed && adapter.changedArgs)
@@ -51,22 +74,112 @@ export function runtimeArgs(adapter, changed) {
51
74
  * disables the cap.
52
75
  */
53
76
  export const DEFAULT_TIMEOUT_SECONDS = 600;
77
+ /**
78
+ * Default pool width for a wave (equal-`order` group): `min(4, max(1, cores −
79
+ * 1))` (D5). Heavy checks (test, mutation, build) parallelize internally, so
80
+ * oversubscribing every core is worse than a conservative cap; one reserved
81
+ * core keeps the machine responsive. Override with `--concurrency`.
82
+ */
83
+ function defaultConcurrency() {
84
+ return Math.min(4, Math.max(1, cpus().length - 1));
85
+ }
54
86
  /** Grace between SIGTERM and SIGKILL when a timed-out check won't die politely. */
55
87
  const KILL_GRACE_SECONDS = 5;
88
+ /**
89
+ * Signal a spawned check's whole process group. The check is spawned
90
+ * `detached`, so it leads its own group and a negated-pid `kill` reaches every
91
+ * descendant — a wrapper's grandchildren included — not just the direct child.
92
+ * That is the difference between a timed-out `sh -c 'slow-tool …'` leaving its
93
+ * real worker orphaned and alive versus taking the whole tree down. ESRCH is
94
+ * swallowed: the group racing to exit on its own before we signal is success,
95
+ * not an error (and `pid` is absent only when the spawn itself failed).
96
+ */
97
+ function killGroup(pid, signal) {
98
+ if (pid === undefined)
99
+ return;
100
+ try {
101
+ process.kill(-pid, signal);
102
+ }
103
+ catch {
104
+ // Group already gone (raced with a clean exit) — nothing left to kill.
105
+ }
106
+ }
107
+ /**
108
+ * Process groups of checks currently in flight: pid → a promise that settles
109
+ * when that child's `close` fires. `spawnCheck` registers each spawn and
110
+ * unregisters it on close; `killLiveChecks` walks the registry to reap
111
+ * everything when the CLI takes a fatal signal.
112
+ */
113
+ const liveChecks = new Map();
114
+ /**
115
+ * One-way interrupt latch. Between `killLiveChecks` reaping the registry and
116
+ * the CLI re-raising the fatal signal, the still-running `runChecks` loop gets
117
+ * control back (the killed check's outcome resolves) and would spawn the next
118
+ * check — a fresh detached process group that outlives the dying CLI as
119
+ * exactly the orphan the cleanup exists to prevent. Once latched, `spawnCheck`
120
+ * starts nothing new.
121
+ */
122
+ let interrupted = false;
123
+ /**
124
+ * Reap every in-flight check before the process dies: SIGTERM each live
125
+ * process group (grandchildren included — see `killGroup`), escalate to
126
+ * SIGKILL after `KILL_GRACE_SECONDS` for a group that won't die politely, and
127
+ * resolve once every group has closed or been SIGKILLed. The CLI's
128
+ * SIGINT/SIGTERM handlers await this and then re-raise — cleanup first, the
129
+ * signal's default exit semantics after.
130
+ */
131
+ export async function killLiveChecks() {
132
+ interrupted = true;
133
+ await Promise.all([...liveChecks.entries()].map(async ([pid, closed]) => {
134
+ killGroup(pid, 'SIGTERM');
135
+ let escalate = null;
136
+ const grace = new Promise((resolve) => {
137
+ escalate = setTimeout(() => {
138
+ killGroup(pid, 'SIGKILL');
139
+ resolve();
140
+ }, KILL_GRACE_SECONDS * 1000);
141
+ });
142
+ await Promise.race([closed, grace]);
143
+ if (escalate !== null)
144
+ clearTimeout(escalate);
145
+ }));
146
+ }
56
147
  /**
57
148
  * Spawn one check. A falsy or non-positive `timeoutSec` means no cap (the
58
- * default cap is applied by the runner, not here). When it fires, the child
59
- * gets SIGTERM, then SIGKILL after a short grace, and a failed outcome carries
60
- * a `"timed out after Ns"` note so the slot is recorded failed with its
61
- * elapsed duration (both timers are always cleared on `close`).
149
+ * default cap is applied by the runner, not here). When it fires, the check's
150
+ * whole process group gets SIGTERM, then SIGKILL after a short grace (see
151
+ * `killGroup` — `detached` + group signal so grandchildren die too), and a
152
+ * failed outcome carries a `"timed out after Ns"` note so the slot is recorded
153
+ * failed with its elapsed duration (both timers are always cleared on `close`).
154
+ * Output is captured with an explicit UTF-8 decoder so a multibyte character
155
+ * split across two read chunks survives intact rather than decoding to U+FFFD.
156
+ * Every spawn is registered in `liveChecks` until it closes, so a fatal signal
157
+ * can reap the lot (`killLiveChecks`); after that interrupt, no new check
158
+ * starts.
62
159
  */
63
160
  function spawnCheck(command, args, cwd, timeoutSec) {
64
161
  return new Promise((resolveOutcome) => {
162
+ // Post-interrupt, the process is about to die by the re-raised signal —
163
+ // starting another detached group here would orphan it (see `interrupted`).
164
+ if (interrupted) {
165
+ resolveOutcome({ ok: false, exit_code: -1, stdout: '', stderr: 'interrupted before start\n' });
166
+ return;
167
+ }
65
168
  const proc = spawn(command, args, {
66
169
  cwd,
170
+ detached: true,
67
171
  stdio: ['ignore', 'pipe', 'pipe'],
68
172
  env: { ...process.env, FORCE_COLOR: '0', NO_COLOR: '1' },
69
173
  });
174
+ let resolveClosed = null;
175
+ if (proc.pid !== undefined) {
176
+ liveChecks.set(proc.pid, new Promise((resolve) => { resolveClosed = resolve; }));
177
+ }
178
+ const unregister = () => {
179
+ if (proc.pid !== undefined)
180
+ liveChecks.delete(proc.pid);
181
+ resolveClosed?.();
182
+ };
70
183
  let stdout = '';
71
184
  let stderr = '';
72
185
  let timedOut = false;
@@ -74,8 +187,8 @@ function spawnCheck(command, args, cwd, timeoutSec) {
74
187
  const timer = timeoutSec && timeoutSec > 0
75
188
  ? setTimeout(() => {
76
189
  timedOut = true;
77
- proc.kill('SIGTERM');
78
- killTimer = setTimeout(() => { proc.kill('SIGKILL'); }, KILL_GRACE_SECONDS * 1000);
190
+ killGroup(proc.pid, 'SIGTERM');
191
+ killTimer = setTimeout(() => { killGroup(proc.pid, 'SIGKILL'); }, KILL_GRACE_SECONDS * 1000);
79
192
  }, timeoutSec * 1000)
80
193
  : null;
81
194
  const clearTimers = () => {
@@ -84,14 +197,21 @@ function spawnCheck(command, args, cwd, timeoutSec) {
84
197
  if (killTimer)
85
198
  clearTimeout(killTimer);
86
199
  };
87
- proc.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
88
- proc.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
200
+ // A decoded string stream (not raw Buffers): the internal StringDecoder holds
201
+ // a partial multibyte sequence until the continuation bytes arrive, so `+=`
202
+ // never concatenates a half-decoded character.
203
+ proc.stdout.setEncoding('utf8');
204
+ proc.stderr.setEncoding('utf8');
205
+ proc.stdout.on('data', (chunk) => { stdout += chunk; });
206
+ proc.stderr.on('data', (chunk) => { stderr += chunk; });
89
207
  proc.on('error', (err) => {
90
208
  clearTimers();
209
+ unregister();
91
210
  resolveOutcome({ ok: false, exit_code: -1, stdout: '', stderr: `Failed to spawn: ${err.message}` });
92
211
  });
93
212
  proc.on('close', (code) => {
94
213
  clearTimers();
214
+ unregister();
95
215
  if (timedOut) {
96
216
  const note = `timed out after ${timeoutSec}s`;
97
217
  resolveOutcome({ ok: false, exit_code: -1, stdout, stderr: stderr ? `${stderr}\n${note}` : `${note}\n` });
@@ -108,6 +228,24 @@ const defaultRunner = (resolved, ctx) => {
108
228
  if (adapter.builtin === 'links')
109
229
  return checkLinks(ctx.cwd);
110
230
  const timeout = adapter.timeout ?? ctx.timeout ?? DEFAULT_TIMEOUT_SECONDS;
231
+ // The pack built-in spawns its own subprocess (the PM's pack dry-run) through
232
+ // `spawnCheck`, so its child registers in `liveChecks` and inherits the
233
+ // timeout + reaping like every other check (D16/C6).
234
+ if (adapter.builtin === 'pack')
235
+ return checkPack({ cwd: ctx.cwd, pm: ctx.pm, spawn: spawnCheck, timeoutSec: timeout });
236
+ // The smoke built-in spawns a `node` probe of the built package through
237
+ // `spawnCheck`, so that child registers in `liveChecks` and inherits the
238
+ // timeout + reaping like every other check (D9/D16/C6).
239
+ if (adapter.builtin === 'smoke')
240
+ return checkSmoke({ cwd: ctx.cwd, spawn: spawnCheck, timeoutSec: timeout });
241
+ // The snippets built-ins spawn `<pm> exec tsc` through `spawnCheck` (D16/C6);
242
+ // the two adapters share one execution path, differing only in mode (D12).
243
+ if (adapter.builtin === 'snippets') {
244
+ return checkSnippets({ cwd: ctx.cwd, mode: 'src', pm: ctx.pm, spawn: spawnCheck, timeoutSec: timeout });
245
+ }
246
+ if (adapter.builtin === 'snippets-dist') {
247
+ return checkSnippets({ cwd: ctx.cwd, mode: 'dist', pm: ctx.pm, spawn: spawnCheck, timeoutSec: timeout });
248
+ }
111
249
  const { command, args } = translateExec(adapter.command, runtimeArgs(adapter, ctx.changed), ctx.pm);
112
250
  return spawnCheck(command, args, ctx.cwd, timeout);
113
251
  };
@@ -129,6 +267,26 @@ async function persistOutput(cwd, adapter, outcome) {
129
267
  if (outcome.stderr.trim())
130
268
  await writeFileAtomic(join(dir, `${adapter.slot}.stderr.txt`), outcome.stderr);
131
269
  }
270
+ /**
271
+ * Remove a slot's prior `.check/` artifacts before it re-runs, so a later clean
272
+ * run that emits nothing — or a different stream/form than last time — can't leave
273
+ * the previous run's output lingering as authoritative. Covers everything
274
+ * `persistOutput` writes (`<slot>.stdout.txt`, `<slot>.stderr.txt`, the adapter's
275
+ * JSON `outputFile`) plus the conventional `<slot>.json` a tool may write itself
276
+ * (e.g. vitest's `--outputFile=.check/test.json`, where `outputFile` is null).
277
+ * Runs *before* the check so this run's own tool-written artifacts survive.
278
+ */
279
+ async function clearSlotOutputs(cwd, adapter) {
280
+ const dir = join(cwd, '.check');
281
+ const names = new Set([
282
+ `${adapter.slot}.stdout.txt`,
283
+ `${adapter.slot}.stderr.txt`,
284
+ `${adapter.slot}.json`,
285
+ ]);
286
+ if (adapter.outputFile)
287
+ names.add(adapter.outputFile);
288
+ await Promise.all([...names].map((f) => rm(join(dir, f), { force: true })));
289
+ }
132
290
  function writeLine(out, line) {
133
291
  out.write(`${line}\n`);
134
292
  }
@@ -151,13 +309,19 @@ function skippedEntry(resolved) {
151
309
  output_file: resolved.adapter?.outputFile ?? null,
152
310
  };
153
311
  }
154
- function buildSummary(checks) {
312
+ /**
313
+ * `total_duration_ms` is the wall-clock span of the whole execution phase (D7),
314
+ * not the sum of per-check durations — under concurrency those diverge, and
315
+ * wall-clock is what the field honestly means. It equals the old sum whenever
316
+ * execution is sequential (one check in flight at a time).
317
+ */
318
+ function buildSummary(checks, totalDurationMs) {
155
319
  return {
156
320
  schema_version: SCHEMA_VERSION,
157
321
  timestamp: new Date().toISOString(),
158
322
  ok: checks.every((c) => c.ok),
159
323
  checks_run: checks.filter((c) => !c.skipped).length,
160
- total_duration_ms: checks.reduce((sum, c) => sum + c.duration_ms, 0),
324
+ total_duration_ms: totalDurationMs,
161
325
  checks,
162
326
  };
163
327
  }
@@ -188,138 +352,330 @@ function warnVacuous(stderr, checks, adapters) {
188
352
  writeLine(stderr, ' Run `checkride doctor` for per-slot detail, or add a custom check to');
189
353
  writeLine(stderr, ' checkride.config.json. Gates should run with --strict (exit 2 on zero checks).');
190
354
  }
191
- /** Run the selected checks against `cwd`, persist output, write the summary. */
192
- export async function runChecks(options) {
355
+ /**
356
+ * Apply the defaults every command shares: cwd, the slot/adapter catalogues, the
357
+ * config (loaded from `cwd` unless injected), and the two streams. Extracted so
358
+ * `runChecks`/`runFix`/`runDoctor` resolve this identical block one way (it was
359
+ * copy-pasted across all three).
360
+ */
361
+ export function resolveCommonOptions(options) {
193
362
  const cwd = options.cwd ?? process.cwd();
194
- const slots = options.slots ?? SLOTS;
195
- const adapters = options.adapters ?? ADAPTERS;
196
- const config = options.config !== undefined ? options.config : loadConfig(cwd);
197
- const stderr = options.stderr ?? process.stderr;
198
- const stdout = options.stdout ?? process.stdout;
199
- const runner = options.runner ?? defaultRunner;
200
- const json = options.json ?? false;
201
- const bail = options.bail ?? false;
202
- const changed = options.changed ?? false;
203
- const timeout = config?.timeout;
204
- const pm = options.pm ?? detectPackageManager({ cwd });
205
- const baseline = options.baseline !== undefined ? options.baseline : loadBaseline(cwd);
206
- await mkdir(join(cwd, '.check'), { recursive: true });
207
- const resolved = resolveChecks({ slots, adapters, config, cwd });
208
- const selected = selectChecks(resolved, options);
363
+ return {
364
+ cwd,
365
+ slots: options.slots ?? SLOTS,
366
+ adapters: options.adapters ?? ADAPTERS,
367
+ config: options.config !== undefined ? options.config : loadConfig(cwd),
368
+ stdout: options.stdout ?? process.stdout,
369
+ stderr: options.stderr ?? process.stderr,
370
+ };
371
+ }
372
+ /** Apply every run default: the common options plus runner, flags, PM, and baseline. */
373
+ function resolveRunContext(options) {
374
+ const common = resolveCommonOptions(options);
375
+ return {
376
+ ...common,
377
+ runner: options.runner ?? defaultRunner,
378
+ json: options.json ?? false,
379
+ bail: options.bail ?? false,
380
+ changed: options.changed ?? false,
381
+ timeout: common.config?.timeout,
382
+ pm: options.pm ?? detectPackageManager({ cwd: common.cwd }),
383
+ baseline: options.baseline !== undefined ? options.baseline : loadBaseline(common.cwd),
384
+ concurrency: Math.max(1, Math.floor(options.concurrency ?? defaultConcurrency())),
385
+ };
386
+ }
387
+ /**
388
+ * Baseline-aware verdict for one slot's outcome. fallow slots derive pass/fail
389
+ * from the parsed report (its exit code doesn't reliably gate); everything else
390
+ * masks the adapter's fingerprint. `observed` is non-null only when the run's
391
+ * findings could be read — the ratchet must never prune from an unreadable run.
392
+ */
393
+ function maskOutcome(adapter, outcome, baseline, slot) {
394
+ if (adapter.gate === 'fallow') {
395
+ const v = fallowVerdict(outcome.stdout, baseline ? (baseline.slots[slot] ?? []) : null);
396
+ return { ok: v.ok, baselined: v.baselined, newKeys: v.newKeys, reason: v.reason, observed: v.observed ? v.findings : null };
397
+ }
398
+ const current = baseline ? fingerprint(adapter.name, outcome.stdout) : null;
399
+ if (baseline && current !== null) {
400
+ const adj = applyBaseline(current, baseline.slots[slot] ?? [], outcome.ok);
401
+ return { ok: adj.ok, baselined: adj.baselined, newKeys: adj.newKeys, reason: null, observed: current };
402
+ }
403
+ return { ok: outcome.ok, baselined: 0, newKeys: [], reason: null, observed: null };
404
+ }
405
+ /** Build (and print) the skipped-entry row for a slot that won't run this pass. */
406
+ function handleSkip(r, unavailable, pm, json, stderr) {
407
+ const entry = skippedEntry(unavailable ? { ...r, skip: `'${r.adapter?.command} ${r.adapter?.args[0]}' is unavailable under ${pm}` } : r);
209
408
  if (!json)
210
- writeLine(stderr, `\nRunning ${selected.length} check(s)...\n`);
211
- const checks = [];
212
- const runs = [];
213
- // Per-slot current fingerprints for slots actually observed this run, and
214
- // whether `--bail` cut the loop short — both feed the ratchet's full-run gate.
215
- const observed = new Map();
216
- let brokeEarly = false;
217
- for (const r of selected) {
218
- // Skip when unresolved, or when the adapter can't run under this PM — e.g.
219
- // `pnpm audit` (the `security` slot) is unavailable off pnpm (b5).
220
- const unavailable = r.adapter && !isAvailableUnder(r.adapter.command, r.adapter.args, pm);
221
- if (r.skip || !r.adapter || unavailable) {
222
- const entry = skippedEntry(unavailable ? { ...r, skip: `'${r.adapter?.command} ${r.adapter?.args[0]}' is unavailable under ${pm}` } : r);
223
- checks.push(entry);
224
- if (!json)
225
- writeLine(stderr, ` ○ ${entry.name.padEnd(8)} skip ${entry.reason ?? ''}`);
226
- continue;
227
- }
228
- const adapter = r.adapter;
229
- if (!json)
230
- writeLine(stderr, ` ▸ ${r.slot} ${adapter.description}`);
231
- const start = performance.now();
232
- const outcome = await runner(r, { cwd, changed, pm, ...(timeout !== undefined ? { timeout } : {}) });
233
- const duration_ms = Math.round(performance.now() - start);
234
- runs.push({ slot: r.slot, adapter, outcome });
235
- await persistOutput(cwd, adapter, outcome);
236
- // Baseline-aware: subtract this slot's grandfathered diagnostics. Masking is
237
- // always on (even under a partial run); only the ratchet below is gated. The
238
- // raw `.check/<slot>.json` is already persisted untouched — masking changes
239
- // the pass/fail verdict, never the authoritative output.
240
- let ok = outcome.ok;
241
- let baselined = 0;
242
- let newKeys = [];
243
- const current = baseline ? fingerprint(adapter.name, outcome.stdout) : null;
244
- if (baseline && current !== null) {
245
- observed.set(r.slot, current);
246
- const adj = applyBaseline(current, baseline.slots[r.slot] ?? [], outcome.ok);
247
- ({ ok, baselined, newKeys } = adj);
248
- }
249
- const entry = {
250
- name: r.slot,
251
- adapter: adapter.name,
252
- description: adapter.description,
253
- ok,
254
- exit_code: outcome.exit_code,
255
- duration_ms,
256
- output_file: adapter.outputFile,
257
- ...(baselined > 0 ? { baselined } : {}),
258
- };
259
- checks.push(entry);
260
- if (!json) {
261
- writeLine(stderr, formatStatusLine(entry));
262
- if (baselined > 0)
263
- writeLine(stderr, ` ${baselined} baselined (grandfathered)`);
264
- if (!ok && newKeys.length > 0) {
265
- writeLine(stderr, ` ${newKeys.length} new, not in baseline:`);
266
- for (const k of newKeys)
267
- writeLine(stderr, ` ${k}`);
268
- }
269
- }
270
- if (!ok && bail) {
271
- brokeEarly = true;
272
- break;
409
+ writeLine(stderr, ` ○ ${entry.name.padEnd(8)} skip ${entry.reason ?? ''}`);
410
+ return entry;
411
+ }
412
+ /** Print one check's status line, its baselined count, and any new (non-grandfathered) findings. */
413
+ function reportCheckResult(stderr, entry, mask) {
414
+ writeLine(stderr, formatStatusLine(entry));
415
+ if (mask.baselined > 0)
416
+ writeLine(stderr, ` ${mask.baselined} baselined (grandfathered)`);
417
+ if (!entry.ok && mask.reason)
418
+ writeLine(stderr, ` ${mask.reason}`);
419
+ if (!entry.ok && mask.newKeys.length > 0) {
420
+ writeLine(stderr, ` ${mask.newKeys.length} new, not in baseline:`);
421
+ for (const k of mask.newKeys)
422
+ writeLine(stderr, ` ${k}`);
423
+ }
424
+ }
425
+ /** Run one active check: clear stale output, run it, persist, and mask against the baseline. */
426
+ async function runOneCheck(r, adapter, ctx) {
427
+ if (!ctx.json)
428
+ writeLine(ctx.stderr, ` ▸ ${r.slot} ${adapter.description}`);
429
+ // Wipe this slot's stale `.check/` artifacts before it runs, so a leaner re-run
430
+ // can't leave last run's output behind as authoritative — and so any artifact
431
+ // the tool writes during *this* run (e.g. `test.json`) survives.
432
+ await clearSlotOutputs(ctx.cwd, adapter);
433
+ const start = performance.now();
434
+ const outcome = await ctx.runner(r, { cwd: ctx.cwd, changed: ctx.changed, pm: ctx.pm, ...(ctx.timeout !== undefined ? { timeout: ctx.timeout } : {}) });
435
+ const duration_ms = Math.round(performance.now() - start);
436
+ await persistOutput(ctx.cwd, adapter, outcome);
437
+ // Masking is always on (even under a partial run); only the ratchet is gated.
438
+ // The raw `.check/<slot>.json` is persisted untouched — masking changes the
439
+ // pass/fail verdict, never the authoritative output.
440
+ const mask = maskOutcome(adapter, outcome, ctx.baseline, r.slot);
441
+ const entry = {
442
+ name: r.slot,
443
+ adapter: adapter.name,
444
+ description: adapter.description,
445
+ ok: mask.ok,
446
+ exit_code: outcome.exit_code,
447
+ duration_ms,
448
+ output_file: adapter.outputFile,
449
+ ...(mask.baselined > 0 ? { baselined: mask.baselined } : {}),
450
+ };
451
+ return { entry, run: { slot: r.slot, adapter, outcome }, mask };
452
+ }
453
+ /**
454
+ * Run one selected check to a {@link CheckResult} — the concurrency-safe core
455
+ * shared by the sequential (`--bail`) and wave paths. A skipped/unavailable slot
456
+ * records a skip row and runs nothing; an active one runs, prints its status
457
+ * line, and reports its fingerprint for the ratchet. It mutates no shared state,
458
+ * so N of these can be in flight at once (C6).
459
+ */
460
+ async function runSelectedCheck(r, ctx) {
461
+ // Skip when unresolved, or when the adapter can't run under this PM — e.g.
462
+ // `pnpm audit` (the `security` slot) is unavailable off pnpm (b5).
463
+ const unavailable = Boolean(r.adapter && !isAvailableUnder(r.adapter.command, r.adapter.args, ctx.pm));
464
+ if (r.skip || !r.adapter || unavailable) {
465
+ return { entry: handleSkip(r, unavailable, ctx.pm, ctx.json, ctx.stderr), run: null, observed: null };
466
+ }
467
+ const { entry, run, mask } = await runOneCheck(r, r.adapter, ctx);
468
+ if (!ctx.json)
469
+ reportCheckResult(ctx.stderr, entry, mask);
470
+ return { entry, run, observed: mask.observed !== null ? { slot: r.slot, fp: mask.observed } : null };
471
+ }
472
+ /** Fold one result into the report accumulators, in call order. */
473
+ function collect(res, acc) {
474
+ acc.checks.push(res.entry);
475
+ if (res.run)
476
+ acc.runs.push(res.run);
477
+ if (res.observed)
478
+ acc.observed.set(res.observed.slot, res.observed.fp);
479
+ }
480
+ /** A fresh, empty {@link Execution}. */
481
+ function emptyExecution(brokeEarly = false) {
482
+ return { checks: [], runs: [], observed: new Map(), brokeEarly };
483
+ }
484
+ /**
485
+ * Scheduling coordinates for a check on D1's line: the group `rank` (firsts 0, the
486
+ * numeric line 1, singles 2, lasts 3) and its position on the numeric line
487
+ * (`'any'`/`'middle'` sit at 0 — v1's conservative placement). This must agree
488
+ * with `config.ts`'s group sort, which already put `selected` in exactly this
489
+ * sequence; here it only re-derives the wave *boundaries* the sort collapsed.
490
+ */
491
+ const SINGLE_RANK = 2;
492
+ function scheduleGroup(order) {
493
+ if (order === 'first')
494
+ return { rank: 0, line: 0 };
495
+ if (order === 'single')
496
+ return { rank: SINGLE_RANK, line: 0 };
497
+ if (order === 'last')
498
+ return { rank: 3, line: 0 };
499
+ return { rank: 1, line: typeof order === 'number' ? order : 0 };
500
+ }
501
+ /**
502
+ * Split the already-sorted `selected` into execution waves: the `'first'` group,
503
+ * each distinct numeric value, and the `'last'` group each become one concurrent
504
+ * wave, with a barrier between waves; every `'single'` is its own wave so it runs
505
+ * with nothing else in flight. Adjacent items share a wave only when their (rank,
506
+ * line) match and neither is a single.
507
+ */
508
+ function partitionWaves(items) {
509
+ const waves = [];
510
+ let current = [];
511
+ let prev = null;
512
+ for (const item of items) {
513
+ const g = scheduleGroup(item.r.order ?? 'any');
514
+ const sameWave = prev !== null && prev.rank === g.rank && prev.line === g.line && g.rank !== SINGLE_RANK;
515
+ if (!sameWave && current.length > 0) {
516
+ waves.push(current);
517
+ current = [];
273
518
  }
519
+ current.push(item);
520
+ prev = g;
274
521
  }
275
- // Ratchet: on a fully-observed run, prune grandfathered diagnostics now fixed.
276
- // A partial run (`--only`/`--skip`/`--changed`) or an early `--bail` break can't
277
- // see every slot's full output, so it never prunes — an unobserved diagnostic
278
- // must not be mistaken for a fixed one and dropped (a1). Masking still applied
279
- // above; only the rewrite is withheld.
280
- if (baseline) {
281
- const restricted = (options.only ?? null) !== null || (options.skip?.length ?? 0) > 0 || changed || brokeEarly;
282
- if (!restricted) {
283
- const pruned = ratchet(baseline, observed);
284
- if (!baselinesEqual(baseline, pruned)) {
285
- await writeBaseline(cwd, pruned);
286
- if (!json) {
287
- writeLine(stderr, `\nbaseline: ratcheted ${BASELINE_FILE} to ${countBaselineKeys(pruned)} grandfathered diagnostic(s)`);
288
- }
289
- }
522
+ if (current.length > 0)
523
+ waves.push(current);
524
+ return waves;
525
+ }
526
+ /**
527
+ * Run `items` through a pool `width` workers wide: each worker pulls the next
528
+ * item until the queue drains, so at most `width` run at once. Concurrency is the
529
+ * worker count; each worker still processes its own items one at a time. `width`
530
+ * is clamped to at least 1 and never exceeds the item count.
531
+ */
532
+ async function runPool(items, width, worker) {
533
+ if (items.length === 0)
534
+ return;
535
+ let next = 0;
536
+ const runWorker = async () => {
537
+ while (next < items.length) {
538
+ const item = items[next];
539
+ next += 1;
540
+ if (item === undefined)
541
+ continue;
542
+ // oxlint-disable-next-line no-await-in-loop -- a worker processes its items in sequence; concurrency is the `width` workers running this loop at once.
543
+ await worker(item);
290
544
  }
545
+ };
546
+ const workers = Array.from({ length: Math.max(1, Math.min(width, items.length)) }, () => runWorker());
547
+ await Promise.all(workers);
548
+ }
549
+ /**
550
+ * `--bail`: the flat sequential path (D6). Checks run one at a time in D1's group
551
+ * order and the run stops at the first *executed* failure — fail-fast is
552
+ * incompatible with already-launched concurrent work, so `--concurrency` is moot
553
+ * here (the caller notes it if one was passed). For the default set (all `'any'`)
554
+ * this is exactly today's cheapest-first order.
555
+ */
556
+ async function executeBail(selected, ctx) {
557
+ const acc = emptyExecution();
558
+ for (const r of selected) {
559
+ // oxlint-disable-next-line no-await-in-loop -- --bail is fail-fast: checks run one at a time so the run can stop at the first failure (D6).
560
+ const res = await runSelectedCheck(r, ctx);
561
+ collect(res, acc);
562
+ if (res.run && !res.entry.ok)
563
+ return { ...acc, brokeEarly: true };
564
+ }
565
+ return acc;
566
+ }
567
+ /**
568
+ * The wave scheduler (D1): run each wave's members concurrently through the pool,
569
+ * with a barrier between waves. Results are placed by their `selected` index and
570
+ * assembled in that order afterwards, so the report is deterministic regardless
571
+ * of which check finishes first (D7). Every selected check runs — there is no
572
+ * early stop off the `--bail` path.
573
+ */
574
+ async function executeWaves(selected, ctx) {
575
+ const indexed = selected.map((r, i) => ({ r, i }));
576
+ const results = Array.from({ length: selected.length });
577
+ for (const wave of partitionWaves(indexed)) {
578
+ // oxlint-disable-next-line no-await-in-loop -- the between-values barrier (D1): a wave runs to completion before the next distinct order value begins.
579
+ await runPool(wave, ctx.concurrency, async ({ r, i }) => { results[i] = await runSelectedCheck(r, ctx); });
580
+ }
581
+ const acc = emptyExecution();
582
+ for (const res of results)
583
+ if (res)
584
+ collect(res, acc);
585
+ return acc;
586
+ }
587
+ /**
588
+ * Run the selected checks, collecting rows, raw runs, observed fingerprints, and
589
+ * `--bail` state. `--bail` takes the flat sequential path; every other run waves
590
+ * on the effective `order` through a bounded pool.
591
+ */
592
+ function executeChecks(selected, ctx) {
593
+ return ctx.bail ? executeBail(selected, ctx) : executeWaves(selected, ctx);
594
+ }
595
+ /**
596
+ * Whether this run saw less than the full pipeline — an `--only`/`--skip`/
597
+ * `--changed` filter or an early `--bail` break. The ratchet is gated off for
598
+ * these: an unobserved diagnostic must not be mistaken for a fixed one (a1).
599
+ */
600
+ function isPartialRun(options, changed, brokeEarly) {
601
+ return (options.only ?? null) !== null || (options.skip?.length ?? 0) > 0 || changed || brokeEarly;
602
+ }
603
+ /** On a fully-observed run, prune grandfathered diagnostics now fixed (shrink-only, atomic). */
604
+ async function maybeRatchet(cwd, baseline, observed, restricted, json, stderr) {
605
+ if (!baseline || restricted)
606
+ return;
607
+ const pruned = ratchet(baseline, observed);
608
+ if (baselinesEqual(baseline, pruned))
609
+ return;
610
+ await writeBaseline(cwd, pruned);
611
+ if (!json) {
612
+ writeLine(stderr, `\nbaseline: ratcheted ${BASELINE_FILE} to ${countBaselineKeys(pruned)} grandfathered diagnostic(s)`);
613
+ }
614
+ }
615
+ /** Print the human run summary: the vacuous-green warning, the status line, and artifact paths. */
616
+ function reportSummary(stderr, summary, checks, adapters, digestWritten) {
617
+ if (summary.checks_run === 0)
618
+ warnVacuous(stderr, checks, adapters);
619
+ writeLine(stderr, '');
620
+ const status = summary.checks_run === 0
621
+ ? '⚠ no checks ran'
622
+ : summary.ok ? '✔ all checks passed' : '✘ one or more checks failed';
623
+ writeLine(stderr, `${status} in ${summary.total_duration_ms}ms`);
624
+ writeLine(stderr, 'report: .check/summary.json');
625
+ if (digestWritten)
626
+ writeLine(stderr, 'digest: .check/digest.md');
627
+ writeLine(stderr, '');
628
+ }
629
+ /**
630
+ * The 0/1/2 exit taxonomy. `--strict` turns a vacuous green (zero checks) into a
631
+ * harness error (exit 2) — a gate must not report "done" on a repo where nothing
632
+ * was checked.
633
+ */
634
+ function computeExitCode(summary, strict, json, stderr) {
635
+ if (strict && summary.checks_run === 0) {
636
+ if (!json)
637
+ writeLine(stderr, '--strict: zero checks ran, exiting 2.\n');
638
+ return 2;
639
+ }
640
+ return summary.ok ? 0 : 1;
641
+ }
642
+ /** Run the selected checks against `cwd`, persist output, write the summary. */
643
+ export async function runChecks(options) {
644
+ const ctx = resolveRunContext(options);
645
+ const resolved = resolveChecks({ slots: ctx.slots, adapters: ctx.adapters, config: ctx.config, cwd: ctx.cwd });
646
+ // A usage error before any side effect: no `.check/` dir, no run, exit 2.
647
+ validateSelection(resolved, options);
648
+ await mkdir(join(ctx.cwd, '.check'), { recursive: true });
649
+ const selected = selectChecks(resolved, options);
650
+ if (!ctx.json)
651
+ writeLine(ctx.stderr, `\nRunning ${selected.length} check(s)...\n`);
652
+ // `--bail` is fail-fast sequential (D6); a `--concurrency > 1` passed alongside
653
+ // is safe but moot, so say so once — not a usage error, the run just goes slow.
654
+ if (ctx.bail && (options.concurrency ?? 0) > 1 && !ctx.json) {
655
+ writeLine(ctx.stderr, '--concurrency ignored under --bail (fail-fast runs sequentially).');
291
656
  }
292
- const summary = buildSummary(checks);
293
- await writeFileAtomic(join(cwd, '.check', 'summary.json'), `${JSON.stringify(summary, null, 2)}\n`);
657
+ // `total_duration_ms` is wall-clock of the execution phase (D7), so measure
658
+ // around it rather than summing per-check durations (which diverge under
659
+ // concurrency). The report array is assembled in `selected` order, not
660
+ // completion order, so it stays byte-reproducible regardless of interleaving.
661
+ const startedAt = performance.now();
662
+ const { checks, runs, observed, brokeEarly } = await executeChecks(selected, ctx);
663
+ const totalDurationMs = Math.round(performance.now() - startedAt);
664
+ await maybeRatchet(ctx.cwd, ctx.baseline, observed, isPartialRun(options, ctx.changed, brokeEarly), ctx.json, ctx.stderr);
665
+ const summary = buildSummary(checks, totalDurationMs);
666
+ await writeFileAtomic(join(ctx.cwd, '.check', 'summary.json'), `${JSON.stringify(summary, null, 2)}\n`);
294
667
  // `--digest`: write (or, on green, clear) the token-bounded failure excerpt.
295
668
  // A file beside summary.json, never a stdout stream, so the machine-output
296
669
  // split holds (C5). Raw `.check/<slot>.json` files are already persisted and
297
670
  // untouched — the digest only reads them.
298
- const digestWritten = (options.digest ?? false) ? await writeDigest(cwd, runs, checks) : false;
299
- if (json) {
300
- stdout.write(`${JSON.stringify(summary, null, 2)}\n`);
671
+ const digestWritten = (options.digest ?? false) ? await writeDigest(ctx.cwd, runs, checks) : false;
672
+ if (ctx.json) {
673
+ ctx.stdout.write(`${JSON.stringify(summary, null, 2)}\n`);
301
674
  }
302
675
  else {
303
- if (summary.checks_run === 0)
304
- warnVacuous(stderr, checks, adapters);
305
- writeLine(stderr, '');
306
- const status = summary.checks_run === 0
307
- ? '⚠ no checks ran'
308
- : summary.ok ? '✔ all checks passed' : '✘ one or more checks failed';
309
- writeLine(stderr, `${status} in ${summary.total_duration_ms}ms`);
310
- writeLine(stderr, 'report: .check/summary.json');
311
- if (digestWritten)
312
- writeLine(stderr, 'digest: .check/digest.md');
313
- writeLine(stderr, '');
314
- }
315
- // `--strict` turns a vacuous green into a harness error: exit 2, never 0 —
316
- // a gate must not report "done" on a repo where nothing was checked.
317
- let exitCode = summary.ok ? 0 : 1;
318
- if ((options.strict ?? false) && summary.checks_run === 0) {
319
- exitCode = 2;
320
- if (!json)
321
- writeLine(stderr, '--strict: zero checks ran, exiting 2.\n');
676
+ reportSummary(ctx.stderr, summary, checks, ctx.adapters, digestWritten);
322
677
  }
678
+ const exitCode = computeExitCode(summary, options.strict ?? false, ctx.json, ctx.stderr);
323
679
  return { ok: summary.ok, summary, exitCode, runs };
324
680
  }
325
681
  function spawnInherit(command, args, cwd) {
@@ -329,16 +685,27 @@ function spawnInherit(command, args, cwd) {
329
685
  proc.on('close', (code) => { resolveOutcome({ ok: code === 0, exit_code: code ?? -1 }); });
330
686
  });
331
687
  }
332
- const defaultFixRunner = (adapter, ctx) => spawnInherit(adapter.command, adapter.fixArgs ?? [], ctx.cwd);
688
+ /**
689
+ * The command `checkride fix` spawns for one adapter under `pm` — the fix
690
+ * path's counterpart to `defaultRunner`'s `translateExec` call, so a fix runs
691
+ * on the same binary the run path does (a canonical `pnpm exec oxlint --fix`
692
+ * becomes `npx oxlint --fix` under npm). Non-`pnpm exec` fix commands, and
693
+ * everything under pnpm, pass through unchanged.
694
+ */
695
+ export function fixInvocation(adapter, pm) {
696
+ return translateExec(adapter.command, adapter.fixArgs ?? [], pm);
697
+ }
698
+ const defaultFixRunner = (adapter, ctx) => {
699
+ const { command, args } = fixInvocation(adapter, ctx.pm);
700
+ return spawnInherit(command, args, ctx.cwd);
701
+ };
333
702
  /** Run every active adapter's `fixArgs` (`checkride fix`). */
334
703
  export async function runFix(options) {
335
- const cwd = options.cwd ?? process.cwd();
336
- const slots = options.slots ?? SLOTS;
337
- const adapters = options.adapters ?? ADAPTERS;
338
- const config = options.config !== undefined ? options.config : loadConfig(cwd);
339
- const stderr = options.stderr ?? process.stderr;
704
+ const { cwd, slots, adapters, config, stderr } = resolveCommonOptions(options);
340
705
  const fixRunner = options.fixRunner ?? defaultFixRunner;
706
+ const pm = options.pm ?? detectPackageManager({ cwd });
341
707
  const resolved = resolveChecks({ slots, adapters, config, cwd });
708
+ validateSelection(resolved, options);
342
709
  const fixable = selectChecks(resolved, options).filter((r) => r.adapter?.fixArgs);
343
710
  if (fixable.length === 0) {
344
711
  writeLine(stderr, 'checkride fix: no active adapters expose a fix command.');
@@ -351,7 +718,8 @@ export async function runFix(options) {
351
718
  if (!adapter)
352
719
  continue;
353
720
  writeLine(stderr, ` ▸ fix ${r.slot.padEnd(8)} (${adapter.name})`);
354
- const outcome = await fixRunner(adapter, { cwd });
721
+ // oxlint-disable-next-line no-await-in-loop -- fixers mutate the working tree; running them sequentially prevents two (e.g. oxlint --fix and prettier --write) racing on the same files.
722
+ const outcome = await fixRunner(adapter, { cwd, pm });
355
723
  ran.push(adapter.name);
356
724
  writeLine(stderr, outcome.ok ? ` ✔ ${r.slot}` : ` ✘ ${r.slot} (exit ${outcome.exit_code})`);
357
725
  if (!outcome.ok)