@sabaiway/agent-workflow-kit 1.37.1 → 1.38.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.
- package/CHANGELOG.md +44 -0
- package/README.md +2 -2
- package/SKILL.md +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/fold-completeness.md +7 -3
- package/references/modes/review-ledger.md +3 -2
- package/tools/commands.mjs +2 -2
- package/tools/fold-completeness-run.mjs +348 -30
- package/tools/fold-completeness.mjs +247 -57
- package/tools/review-ledger-write.mjs +60 -6
- package/tools/review-ledger.mjs +69 -14
- package/tools/seed-gates.mjs +2 -2
|
@@ -12,10 +12,14 @@
|
|
|
12
12
|
// TS-JSX / out-of-domain) and derive per-file changed line ranges;
|
|
13
13
|
// 3. M3a — run the suite ONCE under NODE_V8_COVERAGE (a dir OUTSIDE the work tree, Decision 8) and
|
|
14
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
|
|
16
|
-
// resolvability +
|
|
17
|
-
//
|
|
15
|
+
// 4. probe each of the loop's fixable-bug bound testIds N times (Decision 3 / 10 + D4, shell-free,
|
|
16
|
+
// per-run timeout) for resolvability + an N/N-green baseline, hashing each bound test file
|
|
17
|
+
// (the D5 custody anchor);
|
|
18
|
+
// 5. append ONE machine-only v2 run record, bound to BOTH the tree fingerprint AND the sorted
|
|
18
19
|
// fixable-bug testId set (Decision 9), to <git dir>/agent-workflow-fold-completeness.jsonl.
|
|
20
|
+
// A SECOND verb, --red "<testId>" (BUGFREE-1 / AD-047), observes a testId RED on the current
|
|
21
|
+
// (pre-fold) tree and mints a red-probe receipt — the observed-red half of the honest red→green
|
|
22
|
+
// proof; observed-green / unresolvable / mixed / timed-out are distinguished refusals, nothing written.
|
|
19
23
|
// The researched mutation half (M3b) was SHELVED — bounded local-boundary mutation adds too little
|
|
20
24
|
// over coverage and is not language-independent — so the `mutation` field stays the reserved empty shape.
|
|
21
25
|
//
|
|
@@ -24,18 +28,20 @@
|
|
|
24
28
|
// TS/JSX source is out of scope v1. Dependency-free, Node >= 18. No side effects on import.
|
|
25
29
|
|
|
26
30
|
import { readFileSync, readdirSync, mkdtempSync, rmSync, realpathSync, lstatSync } from 'node:fs';
|
|
27
|
-
import { join, dirname, basename } from 'node:path';
|
|
31
|
+
import { join, dirname, basename, isAbsolute, normalize, posix, sep } from 'node:path';
|
|
28
32
|
import { tmpdir } from 'node:os';
|
|
29
33
|
import { pathToFileURL, fileURLToPath } from 'node:url';
|
|
30
34
|
import { spawnSync } from 'node:child_process';
|
|
35
|
+
import { createHash } from 'node:crypto';
|
|
31
36
|
import { writeContainedFileAtomic } from './atomic-write.mjs';
|
|
32
37
|
import { computeTreeFingerprint, plansInFlight } from './review-state.mjs';
|
|
33
|
-
import { resolveLedgerPath, readLedger } from './review-ledger.mjs';
|
|
38
|
+
import { resolveLedgerPath, readLedger, isWellFormedTestId, splitTestId } from './review-ledger.mjs';
|
|
34
39
|
import {
|
|
35
40
|
RESULT_SCHEMA_VERSION,
|
|
36
41
|
resolveResultsPath,
|
|
37
42
|
validateRunRecord,
|
|
38
43
|
collectBoundTestIds,
|
|
44
|
+
probeVerdict,
|
|
39
45
|
} from './fold-completeness.mjs';
|
|
40
46
|
|
|
41
47
|
const ACTIVITY = 'plan-execution';
|
|
@@ -98,7 +104,7 @@ export const parseUnifiedDiff = (diffText) => {
|
|
|
98
104
|
}
|
|
99
105
|
if (inHeader && line.startsWith('--- ')) continue;
|
|
100
106
|
if (inHeader && line.startsWith('+++ ')) {
|
|
101
|
-
const p = line.slice(4).
|
|
107
|
+
const p = unquoteDiffPath(line.slice(4).replace(/[\t\r]+$/, '')); // TAB/CR are git artifacts, never filename bytes (agy R6)
|
|
102
108
|
current = p === '/dev/null' ? null : p.startsWith('b/') ? p.slice(2) : p;
|
|
103
109
|
inHeader = false;
|
|
104
110
|
continue;
|
|
@@ -213,6 +219,59 @@ export const parseProbeOutput = ({ stdout, code, fileArg }) => {
|
|
|
213
219
|
// defaultBoundArgv(file, pattern) → the shell-free node:test argv (testId content never reaches a shell).
|
|
214
220
|
export const defaultBoundArgv = (file, pattern) => ['node', '--test', '--test-reporter', 'tap', '--test-name-pattern', pattern, file];
|
|
215
221
|
|
|
222
|
+
// ── the shared safe test-file resolver (BUGFREE-1, codex R1+R2) ───────────────────────────────────
|
|
223
|
+
// Custody hashing and every probe spawn go through THIS one resolver — the testId format itself is
|
|
224
|
+
// deliberately suffix-free and format-only (review-ledger.mjs), so path safety lives here: the file
|
|
225
|
+
// half must be repo-relative (absolute + parent-escaping refused), a REGULAR file under the no-follow
|
|
226
|
+
// lstat discipline, and its RESOLVED real path must be contained under the REAL repo root — a leaf
|
|
227
|
+
// check alone would let a symlinked PARENT directory escape the work tree.
|
|
228
|
+
|
|
229
|
+
// resolveTestFile(rootTop, rel, deps?) → { ok: true, abs } | { ok: false, reason } (never throws).
|
|
230
|
+
// deps.{lstat,realpath} are injectable so the defensive fs-race catch is unit-testable (the family
|
|
231
|
+
// deps idiom — review-ledger-write.mjs).
|
|
232
|
+
export const resolveTestFile = (rootTop, rel, deps = {}) => {
|
|
233
|
+
const lstat = deps.lstat ?? lstatSync;
|
|
234
|
+
const realpath = deps.realpath ?? realpathSync;
|
|
235
|
+
if (typeof rel !== 'string' || rel.length === 0) return { ok: false, reason: 'empty file path' };
|
|
236
|
+
if (isAbsolute(rel)) return { ok: false, reason: `absolute path "${rel}" — the testId file half must be repo-relative` };
|
|
237
|
+
const norm = normalize(rel);
|
|
238
|
+
if (norm === '..' || norm.startsWith(`..${sep}`)) return { ok: false, reason: `path "${rel}" escapes the repo root` };
|
|
239
|
+
const abs = join(rootTop, norm);
|
|
240
|
+
let st;
|
|
241
|
+
try {
|
|
242
|
+
st = lstat(abs);
|
|
243
|
+
} catch {
|
|
244
|
+
return { ok: false, reason: `file "${rel}" does not exist` };
|
|
245
|
+
}
|
|
246
|
+
if (!st.isFile()) return { ok: false, reason: `"${rel}" is not a regular file (a symlink/directory/device is never followed — fail closed)` };
|
|
247
|
+
let realAbs;
|
|
248
|
+
let realRoot;
|
|
249
|
+
try {
|
|
250
|
+
realAbs = realpath(abs);
|
|
251
|
+
realRoot = realpath(rootTop);
|
|
252
|
+
} catch {
|
|
253
|
+
return { ok: false, reason: `cannot resolve the real path of "${rel}"` };
|
|
254
|
+
}
|
|
255
|
+
if (!containsPath(realRoot, realAbs)) return { ok: false, reason: `"${rel}" resolves outside the repo root (a symlinked parent directory) — fail closed` };
|
|
256
|
+
return { ok: true, abs: realAbs };
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
// containsPath(realRoot, realAbs) → realAbs is strictly INSIDE realRoot. Segment-safe ('/a' never
|
|
260
|
+
// contains '/ab') and correct for a repo at the filesystem root, where realRoot already ends with
|
|
261
|
+
// the separator ('/'+sep would be '//' and reject every valid path — agy R1).
|
|
262
|
+
export const containsPath = (realRoot, realAbs) => realAbs.startsWith(realRoot.endsWith(sep) ? realRoot : realRoot + sep);
|
|
263
|
+
|
|
264
|
+
// The D5 custody hash: sha-256 over the file's BYTES (no encoding normalization). null on a read
|
|
265
|
+
// failure — the caller reads that as an unresolvable file (exported for the unit test of exactly
|
|
266
|
+
// that fail-closed edge).
|
|
267
|
+
export const hashFileBytes = (abs) => {
|
|
268
|
+
try {
|
|
269
|
+
return createHash('sha256').update(readFileSync(abs)).digest('hex');
|
|
270
|
+
} catch {
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
|
|
216
275
|
// resolveBoundArgv(env) → (file, pattern) => argv[]. Default = the node:test shape; AW_FOLD_BOUND_CMD
|
|
217
276
|
// overrides with a JSON array of argv strings using {file}/{pattern} placeholders (the universality
|
|
218
277
|
// escape hatch — a consumer on another runner). A malformed override is a typed refusal, never a
|
|
@@ -234,6 +293,11 @@ export const resolveBoundArgv = (env = process.env) => {
|
|
|
234
293
|
|
|
235
294
|
// ── the changed surface (git-driven) ──────────────────────────────────────────────────────────────
|
|
236
295
|
|
|
296
|
+
// The one diff-invocation shape both surface passes use. The a/ b/ prefixes are pinned EXPLICITLY
|
|
297
|
+
// (agy R6): a user's global diff.noprefix=true would otherwise drop them and the parsers would eat
|
|
298
|
+
// a real directory named "a" — user git config must never bend the parse.
|
|
299
|
+
const DIFF_FLAGS = ['--unified=0', '--no-color', '--no-ext-diff', '--no-renames', '--src-prefix=a/', '--dst-prefix=b/'];
|
|
300
|
+
|
|
237
301
|
const runGit = (args, cwd) => spawnSync('git', args, { cwd, maxBuffer: GIT_MAX_BUFFER, encoding: 'utf8', windowsHide: true });
|
|
238
302
|
const gitStdout = (args, cwd) => {
|
|
239
303
|
const r = runGit(args, cwd);
|
|
@@ -269,8 +333,8 @@ const isRegularLeaf = (abs) => {
|
|
|
269
333
|
// classified by the CLOSED rule. Tracked changed lines come from `git diff HEAD -U0`; an untracked file
|
|
270
334
|
// is wholly new, so all its lines are "changed".
|
|
271
335
|
export const computeChangedSurface = (root) => {
|
|
272
|
-
const trackedDiff = gitStdout(['diff', 'HEAD',
|
|
273
|
-
?? gitStdout(['diff',
|
|
336
|
+
const trackedDiff = gitStdout(['diff', 'HEAD', ...DIFF_FLAGS], root)
|
|
337
|
+
?? gitStdout(['diff', ...DIFF_FLAGS], root) // no HEAD yet (unborn branch)
|
|
274
338
|
?? '';
|
|
275
339
|
const trackedLines = parseUnifiedDiff(trackedDiff);
|
|
276
340
|
const untrackedZ = gitStdout(['ls-files', '--others', '--exclude-standard', '-z'], root) ?? '';
|
|
@@ -309,6 +373,109 @@ export const computeChangedSurface = (root) => {
|
|
|
309
373
|
return { assessable, unsupported: unsupported.sort(), outOfDomain: outOfDomain.sort() };
|
|
310
374
|
};
|
|
311
375
|
|
|
376
|
+
// ── the oracle-tamper surface (BUGFREE-1 Phase 2.2, Approach-3) ───────────────────────────────────
|
|
377
|
+
|
|
378
|
+
const DIFF_HUNK_OLD_RE = /^@@ -\d+(?:,(\d+))? \+\d+(?:,\d+)? @@/;
|
|
379
|
+
|
|
380
|
+
// Git C-quotes diff-header paths carrying quotes/control/non-ASCII bytes ("a/\321\202….mjs") — an
|
|
381
|
+
// unparsed quoted path compares unequal to its classifier/testId form and would silently escape the
|
|
382
|
+
// tamper/coverage surface (agy R5). Strip the quotes and decode escapes BYTE-wise (octal escapes
|
|
383
|
+
// are UTF-8 bytes). Space-only paths are not quoted (they carry a trailing TAB — trimmed upstream).
|
|
384
|
+
const CQUOTE_SIMPLE = { n: 10, t: 9, r: 13, f: 12, v: 11, b: 8, a: 7, '"': 34, '\\': 92 };
|
|
385
|
+
export const unquoteDiffPath = (p) => {
|
|
386
|
+
if (!(p.length >= 2 && p.startsWith('"') && p.endsWith('"'))) return p;
|
|
387
|
+
const inner = p.slice(1, -1);
|
|
388
|
+
const bytes = [];
|
|
389
|
+
for (let i = 0; i < inner.length; i += 1) {
|
|
390
|
+
const c = inner[i];
|
|
391
|
+
if (c !== '\\') {
|
|
392
|
+
// Consume a full CODE POINT — 16-bit-unit iteration would split a surrogate pair (an
|
|
393
|
+
// unescaped non-BMP char, reachable under core.quotepath=false) into replacement bytes (agy R6).
|
|
394
|
+
const ch = String.fromCodePoint(inner.codePointAt(i));
|
|
395
|
+
for (const b of Buffer.from(ch, 'utf8')) bytes.push(b);
|
|
396
|
+
i += ch.length - 1;
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
const rest = inner.slice(i + 1);
|
|
400
|
+
const oct = /^[0-7]{1,3}/.exec(rest);
|
|
401
|
+
if (oct) {
|
|
402
|
+
bytes.push(Number.parseInt(oct[0], 8) & 0xff);
|
|
403
|
+
i += oct[0].length;
|
|
404
|
+
} else if (rest[0] in CQUOTE_SIMPLE) {
|
|
405
|
+
bytes.push(CQUOTE_SIMPLE[rest[0]]);
|
|
406
|
+
i += 1;
|
|
407
|
+
} else if (rest[0] !== undefined) {
|
|
408
|
+
for (const b of Buffer.from(rest[0], 'utf8')) bytes.push(b);
|
|
409
|
+
i += 1;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
return Buffer.from(bytes).toString('utf8');
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
// parseDiffOldSide(diffText) → Map<oldRel, { removals: boolean }> — the OLD-side view of a -U0
|
|
416
|
+
// tracked diff: which pre-existing (HEAD) files carry any removed/modified line (a hunk whose
|
|
417
|
+
// old-count > 0). A file ADDED by the diff (--- /dev/null) has no old side and never appears; a
|
|
418
|
+
// DELETED file's hunks remove every line, so it reads removals: true. Renames under --no-renames
|
|
419
|
+
// read as delete+add — the delete side lands here (stated).
|
|
420
|
+
export const parseDiffOldSide = (diffText) => {
|
|
421
|
+
const map = new Map();
|
|
422
|
+
let current = null;
|
|
423
|
+
let inHeader = false;
|
|
424
|
+
for (const line of String(diffText).split('\n')) {
|
|
425
|
+
if (line.startsWith('diff --git ')) {
|
|
426
|
+
current = null;
|
|
427
|
+
inHeader = true;
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
if (inHeader && line.startsWith('--- ')) {
|
|
431
|
+
// Strip ONLY the git-appended trailing TAB (space-carrying paths) and a CRLF \r — never a
|
|
432
|
+
// legitimate trailing character of the filename itself (agy R6; a raw TAB in a name is
|
|
433
|
+
// C-quoted anyway, so [\t\r]+$ can only match git artifacts).
|
|
434
|
+
const p = unquoteDiffPath(line.slice(4).replace(/[\t\r]+$/, ''));
|
|
435
|
+
current = p === '/dev/null' ? null : p.startsWith('a/') ? p.slice(2) : p;
|
|
436
|
+
if (current != null && !map.has(current)) map.set(current, { removals: false });
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
if (inHeader && line.startsWith('+++ ')) {
|
|
440
|
+
inHeader = false;
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
const m = DIFF_HUNK_OLD_RE.exec(line);
|
|
444
|
+
if (m) {
|
|
445
|
+
inHeader = false;
|
|
446
|
+
if (current == null) continue;
|
|
447
|
+
const oldCount = m[1] === undefined ? 1 : Number(m[1]);
|
|
448
|
+
if (oldCount > 0) map.get(current).removals = true;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
return map;
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
// computeTamperedTests(root, boundFiles) → { tampered: [rel...] } — the tamper surface is the union
|
|
455
|
+
// of test-classified paths (TEST_FILE_RE) and the loop's bound-testId file halves (the testId format
|
|
456
|
+
// carries no suffix rule, so a bound test at a nonstandard path must not escape the guard),
|
|
457
|
+
// restricted to files that exist at HEAD (having an old side in the tracked diff IS existing at
|
|
458
|
+
// HEAD). Tampered = any removed/modified line; pure additions and new/untracked files never trip it
|
|
459
|
+
// (widening to any-change-is-tamper would flag the standard append-a-new-test flow — the FP churn
|
|
460
|
+
// this series exists to kill).
|
|
461
|
+
export const computeTamperedTests = (root, boundFiles = new Set()) => {
|
|
462
|
+
const trackedDiff = gitStdout(['diff', 'HEAD', ...DIFF_FLAGS], root)
|
|
463
|
+
?? gitStdout(['diff', ...DIFF_FLAGS], root) // no HEAD yet (unborn branch)
|
|
464
|
+
?? '';
|
|
465
|
+
// The file halves are user-authored — './checks/x.mjs' probes and hashes as 'checks/x.mjs', so
|
|
466
|
+
// the surface compares NORMALIZED halves or a modified bound file escapes the guard (codex R5).
|
|
467
|
+
// Normalization happens in GIT/POSIX path space (codex+agy R6): node's OS-local normalize emits
|
|
468
|
+
// backslashes on Windows while git diff paths stay slash-separated — the compare must never
|
|
469
|
+
// depend on the host separator.
|
|
470
|
+
const boundSet = new Set([...boundFiles].map((f) => posix.normalize(f)));
|
|
471
|
+
const tampered = [];
|
|
472
|
+
for (const [rel, info] of parseDiffOldSide(trackedDiff)) {
|
|
473
|
+
if (!info.removals) continue;
|
|
474
|
+
if (classifyChangedPath(rel) === 'excluded-test' || boundSet.has(rel)) tampered.push(rel);
|
|
475
|
+
}
|
|
476
|
+
return { tampered: tampered.sort() };
|
|
477
|
+
};
|
|
478
|
+
|
|
312
479
|
// ── coverage (run the suite once under NODE_V8_COVERAGE, outside the tree) ─────────────────────────
|
|
313
480
|
|
|
314
481
|
// readCoverage(covDir) → Map<canonicalAbsPath, Array<Array<range>>> — one flat range-list per process.
|
|
@@ -382,12 +549,71 @@ const appendRecord = (resultsPath, record) => {
|
|
|
382
549
|
|
|
383
550
|
// ── the run ───────────────────────────────────────────────────────────────────────────────────────
|
|
384
551
|
|
|
385
|
-
|
|
552
|
+
// The shared fail-closed integer parser for the D4 probe knobs: zero / negative / fractional /
|
|
553
|
+
// non-numeric values are typed refusals by name — the parseInt(...)||default idiom would silently
|
|
554
|
+
// accept bad truthy values (codex R2). Unset → the default; set → a positive integer, exactly.
|
|
555
|
+
const parsePositiveIntKnob = (env, name, fallback) => {
|
|
556
|
+
const raw = env[name];
|
|
557
|
+
if (raw === undefined) return fallback;
|
|
558
|
+
if (!/^\d+$/.test(String(raw).trim()) || Number.parseInt(raw, 10) < 1) {
|
|
559
|
+
throw stop(`${name} must be a positive integer (got "${raw}") — refusing to guess (fail closed)`);
|
|
560
|
+
}
|
|
561
|
+
return Number.parseInt(raw, 10);
|
|
562
|
+
};
|
|
563
|
+
|
|
564
|
+
export const budgetsFromEnv = (env) => ({
|
|
386
565
|
mutantsMax: Number.parseInt(env.AW_FOLD_MUTANTS_MAX ?? '200', 10) || 200,
|
|
387
566
|
hunkMutantsMax: Number.parseInt(env.AW_FOLD_HUNK_MUTANTS_MAX ?? '25', 10) || 25,
|
|
388
567
|
timeBudgetS: Number.parseInt(env.AW_FOLD_TIME_BUDGET_S ?? '600', 10) || 600,
|
|
568
|
+
// D4: N reruns per probe side; the per-RUN probe timeout (each of the N runs gets its own budget,
|
|
569
|
+
// never one shared series budget — agy R1). Probes only: the suite run keeps the no-timeout status quo.
|
|
570
|
+
foldReruns: parsePositiveIntKnob(env, 'AW_FOLD_RERUNS', 3),
|
|
571
|
+
probeTimeoutS: parsePositiveIntKnob(env, 'AW_FOLD_PROBE_TIMEOUT_S', 120),
|
|
389
572
|
});
|
|
390
573
|
|
|
574
|
+
// ── the shared N-rerun probe (D4) — ONE helper for both the run's green side and the --red verb ───
|
|
575
|
+
|
|
576
|
+
// probeBound({ id, rootTop, env, boundArgv, reruns, timeoutS }) → { entry, resolveReason }. The entry
|
|
577
|
+
// is the v2 per-testId record shape: rerun counts (evidence) + the custody content hash + the derived
|
|
578
|
+
// booleans. The custody hash is taken BEFORE the runs (the content the observation attests). A
|
|
579
|
+
// timed-out or signal-killed run is neither red nor green — it lands in `timeouts` (quarantine fuel).
|
|
580
|
+
const probeBound = ({ id, rootTop, env, boundArgv, reruns, timeoutS }) => {
|
|
581
|
+
const { file, pattern } = splitTestId(id);
|
|
582
|
+
const resolved = resolveTestFile(rootTop, file);
|
|
583
|
+
const fileHash = resolved.ok ? hashFileBytes(resolved.abs) : null;
|
|
584
|
+
let executed = 0;
|
|
585
|
+
let greens = 0;
|
|
586
|
+
let reds = 0;
|
|
587
|
+
let timeouts = 0;
|
|
588
|
+
if (resolved.ok && fileHash != null) {
|
|
589
|
+
// ALWAYS spawn the resolver's canonical absolute path — the executed file must be the hashed
|
|
590
|
+
// file independent of runner path semantics (codex R1+R2, BUGFREE-1 live loop): a raw
|
|
591
|
+
// leading-dash filename parses as an OPTION (and a ./-prefix does not survive node's runner
|
|
592
|
+
// normalization), and a raw traversal path like linkdir/../x lets an OS-resolving runner
|
|
593
|
+
// execute a different filesystem target than the lexically-normalized file the hash covers.
|
|
594
|
+
const argv = boundArgv(resolved.abs, pattern);
|
|
595
|
+
for (let i = 0; i < reruns; i += 1) {
|
|
596
|
+
const res = spawnSync(argv[0], argv.slice(1), {
|
|
597
|
+
cwd: rootTop, env: childTestEnv(env), encoding: 'utf8', maxBuffer: GIT_MAX_BUFFER, timeout: timeoutS * 1000,
|
|
598
|
+
});
|
|
599
|
+
if ((res.error && res.error.code === 'ETIMEDOUT') || res.signal != null) {
|
|
600
|
+
timeouts += 1;
|
|
601
|
+
continue;
|
|
602
|
+
}
|
|
603
|
+
const p = parseProbeOutput({ stdout: res.stdout ?? '', code: res.error ? 1 : res.status ?? 1, fileArg: file });
|
|
604
|
+
executed = Math.max(executed, p.executed);
|
|
605
|
+
if (!p.resolvable) continue; // an unresolved run (the pattern selected nothing)
|
|
606
|
+
if (p.baselineGreen) greens += 1;
|
|
607
|
+
else reds += 1;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
const entry = {
|
|
611
|
+
id, executed, runs: reruns, greens, reds, timeouts, fileHash,
|
|
612
|
+
resolvable: greens + reds === reruns, baselineGreen: greens === reruns,
|
|
613
|
+
};
|
|
614
|
+
return { entry, resolveReason: resolved.ok ? (fileHash == null ? `cannot read "${file}"` : null) : resolved.reason };
|
|
615
|
+
};
|
|
616
|
+
|
|
391
617
|
// runFoldCompleteness({ cwd, env, suiteCmd }) → { writtenPath, record }. THROWS a typed STOP (loop
|
|
392
618
|
// derivation / suite discovery / a malformed record / an fs error) or a native fs error.
|
|
393
619
|
export const runFoldCompleteness = ({ cwd = process.cwd(), env = process.env, suiteCmd } = {}) => {
|
|
@@ -430,21 +656,21 @@ export const runFoldCompleteness = ({ cwd = process.cwd(), env = process.env, su
|
|
|
430
656
|
for (const n of computeUncoveredLines({ perProcessRanges: perProc, sourceText: src, changedLines: lines })) uncoveredChanged.push({ file: rel, line: n });
|
|
431
657
|
}
|
|
432
658
|
|
|
433
|
-
// Decision 3 / 10: probe each fixable-bug bound testId
|
|
659
|
+
// Decision 3 / 10 + D4: probe each fixable-bug bound testId N times (shell-free, per-run timeout).
|
|
434
660
|
const ledgerPath = resolveLedgerPath(cwd, env);
|
|
435
661
|
const { records: reviewRecords } = ledgerPath ? readLedger(ledgerPath) : { records: [] };
|
|
436
662
|
const boundTestIds = collectBoundTestIds(reviewRecords, { activity: ACTIVITY, loop });
|
|
437
|
-
const testIds = boundTestIds.map(
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
});
|
|
663
|
+
const testIds = boundTestIds.map(
|
|
664
|
+
(id) => probeBound({ id, rootTop, env, boundArgv, reruns: budgets.foldReruns, timeoutS: budgets.probeTimeoutS }).entry,
|
|
665
|
+
);
|
|
666
|
+
|
|
667
|
+
// Approach-3: the oracle-tamper pass over the tracked working-vs-HEAD diff, restricted to the
|
|
668
|
+
// test surface ∪ the bound-testId file halves. Recorded; the checker enforces overrides.
|
|
669
|
+
const tamper = computeTamperedTests(rootTop, new Set(boundTestIds.map((id) => splitTestId(id).file)));
|
|
445
670
|
|
|
446
671
|
const record = {
|
|
447
672
|
schema: RESULT_SCHEMA_VERSION,
|
|
673
|
+
kind: 'run',
|
|
448
674
|
loop,
|
|
449
675
|
fingerprint,
|
|
450
676
|
boundTestIds,
|
|
@@ -452,6 +678,7 @@ export const runFoldCompleteness = ({ cwd = process.cwd(), env = process.env, su
|
|
|
452
678
|
unsupported,
|
|
453
679
|
outOfDomain,
|
|
454
680
|
coverage: { uncoveredChanged },
|
|
681
|
+
tamper,
|
|
455
682
|
mutation: { total: 0, killed: 0, survived: [], skipped: 0, killSetBasis: null }, // reserved — mutation not shipped (shelved)
|
|
456
683
|
budgets,
|
|
457
684
|
timestamp: isoNow(),
|
|
@@ -464,28 +691,106 @@ export const runFoldCompleteness = ({ cwd = process.cwd(), env = process.env, su
|
|
|
464
691
|
return appendRecord(resultsPath, record);
|
|
465
692
|
};
|
|
466
693
|
|
|
694
|
+
// ── the --red verb (D6): observe RED when it actually happens, mint the custody receipt ──────────
|
|
695
|
+
|
|
696
|
+
// runRedProbe({ cwd, env, testId }) → { writtenPath, record }. Observes `testId` on the CURRENT
|
|
697
|
+
// (pre-fold) tree: resolvable + failing on N/N runs → appends a red-probe receipt (testId, counts,
|
|
698
|
+
// the test file's content hash, fingerprint, timestamp) to the fold results ledger. Observed green,
|
|
699
|
+
// unresolvable, mixed, or timed out → a typed refusal DISTINGUISHED by name, and NOTHING is written
|
|
700
|
+
// (D4: mixed/timeout is QUARANTINE — it never converts and has no override lane). No triage-order
|
|
701
|
+
// requirement: the checker joins receipts to the bound set at gate time (D6).
|
|
702
|
+
export const runRedProbe = ({ cwd = process.cwd(), env = process.env, testId } = {}) => {
|
|
703
|
+
if (!isWellFormedTestId(testId)) {
|
|
704
|
+
throw usageFail(`--red needs a well-formed testId "<test-file>#<test-name-pattern>" (a "#" separator, both halves non-empty; got ${JSON.stringify(testId)})`);
|
|
705
|
+
}
|
|
706
|
+
const root = gitStdout(['rev-parse', '--show-toplevel'], cwd);
|
|
707
|
+
if (root == null) throw stop('not a git work tree — nothing to observe');
|
|
708
|
+
const rootTop = root.replace(/\r?\n$/, '');
|
|
709
|
+
const plans = plansInFlight(rootTop);
|
|
710
|
+
if (plans.length === 0) throw stop('no plan in flight (docs/plans/ holds no active plan) — nothing to observe');
|
|
711
|
+
if (plans.length > 1) throw stop(`more than one plan in flight (${plans.join(', ')}) — ambiguous loop id; resolve to one active plan`);
|
|
712
|
+
const loop = plans[0].replace(/\.md$/, '');
|
|
713
|
+
|
|
714
|
+
const boundArgv = resolveBoundArgv(env);
|
|
715
|
+
const budgets = budgetsFromEnv(env);
|
|
716
|
+
const { entry, resolveReason } = probeBound({ id: testId, rootTop, env, boundArgv, reruns: budgets.foldReruns, timeoutS: budgets.probeTimeoutS });
|
|
717
|
+
const verdict = probeVerdict(entry);
|
|
718
|
+
const counts = `${entry.greens} green / ${entry.reds} red / ${entry.timeouts} timed out / ${entry.runs - entry.greens - entry.reds - entry.timeouts} unresolved of ${entry.runs} run(s)`;
|
|
719
|
+
if (verdict === 'unresolvable') {
|
|
720
|
+
throw stop(
|
|
721
|
+
`--red refused for "${testId}": unresolvable — ${resolveReason ?? 'the pattern selects no test'} (${counts}). ` +
|
|
722
|
+
`If the test cannot even LOAD pre-fold (it imports an export the fix introduces), author it with a dynamic import() so it loads and FAILS pre-fold; ` +
|
|
723
|
+
`if the red is genuinely unestablishable, the loud escape is a recorded red-proof override (review-ledger-write override). Nothing was recorded.`,
|
|
724
|
+
);
|
|
725
|
+
}
|
|
726
|
+
if (verdict === 'green') {
|
|
727
|
+
throw stop(
|
|
728
|
+
`--red refused for "${testId}": observed GREEN on ${entry.greens}/${entry.runs} runs — the test does not fail on the current (pre-fold) tree, so it proves nothing about the fix. ` +
|
|
729
|
+
`Write a test that FAILS before the fix is applied, then re-run --red BEFORE folding the fix. Nothing was recorded.`,
|
|
730
|
+
);
|
|
731
|
+
}
|
|
732
|
+
if (verdict === 'quarantine') {
|
|
733
|
+
const flavor = entry.timeouts > 0
|
|
734
|
+
? `${entry.timeouts} of ${entry.runs} probe run(s) timed out (AW_FOLD_PROBE_TIMEOUT_S=${budgets.probeTimeoutS}) — a timed-out run is neither red nor green`
|
|
735
|
+
: `mixed outcomes (${counts}) — a flaky test can launder a fake red`;
|
|
736
|
+
throw stop(
|
|
737
|
+
`--red refused for "${testId}": QUARANTINE — ${flavor}. QUARANTINE never converts and has no override lane: ` +
|
|
738
|
+
`${entry.timeouts > 0 ? 'raise the timeout or make the test faster' : 'replace the flaky test'}, then re-observe. Nothing was recorded.`,
|
|
739
|
+
);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
const record = {
|
|
743
|
+
schema: RESULT_SCHEMA_VERSION,
|
|
744
|
+
kind: 'red-probe',
|
|
745
|
+
loop,
|
|
746
|
+
testId,
|
|
747
|
+
fileHash: entry.fileHash,
|
|
748
|
+
runs: entry.runs,
|
|
749
|
+
reds: entry.reds,
|
|
750
|
+
fingerprint: computeTreeFingerprint(cwd),
|
|
751
|
+
timestamp: isoNow(),
|
|
752
|
+
};
|
|
753
|
+
const v = validateRunRecord(record);
|
|
754
|
+
if (!v.ok) throw stop(`refusing to write a malformed red-probe record: ${v.reason}`);
|
|
755
|
+
const resultsPath = resolveResultsPath(cwd, env);
|
|
756
|
+
if (resultsPath == null) throw stop('cannot resolve the result-ledger path — not a git work tree and AW_FOLD_RESULTS is unset');
|
|
757
|
+
return appendRecord(resultsPath, record);
|
|
758
|
+
};
|
|
759
|
+
|
|
467
760
|
// ── CLI ───────────────────────────────────────────────────────────────────────────────────────────
|
|
468
761
|
|
|
469
|
-
const HELP = `fold-completeness-run — the M3 fold-completeness RUNNER (agent-workflow family, AD-046).
|
|
762
|
+
const HELP = `fold-completeness-run — the M3 fold-completeness RUNNER (agent-workflow family, AD-046 + AD-047).
|
|
470
763
|
|
|
471
764
|
Usage:
|
|
472
765
|
node fold-completeness-run.mjs [--suite "<cmd>"] [--cwd <dir>]
|
|
766
|
+
node fold-completeness-run.mjs --red "<test-file>#<test-name-pattern>" [--cwd <dir>]
|
|
767
|
+
|
|
768
|
+
The default run: runs the in-flight plan-execution loop's suite ONCE under coverage, maps every
|
|
769
|
+
changed executable line to covered/uncovered, probes each fixable-bug bound testId N times
|
|
770
|
+
(AW_FOLD_RERUNS, default 3) for resolvability + an N/N-green baseline, records each bound test file's
|
|
771
|
+
content hash (custody), and appends one v2 run record to
|
|
772
|
+
<git dir>/${'agent-workflow-fold-completeness.jsonl'} (AW_FOLD_RESULTS overrides).
|
|
473
773
|
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
774
|
+
--red observes a testId RED on the CURRENT (pre-fold) tree — the honest fold-time order is: classify
|
|
775
|
+
the fixable-bug with its testId → write the test → --red observes it FAIL (N/N) BEFORE the fix is
|
|
776
|
+
applied → fold the fix → the normal run observes green → the gate checks receipt + order + custody.
|
|
777
|
+
An N/N red mints a red-probe receipt (testId, counts, content hash, fingerprint); observed-green /
|
|
778
|
+
unresolvable / mixed / timed-out are DISTINGUISHED typed refusals and nothing is written
|
|
779
|
+
(mixed/timeout = QUARANTINE — never converts, no override lane).
|
|
478
780
|
|
|
479
781
|
Suite command: --suite "<cmd>" or AW_FOLD_SUITE_CMD, else the unit-tests gate cmd in docs/ai/gates.json.
|
|
480
|
-
Bound-test
|
|
481
|
-
with a JSON argv array using {file}/{pattern}.
|
|
482
|
-
|
|
782
|
+
Bound-test probes default to node --test --test-name-pattern (shell-free); AW_FOLD_BOUND_CMD overrides
|
|
783
|
+
with a JSON argv array using {file}/{pattern}. Probe knobs (fail-closed positive integers):
|
|
784
|
+
AW_FOLD_RERUNS (default 3) · AW_FOLD_PROBE_TIMEOUT_S (default 120, per probe RUN, probes only).
|
|
785
|
+
Inert budgets: AW_FOLD_MUTANTS_MAX / AW_FOLD_HUNK_MUTANTS_MAX / AW_FOLD_TIME_BUDGET_S (mutation shelved).
|
|
786
|
+
|
|
787
|
+
The read-only gate is a SEPARATE tool: node fold-completeness.mjs --check / --status / --json.
|
|
483
788
|
|
|
484
|
-
Exit codes: 0 written; 1 a typed STOP (loop derivation / suite discovery /
|
|
485
|
-
2 usage.`;
|
|
789
|
+
Exit codes: 0 written; 1 a typed STOP (loop derivation / suite discovery / a --red refusal / malformed
|
|
790
|
+
record / fs error); 2 usage.`;
|
|
486
791
|
|
|
487
792
|
const parseArgs = (argv) => {
|
|
488
|
-
const opts = { cwd: undefined, suite: undefined };
|
|
793
|
+
const opts = { cwd: undefined, suite: undefined, red: undefined };
|
|
489
794
|
for (let i = 0; i < argv.length; i += 1) {
|
|
490
795
|
const a = argv[i];
|
|
491
796
|
if (a === '--cwd') {
|
|
@@ -496,6 +801,10 @@ const parseArgs = (argv) => {
|
|
|
496
801
|
opts.suite = argv[i + 1];
|
|
497
802
|
if (opts.suite === undefined) throw usageFail('--suite needs a command');
|
|
498
803
|
i += 1;
|
|
804
|
+
} else if (a === '--red') {
|
|
805
|
+
opts.red = argv[i + 1];
|
|
806
|
+
if (opts.red === undefined) throw usageFail('--red needs a testId ("<test-file>#<test-name-pattern>")');
|
|
807
|
+
i += 1;
|
|
499
808
|
} else {
|
|
500
809
|
throw usageFail(`unknown argument: ${a}`);
|
|
501
810
|
}
|
|
@@ -509,7 +818,16 @@ export const main = (argv, ctx = {}) => {
|
|
|
509
818
|
try {
|
|
510
819
|
if (argv.includes('--help') || argv.includes('-h')) return { code: 0, stdout: HELP, stderr: '' };
|
|
511
820
|
const opts = parseArgs(argv);
|
|
512
|
-
const
|
|
821
|
+
const cwd = opts.cwd ?? cwd0;
|
|
822
|
+
if (opts.red !== undefined) {
|
|
823
|
+
const { writtenPath, record } = runRedProbe({ cwd, env, testId: opts.red });
|
|
824
|
+
return {
|
|
825
|
+
code: 0,
|
|
826
|
+
stdout: `fold-completeness-run: minted a red-probe receipt for "${record.testId}" (loop "${record.loop}", ${record.reds}/${record.runs} observed red, hash ${record.fileHash.slice(0, 12)}…) → ${writtenPath}`,
|
|
827
|
+
stderr: '',
|
|
828
|
+
};
|
|
829
|
+
}
|
|
830
|
+
const { writtenPath, record } = runFoldCompleteness({ cwd, env, suiteCmd: opts.suite });
|
|
513
831
|
const uncovered = record.coverage.uncoveredChanged.length;
|
|
514
832
|
const unresolved = record.testIds.filter((t) => !t.resolvable || !t.baselineGreen).length;
|
|
515
833
|
return {
|