plumbbob 0.8.14 → 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.
- package/.claude-plugin/plugin.json +1 -1
- package/dist/lib/agents.js +1 -1
- package/dist/lib/check.js +9 -6
- package/node_modules/checkride/CHANGELOG.md +234 -0
- package/node_modules/checkride/README.md +134 -31
- package/node_modules/checkride/dist/adapters.d.ts +50 -1
- package/node_modules/checkride/dist/adapters.d.ts.map +1 -1
- package/node_modules/checkride/dist/adapters.js +123 -7
- package/node_modules/checkride/dist/adapters.js.map +1 -1
- package/node_modules/checkride/dist/agent-setup/hook.d.ts.map +1 -1
- package/node_modules/checkride/dist/agent-setup/hook.js +15 -1
- package/node_modules/checkride/dist/agent-setup/hook.js.map +1 -1
- package/node_modules/checkride/dist/agent-setup/index.d.ts +0 -1
- package/node_modules/checkride/dist/agent-setup/index.d.ts.map +1 -1
- package/node_modules/checkride/dist/agent-setup/index.js.map +1 -1
- package/node_modules/checkride/dist/baseline/fallow.d.ts +64 -0
- package/node_modules/checkride/dist/baseline/fallow.d.ts.map +1 -0
- package/node_modules/checkride/dist/baseline/fallow.js +256 -0
- package/node_modules/checkride/dist/baseline/fallow.js.map +1 -0
- package/node_modules/checkride/dist/baseline/fingerprint.d.ts +4 -6
- package/node_modules/checkride/dist/baseline/fingerprint.d.ts.map +1 -1
- package/node_modules/checkride/dist/baseline/fingerprint.js +9 -4
- package/node_modules/checkride/dist/baseline/fingerprint.js.map +1 -1
- package/node_modules/checkride/dist/baseline/index.d.ts +4 -5
- package/node_modules/checkride/dist/baseline/index.d.ts.map +1 -1
- package/node_modules/checkride/dist/baseline/index.js +2 -2
- package/node_modules/checkride/dist/baseline/index.js.map +1 -1
- package/node_modules/checkride/dist/{baseline/command.d.ts → baseline-command.d.ts} +11 -9
- package/node_modules/checkride/dist/baseline-command.d.ts.map +1 -0
- package/node_modules/checkride/dist/{baseline/command.js → baseline-command.js} +9 -7
- package/node_modules/checkride/dist/baseline-command.js.map +1 -0
- package/node_modules/checkride/dist/cli.d.ts.map +1 -1
- package/node_modules/checkride/dist/cli.js +95 -8
- package/node_modules/checkride/dist/cli.js.map +1 -1
- package/node_modules/checkride/dist/config.d.ts +37 -12
- package/node_modules/checkride/dist/config.d.ts.map +1 -1
- package/node_modules/checkride/dist/config.js +268 -60
- package/node_modules/checkride/dist/config.js.map +1 -1
- package/node_modules/checkride/dist/digest/digest.d.ts +0 -6
- package/node_modules/checkride/dist/digest/digest.d.ts.map +1 -1
- package/node_modules/checkride/dist/digest/digest.js +3 -3
- package/node_modules/checkride/dist/digest/digest.js.map +1 -1
- package/node_modules/checkride/dist/digest/index.d.ts +1 -2
- package/node_modules/checkride/dist/digest/index.d.ts.map +1 -1
- package/node_modules/checkride/dist/digest/index.js +1 -1
- package/node_modules/checkride/dist/digest/index.js.map +1 -1
- package/node_modules/checkride/dist/doctor.d.ts +14 -1
- package/node_modules/checkride/dist/doctor.d.ts.map +1 -1
- package/node_modules/checkride/dist/doctor.js +124 -55
- package/node_modules/checkride/dist/doctor.js.map +1 -1
- package/node_modules/checkride/dist/init.d.ts +6 -0
- package/node_modules/checkride/dist/init.d.ts.map +1 -1
- package/node_modules/checkride/dist/init.js +347 -121
- package/node_modules/checkride/dist/init.js.map +1 -1
- package/node_modules/checkride/dist/links.d.ts.map +1 -1
- package/node_modules/checkride/dist/links.js +8 -9
- package/node_modules/checkride/dist/links.js.map +1 -1
- package/node_modules/checkride/dist/orchestrator.d.ts +56 -1
- package/node_modules/checkride/dist/orchestrator.d.ts.map +1 -1
- package/node_modules/checkride/dist/orchestrator.js +506 -138
- package/node_modules/checkride/dist/orchestrator.js.map +1 -1
- package/node_modules/checkride/dist/pack.d.ts +76 -0
- package/node_modules/checkride/dist/pack.d.ts.map +1 -0
- package/node_modules/checkride/dist/pack.js +217 -0
- package/node_modules/checkride/dist/pack.js.map +1 -0
- package/node_modules/checkride/dist/pm/translate.d.ts +18 -10
- package/node_modules/checkride/dist/pm/translate.d.ts.map +1 -1
- package/node_modules/checkride/dist/pm/translate.js +27 -13
- package/node_modules/checkride/dist/pm/translate.js.map +1 -1
- package/node_modules/checkride/dist/smoke.d.ts +86 -0
- package/node_modules/checkride/dist/smoke.d.ts.map +1 -0
- package/node_modules/checkride/dist/smoke.js +346 -0
- package/node_modules/checkride/dist/smoke.js.map +1 -0
- package/node_modules/checkride/dist/snippets.d.ts +215 -0
- package/node_modules/checkride/dist/snippets.d.ts.map +1 -0
- package/node_modules/checkride/dist/snippets.js +360 -0
- package/node_modules/checkride/dist/snippets.js.map +1 -0
- package/node_modules/checkride/package.json +4 -2
- package/node_modules/checkride/schema/checkride.config.schema.json +11 -6
- package/package.json +3 -2
- package/node_modules/checkride/dist/baseline/command.d.ts.map +0 -1
- 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
|
|
59
|
-
* gets SIGTERM, then SIGKILL after a short grace
|
|
60
|
-
*
|
|
61
|
-
*
|
|
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.
|
|
78
|
-
killTimer = setTimeout(() => { proc.
|
|
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
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
/**
|
|
192
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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,
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
|
|
293
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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)
|