@sabaiway/agent-workflow-kit 1.35.0 → 1.37.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.
@@ -0,0 +1,526 @@
1
+ #!/usr/bin/env node
2
+ // fold-completeness-run.mjs — the M3 fold-completeness RUNNER (AD-046, DEBT-TEST-COMPLETENESS). It is
3
+ // the SOLE tree-toucher and the SOLE writer of the fold-completeness result ledger, the write half of
4
+ // the family read/write split (mirrors review-ledger-write.mjs): fold-completeness.mjs (the schema +
5
+ // result reader + the read-only `--check` gate) NEVER imports this module — an import-split test pins
6
+ // that. This module imports the read core the OTHER direction (the result schema + reader + the shared
7
+ // bound-testId collector) and appends records through the shared hardened atomic-write core.
8
+ //
9
+ // One run, over the in-flight plan-execution loop's dirty tree:
10
+ // 1. resolve the loop = the single in-flight plan stem (0 or >1 → typed refusal);
11
+ // 2. classify the changed surface (Decision 5, a CLOSED extension rule — assessable JS / unsupported
12
+ // TS-JSX / out-of-domain) and derive per-file changed line ranges;
13
+ // 3. M3a — run the suite ONCE under NODE_V8_COVERAGE (a dir OUTSIDE the work tree, Decision 8) and
14
+ // map every changed executable line to covered/uncovered via V8 innermost-range-wins (Decision 6);
15
+ // 4. probe each of the loop's fixable-bug bound testIds once (Decision 3 / 10, shell-free) for
16
+ // resolvability + a green baseline;
17
+ // 5. append ONE machine-only result record, bound to BOTH the tree fingerprint AND the sorted
18
+ // fixable-bug testId set (Decision 9), to <git dir>/agent-workflow-fold-completeness.jsonl.
19
+ // The researched mutation half (M3b) was SHELVED — bounded local-boundary mutation adds too little
20
+ // over coverage and is not language-independent — so the `mutation` field stays the reserved empty shape.
21
+ //
22
+ // HONEST residuals (see fold-completeness.mjs header for the full list): coverage proves execution not
23
+ // assertion; testIds/records are forgeable (a self-discipline mechanism, not a security boundary);
24
+ // TS/JSX source is out of scope v1. Dependency-free, Node >= 18. No side effects on import.
25
+
26
+ import { readFileSync, readdirSync, mkdtempSync, rmSync, realpathSync, lstatSync } from 'node:fs';
27
+ import { join, dirname, basename } from 'node:path';
28
+ import { tmpdir } from 'node:os';
29
+ import { pathToFileURL, fileURLToPath } from 'node:url';
30
+ import { spawnSync } from 'node:child_process';
31
+ import { writeContainedFileAtomic } from './atomic-write.mjs';
32
+ import { computeTreeFingerprint, plansInFlight } from './review-state.mjs';
33
+ import { resolveLedgerPath, readLedger } from './review-ledger.mjs';
34
+ import {
35
+ RESULT_SCHEMA_VERSION,
36
+ resolveResultsPath,
37
+ validateRunRecord,
38
+ collectBoundTestIds,
39
+ } from './fold-completeness.mjs';
40
+
41
+ const ACTIVITY = 'plan-execution';
42
+ const GIT_MAX_BUFFER = 256 * 1024 * 1024; // a full-tree diff / full-suite TAP can be large; never truncate
43
+
44
+ // A typed STOP — a deliberate refusal we surface (loop derivation / a malformed override / a malformed
45
+ // record / an fs error), distinct from a native fs error. The codebase's typed-error idiom (no classes).
46
+ export const FOLD_RUN_STOP = 'FOLD_RUN_STOP';
47
+ const stop = (message) => Object.assign(new Error(`[agent-workflow-kit] ${message}`), { name: 'FoldRunStop', code: FOLD_RUN_STOP });
48
+ const usageFail = (message) => Object.assign(new Error(`[agent-workflow-kit] ${message}`), { exitCode: 2 });
49
+
50
+ const isoNow = () => new Date().toISOString();
51
+
52
+ // Node sets NODE_TEST_CONTEXT for any process running UNDER `node --test`; a fresh `node --test` that
53
+ // inherits it silently SKIPS running its files (the recursive-run guard). The runner spawns `node
54
+ // --test` for the suite + the bound-test probes, so it MUST strip that var — otherwise, whenever the
55
+ // runner is itself invoked from within a test context (e.g. this kit's own fold-completeness-run
56
+ // tests, or a consumer's), the child runs nothing and every file reads as uncovered. Unset in normal
57
+ // (non-test) invocation, so stripping is a no-op there.
58
+ const childTestEnv = (env, extra = {}) => {
59
+ const out = { ...env, ...extra };
60
+ delete out.NODE_TEST_CONTEXT;
61
+ return out;
62
+ };
63
+
64
+ // ── Decision 5: the CLOSED changed-path classification rule (no heuristics) ───────────────────────
65
+
66
+ const TEST_FILE_RE = /\.(test|spec)\.[^./]+$/; // a.test.mjs, b.spec.js, c.test.cjs, d.spec.ts
67
+ const ASSESSABLE_EXT = new Set(['.mjs', '.cjs', '.js']);
68
+ const UNSUPPORTED_EXT = new Set(['.ts', '.tsx', '.jsx', '.mts', '.cts']);
69
+
70
+ // classifyChangedPath(rel) → 'assessable' | 'unsupported' | 'out-of-domain' | 'excluded-test'.
71
+ export const classifyChangedPath = (rel) => {
72
+ const base = rel.split('/').pop();
73
+ if (TEST_FILE_RE.test(base)) return 'excluded-test';
74
+ const dot = base.lastIndexOf('.');
75
+ const ext = dot >= 0 ? base.slice(dot) : '';
76
+ if (ASSESSABLE_EXT.has(ext)) return 'assessable';
77
+ if (UNSUPPORTED_EXT.has(ext)) return 'unsupported';
78
+ return 'out-of-domain';
79
+ };
80
+
81
+ // ── unified-diff → new-side changed line numbers (line numbers only; content lines are ignored) ───
82
+
83
+ const DIFF_HUNK_RE = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/;
84
+
85
+ // parseUnifiedDiff(diffText) → Map<rel, number[]>. Robust against a content line that happens to look
86
+ // like a `+++ ` header: a `+++ ` line is a FILE header only in the header region (right after a
87
+ // `diff --git`, before any `@@`); inside a hunk body it is ignored. New-side lines come purely from the
88
+ // `@@` headers, so no content-line disambiguation is needed for the line numbers themselves.
89
+ export const parseUnifiedDiff = (diffText) => {
90
+ const map = new Map();
91
+ let current = null;
92
+ let inHeader = false;
93
+ for (const line of String(diffText).split('\n')) {
94
+ if (line.startsWith('diff --git ')) {
95
+ current = null;
96
+ inHeader = true;
97
+ continue;
98
+ }
99
+ if (inHeader && line.startsWith('--- ')) continue;
100
+ if (inHeader && line.startsWith('+++ ')) {
101
+ const p = line.slice(4).trim();
102
+ current = p === '/dev/null' ? null : p.startsWith('b/') ? p.slice(2) : p;
103
+ inHeader = false;
104
+ continue;
105
+ }
106
+ const m = DIFF_HUNK_RE.exec(line);
107
+ if (m) {
108
+ inHeader = false;
109
+ if (current == null) continue;
110
+ const start = Number(m[1]);
111
+ const count = m[2] === undefined ? 1 : Number(m[2]);
112
+ if (count > 0) {
113
+ const arr = map.get(current) ?? [];
114
+ for (let i = 0; i < count; i += 1) arr.push(start + i);
115
+ map.set(current, arr);
116
+ }
117
+ }
118
+ }
119
+ for (const [k, v] of map) map.set(k, [...new Set(v)].sort((a, b) => a - b));
120
+ return map;
121
+ };
122
+
123
+ // ── Decision 6: V8 coverage → uncovered changed lines (innermost-range-wins) ──────────────────────
124
+
125
+ // lineStartOffsets(sourceText) → char offset of each line start (index i == line i+1). Splitting on
126
+ // '\n' keeps any trailing '\r' inside the line length, so CRLF offsets stay correct.
127
+ export const lineStartOffsets = (sourceText) => {
128
+ const offs = [0];
129
+ for (let i = 0; i < sourceText.length; i += 1) if (sourceText[i] === '\n') offs.push(i + 1);
130
+ return offs;
131
+ };
132
+
133
+ // effectiveCount(ranges, offset) → the execution count of the SMALLEST (innermost) range containing
134
+ // offset; 0 when no range contains it (absent from this process's report). This is the v8-to-istanbul
135
+ // rule reduced to Node built-ins: a nested count-0 block shadows its executed parent.
136
+ export const effectiveCount = (ranges, offset) => {
137
+ let best = null;
138
+ for (const r of ranges) {
139
+ if (r.startOffset <= offset && offset < r.endOffset) {
140
+ const width = r.endOffset - r.startOffset;
141
+ if (best === null || width < best.width) best = { width, count: r.count };
142
+ }
143
+ }
144
+ return best === null ? 0 : best.count;
145
+ };
146
+
147
+ // computeUncoveredLines({ perProcessRanges, sourceText, changedLines }) → the sorted changed lines that
148
+ // NO process executed. perProcessRanges is one flat range-list per process (process isolation); a line
149
+ // is covered iff ANY process gives its first non-whitespace char a positive effective count. Blank /
150
+ // whitespace-only lines are never executable and never flagged. Callers handle file-absent (an empty
151
+ // perProcessRanges means the file never loaded — a file-level RED decided by the caller, not here).
152
+ //
153
+ // GRANULARITY (stated residual, codex R1 → inherent-layer-residual): this is LINE-ENTRY coverage — the
154
+ // question "was this line entered by some test?", the same granularity c8 reports as line coverage. A
155
+ // same-line uncovered sub-branch (the false arm of `a ? b : c`, the RHS of `a && b()`) is NOT flagged
156
+ // when the line's leading statement executed — flagging it would need branch/AST analysis, and a naive
157
+ // "any count-0 char on the line" rule would FALSE-POSITIVE on an inline uncalled-function definition
158
+ // (`const f = () => never()`, whose body span is count-0 though the assignment ran), i.e. exactly the
159
+ // churn the plan's PRIMARY RISK warns against. Same-line branch gaps would need branch-level analysis
160
+ // (the shelved mutation half / a future parser-backed signal), not M3a line coverage. Chasing sub-line
161
+ // precision here without an AST is explicitly out of scope — a stated residual.
162
+ export const computeUncoveredLines = ({ perProcessRanges, sourceText, changedLines }) => {
163
+ const offs = lineStartOffsets(sourceText);
164
+ const total = sourceText.length;
165
+ const uncovered = [];
166
+ for (const n of changedLines) {
167
+ const start = offs[n - 1];
168
+ if (start === undefined) continue; // a changed line beyond EOF (defensive; should not happen)
169
+ const end = offs[n] ?? total;
170
+ const rel = sourceText.slice(start, end).search(/\S/);
171
+ if (rel < 0) continue; // blank / whitespace-only → not executable
172
+ const offset = start + rel;
173
+ const covered = perProcessRanges.some((ranges) => effectiveCount(ranges, offset) > 0);
174
+ if (!covered) uncovered.push(n);
175
+ }
176
+ return [...new Set(uncovered)].sort((a, b) => a - b);
177
+ };
178
+
179
+ // ── Decision 3 / 10: the bound-test probe ─────────────────────────────────────────────────────────
180
+
181
+ const PROBE_RESULT_RE = /^(?:ok|not ok) \d+ - (.*)$/; // a column-0 TAP result line
182
+ const PROBE_FAIL_RE = /^# fail (\d+)$/;
183
+
184
+ // parseProbeOutput({ stdout, code, fileArg }) → { resolvable, executed, baselineGreen }. A node:test
185
+ // run with a pattern that matches NOTHING emits only a file-wrapper result whose description is the
186
+ // file path itself (`ok N - <file>`); a real match emits the test NAME. So `resolvable` = at least one
187
+ // column-0 result whose description is not the file we passed; `baselineGreen` = resolvable AND the run
188
+ // was green (exit 0 and `# fail 0`). The wrapper is matched by BASENAME, not literally: node normalizes
189
+ // the echoed path ('./x' → 'x', or an absolute path), so a literal desc===fileArg compare would count
190
+ // the wrapper as a real match and falsely report resolvable/green (codex R1). A basename compare is
191
+ // invariant to ./ / abs / rel; a real test name colliding with the file's basename is absurd and would
192
+ // only fail CLOSED (mark unresolvable), never open.
193
+ export const parseProbeOutput = ({ stdout, code, fileArg }) => {
194
+ let matched = 0;
195
+ let failCount = null;
196
+ const wanted = basename(String(fileArg).trim());
197
+ for (const line of String(stdout).split('\n')) {
198
+ const m = PROBE_RESULT_RE.exec(line);
199
+ if (m && basename(m[1].trim()) !== wanted) matched += 1;
200
+ const f = PROBE_FAIL_RE.exec(line.trim());
201
+ if (f) failCount = Number(f[1]);
202
+ }
203
+ const resolvable = matched > 0;
204
+ const fails = failCount ?? (code === 0 ? 0 : 1);
205
+ return { resolvable, executed: matched, baselineGreen: resolvable && code === 0 && fails === 0 };
206
+ };
207
+
208
+ // defaultBoundArgv(file, pattern) → the shell-free node:test argv (testId content never reaches a shell).
209
+ export const defaultBoundArgv = (file, pattern) => ['node', '--test', '--test-reporter', 'tap', '--test-name-pattern', pattern, file];
210
+
211
+ // resolveBoundArgv(env) → (file, pattern) => argv[]. Default = the node:test shape; AW_FOLD_BOUND_CMD
212
+ // overrides with a JSON array of argv strings using {file}/{pattern} placeholders (the universality
213
+ // escape hatch — a consumer on another runner). A malformed override is a typed refusal, never a
214
+ // silent fallback to a shell. Substitution uses function replacers so a `$` in a testId is literal.
215
+ export const resolveBoundArgv = (env = process.env) => {
216
+ const raw = env.AW_FOLD_BOUND_CMD;
217
+ if (!raw) return (file, pattern) => defaultBoundArgv(file, pattern);
218
+ let tmpl;
219
+ try {
220
+ tmpl = JSON.parse(raw);
221
+ } catch (err) {
222
+ throw stop(`AW_FOLD_BOUND_CMD is not valid JSON (${err.message}) — expected a JSON array of argv strings`);
223
+ }
224
+ if (!Array.isArray(tmpl) || tmpl.length === 0 || !tmpl.every((a) => typeof a === 'string')) {
225
+ throw stop('AW_FOLD_BOUND_CMD must be a non-empty JSON array of argv strings with {file}/{pattern} placeholders');
226
+ }
227
+ return (file, pattern) => tmpl.map((a) => a.replace(/\{file\}/g, () => file).replace(/\{pattern\}/g, () => pattern));
228
+ };
229
+
230
+ // ── the changed surface (git-driven) ──────────────────────────────────────────────────────────────
231
+
232
+ const runGit = (args, cwd) => spawnSync('git', args, { cwd, maxBuffer: GIT_MAX_BUFFER, encoding: 'utf8', windowsHide: true });
233
+ const gitStdout = (args, cwd) => {
234
+ const r = runGit(args, cwd);
235
+ return r.error || r.status > 1 ? null : r.stdout;
236
+ };
237
+ const readFileSafe = (path) => {
238
+ try {
239
+ return readFileSync(path, 'utf8');
240
+ } catch {
241
+ return null;
242
+ }
243
+ };
244
+ const canon = (path) => {
245
+ try {
246
+ return realpathSync(path);
247
+ } catch {
248
+ return path;
249
+ }
250
+ };
251
+ // A changed assessable LEAF is assessed only if it is a REGULAR file. lstat (no-follow): a symlinked
252
+ // or non-regular *.mjs must NEVER be read/canonicalized — following it could read outside the work
253
+ // tree or HANG on a FIFO/device (codex R2). A non-regular leaf fails closed (routed to `unsupported`).
254
+ const isRegularLeaf = (abs) => {
255
+ try {
256
+ return lstatSync(abs).isFile();
257
+ } catch {
258
+ return false;
259
+ }
260
+ };
261
+
262
+ // computeChangedSurface(root) → { assessable: Map<rel, number[]>, unsupported: [rel], outOfDomain: [rel] }.
263
+ // Domain = the review-payload domain (tracked working-vs-HEAD changes + untracked-not-ignored files),
264
+ // classified by the CLOSED rule. Tracked changed lines come from `git diff HEAD -U0`; an untracked file
265
+ // is wholly new, so all its lines are "changed".
266
+ export const computeChangedSurface = (root) => {
267
+ const trackedDiff = gitStdout(['diff', 'HEAD', '--unified=0', '--no-color', '--no-ext-diff', '--no-renames'], root)
268
+ ?? gitStdout(['diff', '--unified=0', '--no-color', '--no-ext-diff', '--no-renames'], root) // no HEAD yet (unborn branch)
269
+ ?? '';
270
+ const trackedLines = parseUnifiedDiff(trackedDiff);
271
+ const untrackedZ = gitStdout(['ls-files', '--others', '--exclude-standard', '-z'], root) ?? '';
272
+ const untracked = untrackedZ.split('\0').filter(Boolean);
273
+
274
+ const assessable = new Map();
275
+ const unsupported = [];
276
+ const outOfDomain = [];
277
+ const place = (rel, cls, lines) => {
278
+ if (cls === 'excluded-test') return;
279
+ if (cls === 'assessable') {
280
+ if (isRegularLeaf(join(root, rel))) assessable.set(rel, lines);
281
+ else unsupported.push(rel); // a symlinked / non-regular source → fail closed, never followed
282
+ return;
283
+ }
284
+ if (cls === 'unsupported') unsupported.push(rel);
285
+ else outOfDomain.push(rel);
286
+ };
287
+ for (const [rel, lines] of trackedLines) place(rel, classifyChangedPath(rel), lines);
288
+ for (const rel of untracked) {
289
+ const cls = classifyChangedPath(rel);
290
+ if (cls !== 'assessable') {
291
+ place(rel, cls, []);
292
+ continue;
293
+ }
294
+ // Guard the leaf BEFORE reading — never follow a symlink to count an untracked file's lines.
295
+ const abs = join(root, rel);
296
+ if (!isRegularLeaf(abs)) {
297
+ unsupported.push(rel);
298
+ continue;
299
+ }
300
+ const src = readFileSafe(abs);
301
+ const count = src == null || src.length === 0 ? 0 : src.split('\n').length;
302
+ assessable.set(rel, Array.from({ length: count }, (_, i) => i + 1));
303
+ }
304
+ return { assessable, unsupported: unsupported.sort(), outOfDomain: outOfDomain.sort() };
305
+ };
306
+
307
+ // ── coverage (run the suite once under NODE_V8_COVERAGE, outside the tree) ─────────────────────────
308
+
309
+ // readCoverage(covDir) → Map<canonicalAbsPath, Array<Array<range>>> — one flat range-list per process.
310
+ const readCoverage = (covDir) => {
311
+ const byPath = new Map();
312
+ let files;
313
+ try {
314
+ files = readdirSync(covDir);
315
+ } catch {
316
+ return byPath;
317
+ }
318
+ for (const f of files) {
319
+ if (!f.endsWith('.json')) continue;
320
+ let parsed;
321
+ try {
322
+ parsed = JSON.parse(readFileSync(join(covDir, f), 'utf8'));
323
+ } catch {
324
+ continue;
325
+ }
326
+ if (!parsed || !Array.isArray(parsed.result)) continue;
327
+ for (const entry of parsed.result) {
328
+ if (typeof entry.url !== 'string' || !entry.url.startsWith('file:')) continue;
329
+ let abs;
330
+ try {
331
+ abs = canon(fileURLToPath(entry.url));
332
+ } catch {
333
+ continue;
334
+ }
335
+ const ranges = [];
336
+ for (const fn of entry.functions ?? []) for (const r of fn.ranges ?? []) ranges.push({ startOffset: r.startOffset, endOffset: r.endOffset, count: r.count });
337
+ if (!byPath.has(abs)) byPath.set(abs, []);
338
+ byPath.get(abs).push(ranges);
339
+ }
340
+ }
341
+ return byPath;
342
+ };
343
+
344
+ // ── suite command discovery (Decision 10) ─────────────────────────────────────────────────────────
345
+
346
+ const GATES_REL = 'docs/ai/gates.json';
347
+ const resolveSuiteCmd = (root, env, explicit) => {
348
+ if (explicit) return explicit;
349
+ if (env.AW_FOLD_SUITE_CMD) return env.AW_FOLD_SUITE_CMD;
350
+ const raw = readFileSafe(join(root, GATES_REL));
351
+ if (raw) {
352
+ try {
353
+ const gate = (JSON.parse(raw).gates ?? []).find((g) => g && g.id === 'unit-tests');
354
+ if (gate && typeof gate.cmd === 'string') return gate.cmd;
355
+ } catch {
356
+ /* fall through to the loud refusal */
357
+ }
358
+ }
359
+ throw stop(`cannot resolve the suite command — no unit-tests gate in ${GATES_REL}; pass --suite "<cmd>" or set AW_FOLD_SUITE_CMD`);
360
+ };
361
+
362
+ // ── the append primitive (whole-file read → add one JSONL line → atomic rewrite) ─────────────────
363
+
364
+ const appendRecord = (resultsPath, record) => {
365
+ let existing = '';
366
+ try {
367
+ existing = readFileSync(resultsPath, 'utf8');
368
+ } catch (err) {
369
+ if (err && err.code === 'ENOENT') existing = '';
370
+ else throw stop(`cannot read the result ledger before appending (${(err && err.code) || (err && err.message) || err}) — refusing to overwrite it (fail closed)`);
371
+ }
372
+ const prefix = existing === '' ? '' : existing.endsWith('\n') ? existing : `${existing}\n`;
373
+ const body = `${prefix}${JSON.stringify(record)}\n`;
374
+ writeContainedFileAtomic(dirname(resultsPath), resultsPath, body, {}, { stop, label: resultsPath });
375
+ return { writtenPath: resultsPath, record };
376
+ };
377
+
378
+ // ── the run ───────────────────────────────────────────────────────────────────────────────────────
379
+
380
+ const budgetsFromEnv = (env) => ({
381
+ mutantsMax: Number.parseInt(env.AW_FOLD_MUTANTS_MAX ?? '200', 10) || 200,
382
+ hunkMutantsMax: Number.parseInt(env.AW_FOLD_HUNK_MUTANTS_MAX ?? '25', 10) || 25,
383
+ timeBudgetS: Number.parseInt(env.AW_FOLD_TIME_BUDGET_S ?? '600', 10) || 600,
384
+ });
385
+
386
+ // runFoldCompleteness({ cwd, env, suiteCmd }) → { writtenPath, record }. THROWS a typed STOP (loop
387
+ // derivation / suite discovery / a malformed record / an fs error) or a native fs error.
388
+ export const runFoldCompleteness = ({ cwd = process.cwd(), env = process.env, suiteCmd } = {}) => {
389
+ const root = gitStdout(['rev-parse', '--show-toplevel'], cwd);
390
+ if (root == null) throw stop('not a git work tree — nothing to assess');
391
+ const rootTop = root.replace(/\r?\n$/, '');
392
+
393
+ // Decision: the loop = the single in-flight plan stem; 0 or >1 → refuse (ambiguous).
394
+ const plans = plansInFlight(rootTop);
395
+ if (plans.length === 0) throw stop('no plan in flight (docs/plans/ holds no active plan) — nothing to assess');
396
+ if (plans.length > 1) throw stop(`more than one plan in flight (${plans.join(', ')}) — ambiguous loop id; resolve to one active plan`);
397
+ const loop = plans[0].replace(/\.md$/, '');
398
+
399
+ const fingerprint = computeTreeFingerprint(cwd);
400
+ const cmd = resolveSuiteCmd(rootTop, env, suiteCmd);
401
+ const boundArgv = resolveBoundArgv(env); // resolves BEFORE any spawn (a malformed override refuses loudly)
402
+ const budgets = budgetsFromEnv(env);
403
+
404
+ const { assessable, unsupported, outOfDomain } = computeChangedSurface(rootTop);
405
+
406
+ // M3a: run the suite once under coverage in a dir OUTSIDE the work tree (Decision 8), then map.
407
+ const covDir = mkdtempSync(join(tmpdir(), 'agent-workflow-fold-cov-'));
408
+ let coverage;
409
+ try {
410
+ const suite = spawnSync('bash', ['-c', cmd], { cwd: rootTop, env: childTestEnv(env, { NODE_V8_COVERAGE: covDir }), encoding: 'utf8', maxBuffer: GIT_MAX_BUFFER });
411
+ if (suite.error && suite.error.code === 'ENOENT') throw stop('bash is unavailable — the suite command is a bash command line');
412
+ coverage = readCoverage(covDir);
413
+ } finally {
414
+ rmSync(covDir, { recursive: true, force: true });
415
+ }
416
+ const uncoveredChanged = [];
417
+ for (const [rel, lines] of assessable) {
418
+ const perProc = coverage.get(canon(join(rootTop, rel)));
419
+ if (!perProc || perProc.length === 0) {
420
+ uncoveredChanged.push({ file: rel, line: null }); // absent from coverage → file-level RED (Decision 6)
421
+ continue;
422
+ }
423
+ const src = readFileSafe(join(rootTop, rel));
424
+ if (src == null) continue;
425
+ for (const n of computeUncoveredLines({ perProcessRanges: perProc, sourceText: src, changedLines: lines })) uncoveredChanged.push({ file: rel, line: n });
426
+ }
427
+
428
+ // Decision 3 / 10: probe each fixable-bug bound testId once (shell-free).
429
+ const ledgerPath = resolveLedgerPath(cwd, env);
430
+ const { records: reviewRecords } = ledgerPath ? readLedger(ledgerPath) : { records: [] };
431
+ const boundTestIds = collectBoundTestIds(reviewRecords, { activity: ACTIVITY, loop });
432
+ const testIds = boundTestIds.map((id) => {
433
+ const at = id.indexOf('#');
434
+ const file = id.slice(0, at);
435
+ const pattern = id.slice(at + 1);
436
+ const argv = boundArgv(file, pattern);
437
+ const res = spawnSync(argv[0], argv.slice(1), { cwd: rootTop, env: childTestEnv(env), encoding: 'utf8', maxBuffer: GIT_MAX_BUFFER });
438
+ return { id, ...parseProbeOutput({ stdout: res.stdout ?? '', code: res.error ? 1 : res.status ?? 1, fileArg: file }) };
439
+ });
440
+
441
+ const record = {
442
+ schema: RESULT_SCHEMA_VERSION,
443
+ loop,
444
+ fingerprint,
445
+ boundTestIds,
446
+ testIds,
447
+ unsupported,
448
+ outOfDomain,
449
+ coverage: { uncoveredChanged },
450
+ mutation: { total: 0, killed: 0, survived: [], skipped: 0, killSetBasis: null }, // reserved — mutation not shipped (shelved)
451
+ budgets,
452
+ timestamp: isoNow(),
453
+ };
454
+ const v = validateRunRecord(record);
455
+ if (!v.ok) throw stop(`refusing to write a malformed result record: ${v.reason}`);
456
+
457
+ const resultsPath = resolveResultsPath(cwd, env);
458
+ if (resultsPath == null) throw stop('cannot resolve the result-ledger path — not a git work tree and AW_FOLD_RESULTS is unset');
459
+ return appendRecord(resultsPath, record);
460
+ };
461
+
462
+ // ── CLI ───────────────────────────────────────────────────────────────────────────────────────────
463
+
464
+ const HELP = `fold-completeness-run — the M3 fold-completeness RUNNER (agent-workflow family, AD-046).
465
+
466
+ Usage:
467
+ node fold-completeness-run.mjs [--suite "<cmd>"] [--cwd <dir>]
468
+
469
+ Runs the in-flight plan-execution loop's suite ONCE under coverage, maps every changed executable line
470
+ to covered/uncovered, probes each fixable-bug bound testId for resolvability + a green baseline, and
471
+ appends one result record to <git dir>/${'agent-workflow-fold-completeness.jsonl'} (AW_FOLD_RESULTS
472
+ overrides). The read-only gate is a SEPARATE tool: node fold-completeness.mjs --check / --status / --json.
473
+
474
+ Suite command: --suite "<cmd>" or AW_FOLD_SUITE_CMD, else the unit-tests gate cmd in docs/ai/gates.json.
475
+ Bound-test runs default to node --test --test-name-pattern (shell-free); AW_FOLD_BOUND_CMD overrides
476
+ with a JSON argv array using {file}/{pattern}. Budgets: AW_FOLD_MUTANTS_MAX / AW_FOLD_HUNK_MUTANTS_MAX /
477
+ AW_FOLD_TIME_BUDGET_S (recorded but inert — the mutation half is not shipped).
478
+
479
+ Exit codes: 0 written; 1 a typed STOP (loop derivation / suite discovery / malformed record / fs error);
480
+ 2 usage.`;
481
+
482
+ const parseArgs = (argv) => {
483
+ const opts = { cwd: undefined, suite: undefined };
484
+ for (let i = 0; i < argv.length; i += 1) {
485
+ const a = argv[i];
486
+ if (a === '--cwd') {
487
+ opts.cwd = argv[i + 1];
488
+ if (opts.cwd === undefined) throw usageFail('--cwd needs a directory');
489
+ i += 1;
490
+ } else if (a === '--suite') {
491
+ opts.suite = argv[i + 1];
492
+ if (opts.suite === undefined) throw usageFail('--suite needs a command');
493
+ i += 1;
494
+ } else {
495
+ throw usageFail(`unknown argument: ${a}`);
496
+ }
497
+ }
498
+ return opts;
499
+ };
500
+
501
+ export const main = (argv, ctx = {}) => {
502
+ const cwd0 = ctx.cwd ?? process.cwd();
503
+ const env = ctx.env ?? process.env;
504
+ try {
505
+ if (argv.includes('--help') || argv.includes('-h')) return { code: 0, stdout: HELP, stderr: '' };
506
+ const opts = parseArgs(argv);
507
+ const { writtenPath, record } = runFoldCompleteness({ cwd: opts.cwd ?? cwd0, env, suiteCmd: opts.suite });
508
+ const uncovered = record.coverage.uncoveredChanged.length;
509
+ const unresolved = record.testIds.filter((t) => !t.resolvable || !t.baselineGreen).length;
510
+ return {
511
+ code: 0,
512
+ stdout: `fold-completeness-run: recorded a run for loop "${record.loop}" (${record.boundTestIds.length} bound testId(s), ${unresolved} unresolved/red, ${uncovered} uncovered changed line(s), ${record.unsupported.length} unsupported, ${record.outOfDomain.length} out-of-domain) → ${writtenPath}`,
513
+ stderr: '',
514
+ };
515
+ } catch (err) {
516
+ return { code: err.exitCode ?? 1, stdout: '', stderr: `fold-completeness-run: ${err.message}` };
517
+ }
518
+ };
519
+
520
+ const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
521
+ if (isDirectRun) {
522
+ const r = main(process.argv.slice(2));
523
+ if (r.stdout) process.stdout.write(r.stdout.endsWith('\n') ? r.stdout : `${r.stdout}\n`);
524
+ if (r.stderr) process.stderr.write(r.stderr.endsWith('\n') ? r.stderr : `${r.stderr}\n`);
525
+ process.exitCode = r.code;
526
+ }