@sabaiway/agent-workflow-kit 1.37.1 → 1.39.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 +113 -0
- package/README.md +3 -3
- 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/gates.md +5 -3
- package/references/modes/review-ledger.md +12 -7
- package/references/modes/velocity.md +1 -1
- package/tools/changed-surface.mjs +294 -0
- package/tools/commands.mjs +3 -3
- package/tools/fold-completeness-run.mjs +310 -139
- package/tools/fold-completeness.mjs +292 -77
- package/tools/procedures.mjs +1 -1
- package/tools/review-ledger-write.mjs +241 -43
- package/tools/review-ledger.mjs +425 -43
- package/tools/run-gates.mjs +58 -7
- package/tools/seed-gates.mjs +2 -2
|
@@ -12,10 +12,15 @@
|
|
|
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
|
-
//
|
|
18
|
-
//
|
|
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 v3 run record — segment-framed (base = git rev-parse HEAD, AD-048 D7)
|
|
19
|
+
// and bound to BOTH the tree fingerprint AND the SEGMENT's sorted fixable-bug testId set
|
|
20
|
+
// (Decision 9) — to <git dir>/agent-workflow-fold-completeness.jsonl.
|
|
21
|
+
// A SECOND verb, --red "<testId>" (BUGFREE-1 / AD-047), observes a testId RED on the current
|
|
22
|
+
// (pre-fold) tree and mints a red-probe receipt — the observed-red half of the honest red→green
|
|
23
|
+
// proof; observed-green / unresolvable / mixed / timed-out are distinguished refusals, nothing written.
|
|
19
24
|
// The researched mutation half (M3b) was SHELVED — bounded local-boundary mutation adds too little
|
|
20
25
|
// over coverage and is not language-independent — so the `mutation` field stays the reserved empty shape.
|
|
21
26
|
//
|
|
@@ -24,19 +29,28 @@
|
|
|
24
29
|
// TS/JSX source is out of scope v1. Dependency-free, Node >= 18. No side effects on import.
|
|
25
30
|
|
|
26
31
|
import { readFileSync, readdirSync, mkdtempSync, rmSync, realpathSync, lstatSync } from 'node:fs';
|
|
27
|
-
import { join, dirname, basename } from 'node:path';
|
|
32
|
+
import { join, dirname, basename, isAbsolute, normalize, posix, sep } from 'node:path';
|
|
28
33
|
import { tmpdir } from 'node:os';
|
|
29
34
|
import { pathToFileURL, fileURLToPath } from 'node:url';
|
|
30
35
|
import { spawnSync } from 'node:child_process';
|
|
36
|
+
import { createHash } from 'node:crypto';
|
|
31
37
|
import { writeContainedFileAtomic } from './atomic-write.mjs';
|
|
32
38
|
import { computeTreeFingerprint, plansInFlight } from './review-state.mjs';
|
|
33
|
-
import { resolveLedgerPath, readLedger } from './review-ledger.mjs';
|
|
39
|
+
import { resolveLedgerPath, resolveBase, readLedger, isWellFormedTestId, splitTestId } from './review-ledger.mjs';
|
|
34
40
|
import {
|
|
35
41
|
RESULT_SCHEMA_VERSION,
|
|
36
42
|
resolveResultsPath,
|
|
37
43
|
validateRunRecord,
|
|
38
44
|
collectBoundTestIds,
|
|
45
|
+
probeVerdict,
|
|
39
46
|
} from './fold-completeness.mjs';
|
|
47
|
+
// The changed-surface computation lives in the NEUTRAL shared module (BUGFREE-2 / AD-048, D4): the
|
|
48
|
+
// review-ledger writer's diff-size cap and this runner's coverage domain consume ONE computation,
|
|
49
|
+
// and the writer never imports this runner (the sole-tree-toucher boundary — import-split pinned).
|
|
50
|
+
// Re-exported below so the runner's tests (and any consumer) keep one entry point per concern.
|
|
51
|
+
import { classifyChangedPath, parseUnifiedDiff, unquoteDiffPath, computeChangedSurface, DIFF_FLAGS, parsePositiveIntKnob } from './changed-surface.mjs';
|
|
52
|
+
|
|
53
|
+
export { classifyChangedPath, parseUnifiedDiff, unquoteDiffPath, computeChangedSurface };
|
|
40
54
|
|
|
41
55
|
const ACTIVITY = 'plan-execution';
|
|
42
56
|
const GIT_MAX_BUFFER = 256 * 1024 * 1024; // a full-tree diff / full-suite TAP can be large; never truncate
|
|
@@ -61,65 +75,6 @@ const childTestEnv = (env, extra = {}) => {
|
|
|
61
75
|
return out;
|
|
62
76
|
};
|
|
63
77
|
|
|
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
78
|
// ── Decision 6: V8 coverage → uncovered changed lines (innermost-range-wins) ──────────────────────
|
|
124
79
|
|
|
125
80
|
// lineStartOffsets(sourceText) → char offset of each line start (index i == line i+1). Splitting on
|
|
@@ -210,8 +165,65 @@ export const parseProbeOutput = ({ stdout, code, fileArg }) => {
|
|
|
210
165
|
return { resolvable, executed: matched, baselineGreen: resolvable && code === 0 && fails === 0 };
|
|
211
166
|
};
|
|
212
167
|
|
|
213
|
-
// defaultBoundArgv(file, pattern) → the shell-free node:test argv (testId content never reaches a
|
|
214
|
-
|
|
168
|
+
// defaultBoundArgv(file, pattern) → the shell-free node:test argv (testId content never reaches a
|
|
169
|
+
// shell). The pattern rides in the `=`-joined form: as a SEPARATE argv token a pattern beginning
|
|
170
|
+
// with "-"/"--" (a test name like "--telemetry refuses …") parses as an OPTION and the probe
|
|
171
|
+
// silently selects no test — the pattern-half sibling of the AD-047 dash-spawn file fix (found
|
|
172
|
+
// live by this plan's own --red loop).
|
|
173
|
+
export const defaultBoundArgv = (file, pattern) => ['node', '--test', '--test-reporter', 'tap', `--test-name-pattern=${pattern}`, file];
|
|
174
|
+
|
|
175
|
+
// ── the shared safe test-file resolver (BUGFREE-1, codex R1+R2) ───────────────────────────────────
|
|
176
|
+
// Custody hashing and every probe spawn go through THIS one resolver — the testId format itself is
|
|
177
|
+
// deliberately suffix-free and format-only (review-ledger.mjs), so path safety lives here: the file
|
|
178
|
+
// half must be repo-relative (absolute + parent-escaping refused), a REGULAR file under the no-follow
|
|
179
|
+
// lstat discipline, and its RESOLVED real path must be contained under the REAL repo root — a leaf
|
|
180
|
+
// check alone would let a symlinked PARENT directory escape the work tree.
|
|
181
|
+
|
|
182
|
+
// resolveTestFile(rootTop, rel, deps?) → { ok: true, abs } | { ok: false, reason } (never throws).
|
|
183
|
+
// deps.{lstat,realpath} are injectable so the defensive fs-race catch is unit-testable (the family
|
|
184
|
+
// deps idiom — review-ledger-write.mjs).
|
|
185
|
+
export const resolveTestFile = (rootTop, rel, deps = {}) => {
|
|
186
|
+
const lstat = deps.lstat ?? lstatSync;
|
|
187
|
+
const realpath = deps.realpath ?? realpathSync;
|
|
188
|
+
if (typeof rel !== 'string' || rel.length === 0) return { ok: false, reason: 'empty file path' };
|
|
189
|
+
if (isAbsolute(rel)) return { ok: false, reason: `absolute path "${rel}" — the testId file half must be repo-relative` };
|
|
190
|
+
const norm = normalize(rel);
|
|
191
|
+
if (norm === '..' || norm.startsWith(`..${sep}`)) return { ok: false, reason: `path "${rel}" escapes the repo root` };
|
|
192
|
+
const abs = join(rootTop, norm);
|
|
193
|
+
let st;
|
|
194
|
+
try {
|
|
195
|
+
st = lstat(abs);
|
|
196
|
+
} catch {
|
|
197
|
+
return { ok: false, reason: `file "${rel}" does not exist` };
|
|
198
|
+
}
|
|
199
|
+
if (!st.isFile()) return { ok: false, reason: `"${rel}" is not a regular file (a symlink/directory/device is never followed — fail closed)` };
|
|
200
|
+
let realAbs;
|
|
201
|
+
let realRoot;
|
|
202
|
+
try {
|
|
203
|
+
realAbs = realpath(abs);
|
|
204
|
+
realRoot = realpath(rootTop);
|
|
205
|
+
} catch {
|
|
206
|
+
return { ok: false, reason: `cannot resolve the real path of "${rel}"` };
|
|
207
|
+
}
|
|
208
|
+
if (!containsPath(realRoot, realAbs)) return { ok: false, reason: `"${rel}" resolves outside the repo root (a symlinked parent directory) — fail closed` };
|
|
209
|
+
return { ok: true, abs: realAbs };
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
// containsPath(realRoot, realAbs) → realAbs is strictly INSIDE realRoot. Segment-safe ('/a' never
|
|
213
|
+
// contains '/ab') and correct for a repo at the filesystem root, where realRoot already ends with
|
|
214
|
+
// the separator ('/'+sep would be '//' and reject every valid path — agy R1).
|
|
215
|
+
export const containsPath = (realRoot, realAbs) => realAbs.startsWith(realRoot.endsWith(sep) ? realRoot : realRoot + sep);
|
|
216
|
+
|
|
217
|
+
// The D5 custody hash: sha-256 over the file's BYTES (no encoding normalization). null on a read
|
|
218
|
+
// failure — the caller reads that as an unresolvable file (exported for the unit test of exactly
|
|
219
|
+
// that fail-closed edge).
|
|
220
|
+
export const hashFileBytes = (abs) => {
|
|
221
|
+
try {
|
|
222
|
+
return createHash('sha256').update(readFileSync(abs)).digest('hex');
|
|
223
|
+
} catch {
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
};
|
|
215
227
|
|
|
216
228
|
// resolveBoundArgv(env) → (file, pattern) => argv[]. Default = the node:test shape; AW_FOLD_BOUND_CMD
|
|
217
229
|
// overrides with a JSON array of argv strings using {file}/{pattern} placeholders (the universality
|
|
@@ -232,7 +244,7 @@ export const resolveBoundArgv = (env = process.env) => {
|
|
|
232
244
|
return (file, pattern) => tmpl.map((a) => a.replace(/\{file\}/g, () => file).replace(/\{pattern\}/g, () => pattern));
|
|
233
245
|
};
|
|
234
246
|
|
|
235
|
-
// ── the changed
|
|
247
|
+
// ── read-only git plumbing (the changed-surface computation itself lives in changed-surface.mjs) ──
|
|
236
248
|
|
|
237
249
|
const runGit = (args, cwd) => spawnSync('git', args, { cwd, maxBuffer: GIT_MAX_BUFFER, encoding: 'utf8', windowsHide: true });
|
|
238
250
|
const gitStdout = (args, cwd) => {
|
|
@@ -253,60 +265,72 @@ const canon = (path) => {
|
|
|
253
265
|
return path;
|
|
254
266
|
}
|
|
255
267
|
};
|
|
256
|
-
//
|
|
257
|
-
// or non-regular *.mjs must NEVER be read/canonicalized — following it could read outside the work
|
|
258
|
-
// tree or HANG on a FIFO/device (codex R2). A non-regular leaf fails closed (routed to `unsupported`).
|
|
259
|
-
const isRegularLeaf = (abs) => {
|
|
260
|
-
try {
|
|
261
|
-
return lstatSync(abs).isFile();
|
|
262
|
-
} catch {
|
|
263
|
-
return false;
|
|
264
|
-
}
|
|
265
|
-
};
|
|
268
|
+
// ── the oracle-tamper surface (BUGFREE-1 Phase 2.2, Approach-3) ───────────────────────────────────
|
|
266
269
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
//
|
|
270
|
-
//
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
const
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
if (cls === 'excluded-test') return;
|
|
284
|
-
if (cls === 'assessable') {
|
|
285
|
-
if (isRegularLeaf(join(root, rel))) assessable.set(rel, lines);
|
|
286
|
-
else unsupported.push(rel); // a symlinked / non-regular source → fail closed, never followed
|
|
287
|
-
return;
|
|
270
|
+
const DIFF_HUNK_OLD_RE = /^@@ -\d+(?:,(\d+))? \+\d+(?:,\d+)? @@/;
|
|
271
|
+
|
|
272
|
+
// parseDiffOldSide(diffText) → Map<oldRel, { removals: boolean }> — the OLD-side view of a -U0
|
|
273
|
+
// tracked diff: which pre-existing (HEAD) files carry any removed/modified line (a hunk whose
|
|
274
|
+
// old-count > 0). A file ADDED by the diff (--- /dev/null) has no old side and never appears; a
|
|
275
|
+
// DELETED file's hunks remove every line, so it reads removals: true. Renames under --no-renames
|
|
276
|
+
// read as delete+add — the delete side lands here (stated).
|
|
277
|
+
export const parseDiffOldSide = (diffText) => {
|
|
278
|
+
const map = new Map();
|
|
279
|
+
let current = null;
|
|
280
|
+
let inHeader = false;
|
|
281
|
+
for (const line of String(diffText).split('\n')) {
|
|
282
|
+
if (line.startsWith('diff --git ')) {
|
|
283
|
+
current = null;
|
|
284
|
+
inHeader = true;
|
|
285
|
+
continue;
|
|
288
286
|
}
|
|
289
|
-
if (
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
place(rel, cls, []);
|
|
287
|
+
if (inHeader && line.startsWith('--- ')) {
|
|
288
|
+
// Strip ONLY the git-appended trailing TAB (space-carrying paths) and a CRLF \r — never a
|
|
289
|
+
// legitimate trailing character of the filename itself (agy R6; a raw TAB in a name is
|
|
290
|
+
// C-quoted anyway, so [\t\r]+$ can only match git artifacts).
|
|
291
|
+
const p = unquoteDiffPath(line.slice(4).replace(/[\t\r]+$/, ''));
|
|
292
|
+
current = p === '/dev/null' ? null : p.startsWith('a/') ? p.slice(2) : p;
|
|
293
|
+
if (current != null && !map.has(current)) map.set(current, { removals: false });
|
|
297
294
|
continue;
|
|
298
295
|
}
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
if (!isRegularLeaf(abs)) {
|
|
302
|
-
unsupported.push(rel);
|
|
296
|
+
if (inHeader && line.startsWith('+++ ')) {
|
|
297
|
+
inHeader = false;
|
|
303
298
|
continue;
|
|
304
299
|
}
|
|
305
|
-
const
|
|
306
|
-
|
|
307
|
-
|
|
300
|
+
const m = DIFF_HUNK_OLD_RE.exec(line);
|
|
301
|
+
if (m) {
|
|
302
|
+
inHeader = false;
|
|
303
|
+
if (current == null) continue;
|
|
304
|
+
const oldCount = m[1] === undefined ? 1 : Number(m[1]);
|
|
305
|
+
if (oldCount > 0) map.get(current).removals = true;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return map;
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
// computeTamperedTests(root, boundFiles) → { tampered: [rel...] } — the tamper surface is the union
|
|
312
|
+
// of test-classified paths (TEST_FILE_RE) and the loop's bound-testId file halves (the testId format
|
|
313
|
+
// carries no suffix rule, so a bound test at a nonstandard path must not escape the guard),
|
|
314
|
+
// restricted to files that exist at HEAD (having an old side in the tracked diff IS existing at
|
|
315
|
+
// HEAD). Tampered = any removed/modified line; pure additions and new/untracked files never trip it
|
|
316
|
+
// (widening to any-change-is-tamper would flag the standard append-a-new-test flow — the FP churn
|
|
317
|
+
// this series exists to kill).
|
|
318
|
+
export const computeTamperedTests = (root, boundFiles = new Set()) => {
|
|
319
|
+
const trackedDiff = gitStdout(['diff', 'HEAD', ...DIFF_FLAGS], root)
|
|
320
|
+
?? gitStdout(['diff', ...DIFF_FLAGS], root) // no HEAD yet (unborn branch)
|
|
321
|
+
?? '';
|
|
322
|
+
// The file halves are user-authored — './checks/x.mjs' probes and hashes as 'checks/x.mjs', so
|
|
323
|
+
// the surface compares NORMALIZED halves or a modified bound file escapes the guard (codex R5).
|
|
324
|
+
// Normalization happens in GIT/POSIX path space (codex+agy R6): node's OS-local normalize emits
|
|
325
|
+
// backslashes on Windows while git diff paths stay slash-separated — the compare must never
|
|
326
|
+
// depend on the host separator.
|
|
327
|
+
const boundSet = new Set([...boundFiles].map((f) => posix.normalize(f)));
|
|
328
|
+
const tampered = [];
|
|
329
|
+
for (const [rel, info] of parseDiffOldSide(trackedDiff)) {
|
|
330
|
+
if (!info.removals) continue;
|
|
331
|
+
if (classifyChangedPath(rel) === 'excluded-test' || boundSet.has(rel)) tampered.push(rel);
|
|
308
332
|
}
|
|
309
|
-
return {
|
|
333
|
+
return { tampered: tampered.sort() };
|
|
310
334
|
};
|
|
311
335
|
|
|
312
336
|
// ── coverage (run the suite once under NODE_V8_COVERAGE, outside the tree) ─────────────────────────
|
|
@@ -382,12 +406,60 @@ const appendRecord = (resultsPath, record) => {
|
|
|
382
406
|
|
|
383
407
|
// ── the run ───────────────────────────────────────────────────────────────────────────────────────
|
|
384
408
|
|
|
385
|
-
const budgetsFromEnv = (env) => ({
|
|
409
|
+
export const budgetsFromEnv = (env) => ({
|
|
386
410
|
mutantsMax: Number.parseInt(env.AW_FOLD_MUTANTS_MAX ?? '200', 10) || 200,
|
|
387
411
|
hunkMutantsMax: Number.parseInt(env.AW_FOLD_HUNK_MUTANTS_MAX ?? '25', 10) || 25,
|
|
388
412
|
timeBudgetS: Number.parseInt(env.AW_FOLD_TIME_BUDGET_S ?? '600', 10) || 600,
|
|
413
|
+
// D4: N reruns per probe side; the per-RUN probe timeout (each of the N runs gets its own budget,
|
|
414
|
+
// never one shared series budget — agy R1). Probes only: the suite run keeps the no-timeout status
|
|
415
|
+
// quo. The fail-closed parser is the shared one (changed-surface.mjs), thrown as THIS tool's STOP.
|
|
416
|
+
foldReruns: parsePositiveIntKnob(env, 'AW_FOLD_RERUNS', 3, stop),
|
|
417
|
+
probeTimeoutS: parsePositiveIntKnob(env, 'AW_FOLD_PROBE_TIMEOUT_S', 120, stop),
|
|
389
418
|
});
|
|
390
419
|
|
|
420
|
+
// ── the shared N-rerun probe (D4) — ONE helper for both the run's green side and the --red verb ───
|
|
421
|
+
|
|
422
|
+
// probeBound({ id, rootTop, env, boundArgv, reruns, timeoutS }) → { entry, resolveReason }. The entry
|
|
423
|
+
// is the v2 per-testId record shape: rerun counts (evidence) + the custody content hash + the derived
|
|
424
|
+
// booleans. The custody hash is taken BEFORE the runs (the content the observation attests). A
|
|
425
|
+
// timed-out or signal-killed run is neither red nor green — it lands in `timeouts` (quarantine fuel).
|
|
426
|
+
const probeBound = ({ id, rootTop, env, boundArgv, reruns, timeoutS }) => {
|
|
427
|
+
const { file, pattern } = splitTestId(id);
|
|
428
|
+
const resolved = resolveTestFile(rootTop, file);
|
|
429
|
+
const fileHash = resolved.ok ? hashFileBytes(resolved.abs) : null;
|
|
430
|
+
let executed = 0;
|
|
431
|
+
let greens = 0;
|
|
432
|
+
let reds = 0;
|
|
433
|
+
let timeouts = 0;
|
|
434
|
+
if (resolved.ok && fileHash != null) {
|
|
435
|
+
// ALWAYS spawn the resolver's canonical absolute path — the executed file must be the hashed
|
|
436
|
+
// file independent of runner path semantics (codex R1+R2, BUGFREE-1 live loop): a raw
|
|
437
|
+
// leading-dash filename parses as an OPTION (and a ./-prefix does not survive node's runner
|
|
438
|
+
// normalization), and a raw traversal path like linkdir/../x lets an OS-resolving runner
|
|
439
|
+
// execute a different filesystem target than the lexically-normalized file the hash covers.
|
|
440
|
+
const argv = boundArgv(resolved.abs, pattern);
|
|
441
|
+
for (let i = 0; i < reruns; i += 1) {
|
|
442
|
+
const res = spawnSync(argv[0], argv.slice(1), {
|
|
443
|
+
cwd: rootTop, env: childTestEnv(env), encoding: 'utf8', maxBuffer: GIT_MAX_BUFFER, timeout: timeoutS * 1000,
|
|
444
|
+
});
|
|
445
|
+
if ((res.error && res.error.code === 'ETIMEDOUT') || res.signal != null) {
|
|
446
|
+
timeouts += 1;
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
const p = parseProbeOutput({ stdout: res.stdout ?? '', code: res.error ? 1 : res.status ?? 1, fileArg: file });
|
|
450
|
+
executed = Math.max(executed, p.executed);
|
|
451
|
+
if (!p.resolvable) continue; // an unresolved run (the pattern selected nothing)
|
|
452
|
+
if (p.baselineGreen) greens += 1;
|
|
453
|
+
else reds += 1;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
const entry = {
|
|
457
|
+
id, executed, runs: reruns, greens, reds, timeouts, fileHash,
|
|
458
|
+
resolvable: greens + reds === reruns, baselineGreen: greens === reruns,
|
|
459
|
+
};
|
|
460
|
+
return { entry, resolveReason: resolved.ok ? (fileHash == null ? `cannot read "${file}"` : null) : resolved.reason };
|
|
461
|
+
};
|
|
462
|
+
|
|
391
463
|
// runFoldCompleteness({ cwd, env, suiteCmd }) → { writtenPath, record }. THROWS a typed STOP (loop
|
|
392
464
|
// derivation / suite discovery / a malformed record / an fs error) or a native fs error.
|
|
393
465
|
export const runFoldCompleteness = ({ cwd = process.cwd(), env = process.env, suiteCmd } = {}) => {
|
|
@@ -430,28 +502,33 @@ export const runFoldCompleteness = ({ cwd = process.cwd(), env = process.env, su
|
|
|
430
502
|
for (const n of computeUncoveredLines({ perProcessRanges: perProc, sourceText: src, changedLines: lines })) uncoveredChanged.push({ file: rel, line: n });
|
|
431
503
|
}
|
|
432
504
|
|
|
433
|
-
// Decision 3 / 10: probe each fixable-bug bound
|
|
505
|
+
// Decision 3 / 10 + D4: probe each of the SEGMENT's fixable-bug bound testIds N times
|
|
506
|
+
// (shell-free, per-run timeout). Segment scope (D7): a committed phase's folds are closed
|
|
507
|
+
// obligations — only triages recorded at the current base bind.
|
|
434
508
|
const ledgerPath = resolveLedgerPath(cwd, env);
|
|
435
509
|
const { records: reviewRecords } = ledgerPath ? readLedger(ledgerPath) : { records: [] };
|
|
436
|
-
const
|
|
437
|
-
const
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
510
|
+
const base = resolveBase(cwd);
|
|
511
|
+
const boundTestIds = collectBoundTestIds(reviewRecords, { activity: ACTIVITY, loop, base });
|
|
512
|
+
const testIds = boundTestIds.map(
|
|
513
|
+
(id) => probeBound({ id, rootTop, env, boundArgv, reruns: budgets.foldReruns, timeoutS: budgets.probeTimeoutS }).entry,
|
|
514
|
+
);
|
|
515
|
+
|
|
516
|
+
// Approach-3: the oracle-tamper pass over the tracked working-vs-HEAD diff, restricted to the
|
|
517
|
+
// test surface ∪ the bound-testId file halves. Recorded; the checker enforces overrides.
|
|
518
|
+
const tamper = computeTamperedTests(rootTop, new Set(boundTestIds.map((id) => splitTestId(id).file)));
|
|
445
519
|
|
|
446
520
|
const record = {
|
|
447
521
|
schema: RESULT_SCHEMA_VERSION,
|
|
522
|
+
kind: 'run',
|
|
448
523
|
loop,
|
|
524
|
+
base,
|
|
449
525
|
fingerprint,
|
|
450
526
|
boundTestIds,
|
|
451
527
|
testIds,
|
|
452
528
|
unsupported,
|
|
453
529
|
outOfDomain,
|
|
454
530
|
coverage: { uncoveredChanged },
|
|
531
|
+
tamper,
|
|
455
532
|
mutation: { total: 0, killed: 0, survived: [], skipped: 0, killSetBasis: null }, // reserved — mutation not shipped (shelved)
|
|
456
533
|
budgets,
|
|
457
534
|
timestamp: isoNow(),
|
|
@@ -464,28 +541,109 @@ export const runFoldCompleteness = ({ cwd = process.cwd(), env = process.env, su
|
|
|
464
541
|
return appendRecord(resultsPath, record);
|
|
465
542
|
};
|
|
466
543
|
|
|
544
|
+
// ── the --red verb (D6): observe RED when it actually happens, mint the custody receipt ──────────
|
|
545
|
+
|
|
546
|
+
// runRedProbe({ cwd, env, testId }) → { writtenPath, record }. Observes `testId` on the CURRENT
|
|
547
|
+
// (pre-fold) tree: resolvable + failing on N/N runs → appends a red-probe receipt (testId, counts,
|
|
548
|
+
// the test file's content hash, fingerprint, timestamp) to the fold results ledger. Observed green,
|
|
549
|
+
// unresolvable, mixed, or timed out → a typed refusal DISTINGUISHED by name, and NOTHING is written
|
|
550
|
+
// (D4: mixed/timeout is QUARANTINE — it never converts and has no override lane). No triage-order
|
|
551
|
+
// requirement: the checker joins receipts to the bound set at gate time (D6).
|
|
552
|
+
export const runRedProbe = ({ cwd = process.cwd(), env = process.env, testId } = {}) => {
|
|
553
|
+
if (!isWellFormedTestId(testId)) {
|
|
554
|
+
throw usageFail(`--red needs a well-formed testId "<test-file>#<test-name-pattern>" (a "#" separator, both halves non-empty; got ${JSON.stringify(testId)})`);
|
|
555
|
+
}
|
|
556
|
+
const root = gitStdout(['rev-parse', '--show-toplevel'], cwd);
|
|
557
|
+
if (root == null) throw stop('not a git work tree — nothing to observe');
|
|
558
|
+
const rootTop = root.replace(/\r?\n$/, '');
|
|
559
|
+
const plans = plansInFlight(rootTop);
|
|
560
|
+
if (plans.length === 0) throw stop('no plan in flight (docs/plans/ holds no active plan) — nothing to observe');
|
|
561
|
+
if (plans.length > 1) throw stop(`more than one plan in flight (${plans.join(', ')}) — ambiguous loop id; resolve to one active plan`);
|
|
562
|
+
const loop = plans[0].replace(/\.md$/, '');
|
|
563
|
+
|
|
564
|
+
const boundArgv = resolveBoundArgv(env);
|
|
565
|
+
const budgets = budgetsFromEnv(env);
|
|
566
|
+
const { entry, resolveReason } = probeBound({ id: testId, rootTop, env, boundArgv, reruns: budgets.foldReruns, timeoutS: budgets.probeTimeoutS });
|
|
567
|
+
const verdict = probeVerdict(entry);
|
|
568
|
+
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)`;
|
|
569
|
+
if (verdict === 'unresolvable') {
|
|
570
|
+
throw stop(
|
|
571
|
+
`--red refused for "${testId}": unresolvable — ${resolveReason ?? 'the pattern selects no test'} (${counts}). ` +
|
|
572
|
+
`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; ` +
|
|
573
|
+
`if the red is genuinely unestablishable, the loud escape is a recorded red-proof override (review-ledger-write override). Nothing was recorded.`,
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
if (verdict === 'green') {
|
|
577
|
+
throw stop(
|
|
578
|
+
`--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. ` +
|
|
579
|
+
`Write a test that FAILS before the fix is applied, then re-run --red BEFORE folding the fix. Nothing was recorded.`,
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
if (verdict === 'quarantine') {
|
|
583
|
+
const flavor = entry.timeouts > 0
|
|
584
|
+
? `${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`
|
|
585
|
+
: `mixed outcomes (${counts}) — a flaky test can launder a fake red`;
|
|
586
|
+
throw stop(
|
|
587
|
+
`--red refused for "${testId}": QUARANTINE — ${flavor}. QUARANTINE never converts and has no override lane: ` +
|
|
588
|
+
`${entry.timeouts > 0 ? 'raise the timeout or make the test faster' : 'replace the flaky test'}, then re-observe. Nothing was recorded.`,
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const record = {
|
|
593
|
+
schema: RESULT_SCHEMA_VERSION,
|
|
594
|
+
kind: 'red-probe',
|
|
595
|
+
loop,
|
|
596
|
+
base: resolveBase(cwd), // the SEGMENT frame (D7): a receipt attests red within its segment
|
|
597
|
+
testId,
|
|
598
|
+
fileHash: entry.fileHash,
|
|
599
|
+
runs: entry.runs,
|
|
600
|
+
reds: entry.reds,
|
|
601
|
+
fingerprint: computeTreeFingerprint(cwd),
|
|
602
|
+
timestamp: isoNow(),
|
|
603
|
+
};
|
|
604
|
+
const v = validateRunRecord(record);
|
|
605
|
+
if (!v.ok) throw stop(`refusing to write a malformed red-probe record: ${v.reason}`);
|
|
606
|
+
const resultsPath = resolveResultsPath(cwd, env);
|
|
607
|
+
if (resultsPath == null) throw stop('cannot resolve the result-ledger path — not a git work tree and AW_FOLD_RESULTS is unset');
|
|
608
|
+
return appendRecord(resultsPath, record);
|
|
609
|
+
};
|
|
610
|
+
|
|
467
611
|
// ── CLI ───────────────────────────────────────────────────────────────────────────────────────────
|
|
468
612
|
|
|
469
|
-
const HELP = `fold-completeness-run — the M3 fold-completeness RUNNER (agent-workflow family, AD-046).
|
|
613
|
+
const HELP = `fold-completeness-run — the M3 fold-completeness RUNNER (agent-workflow family, AD-046 + AD-047).
|
|
470
614
|
|
|
471
615
|
Usage:
|
|
472
616
|
node fold-completeness-run.mjs [--suite "<cmd>"] [--cwd <dir>]
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
617
|
+
node fold-completeness-run.mjs --red "<test-file>#<test-name-pattern>" [--cwd <dir>]
|
|
618
|
+
|
|
619
|
+
The default run: runs the in-flight plan-execution loop's suite ONCE under coverage, maps every
|
|
620
|
+
changed executable line to covered/uncovered, probes each of the SEGMENT's fixable-bug bound testIds
|
|
621
|
+
N times (AW_FOLD_RERUNS, default 3) for resolvability + an N/N-green baseline, records each bound test
|
|
622
|
+
file's content hash (custody), and appends one v3 run record — segment-framed (base = git rev-parse
|
|
623
|
+
HEAD; the bound set is the current segment's, AD-048 D7) — to
|
|
624
|
+
<git dir>/${'agent-workflow-fold-completeness.jsonl'} (AW_FOLD_RESULTS overrides).
|
|
625
|
+
|
|
626
|
+
--red observes a testId RED on the CURRENT (pre-fold) tree — the honest fold-time order is: classify
|
|
627
|
+
the fixable-bug with its testId → write the test → --red observes it FAIL (N/N) BEFORE the fix is
|
|
628
|
+
applied → fold the fix → the normal run observes green → the gate checks receipt + order + custody.
|
|
629
|
+
An N/N red mints a red-probe receipt (testId, counts, content hash, fingerprint, and base — the
|
|
630
|
+
current SEGMENT frame: a receipt never crosses a commit boundary); observed-green /
|
|
631
|
+
unresolvable / mixed / timed-out are DISTINGUISHED typed refusals and nothing is written
|
|
632
|
+
(mixed/timeout = QUARANTINE — never converts, no override lane).
|
|
478
633
|
|
|
479
634
|
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
|
-
|
|
635
|
+
Bound-test probes default to node --test --test-name-pattern (shell-free); AW_FOLD_BOUND_CMD overrides
|
|
636
|
+
with a JSON argv array using {file}/{pattern}. Probe knobs (fail-closed positive integers):
|
|
637
|
+
AW_FOLD_RERUNS (default 3) · AW_FOLD_PROBE_TIMEOUT_S (default 120, per probe RUN, probes only).
|
|
638
|
+
Inert budgets: AW_FOLD_MUTANTS_MAX / AW_FOLD_HUNK_MUTANTS_MAX / AW_FOLD_TIME_BUDGET_S (mutation shelved).
|
|
483
639
|
|
|
484
|
-
|
|
485
|
-
|
|
640
|
+
The read-only gate is a SEPARATE tool: node fold-completeness.mjs --check / --status / --json.
|
|
641
|
+
|
|
642
|
+
Exit codes: 0 written; 1 a typed STOP (loop derivation / suite discovery / a --red refusal / malformed
|
|
643
|
+
record / fs error); 2 usage.`;
|
|
486
644
|
|
|
487
645
|
const parseArgs = (argv) => {
|
|
488
|
-
const opts = { cwd: undefined, suite: undefined };
|
|
646
|
+
const opts = { cwd: undefined, suite: undefined, red: undefined };
|
|
489
647
|
for (let i = 0; i < argv.length; i += 1) {
|
|
490
648
|
const a = argv[i];
|
|
491
649
|
if (a === '--cwd') {
|
|
@@ -496,6 +654,10 @@ const parseArgs = (argv) => {
|
|
|
496
654
|
opts.suite = argv[i + 1];
|
|
497
655
|
if (opts.suite === undefined) throw usageFail('--suite needs a command');
|
|
498
656
|
i += 1;
|
|
657
|
+
} else if (a === '--red') {
|
|
658
|
+
opts.red = argv[i + 1];
|
|
659
|
+
if (opts.red === undefined) throw usageFail('--red needs a testId ("<test-file>#<test-name-pattern>")');
|
|
660
|
+
i += 1;
|
|
499
661
|
} else {
|
|
500
662
|
throw usageFail(`unknown argument: ${a}`);
|
|
501
663
|
}
|
|
@@ -509,7 +671,16 @@ export const main = (argv, ctx = {}) => {
|
|
|
509
671
|
try {
|
|
510
672
|
if (argv.includes('--help') || argv.includes('-h')) return { code: 0, stdout: HELP, stderr: '' };
|
|
511
673
|
const opts = parseArgs(argv);
|
|
512
|
-
const
|
|
674
|
+
const cwd = opts.cwd ?? cwd0;
|
|
675
|
+
if (opts.red !== undefined) {
|
|
676
|
+
const { writtenPath, record } = runRedProbe({ cwd, env, testId: opts.red });
|
|
677
|
+
return {
|
|
678
|
+
code: 0,
|
|
679
|
+
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}`,
|
|
680
|
+
stderr: '',
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
const { writtenPath, record } = runFoldCompleteness({ cwd, env, suiteCmd: opts.suite });
|
|
513
684
|
const uncovered = record.coverage.uncoveredChanged.length;
|
|
514
685
|
const unresolved = record.testIds.filter((t) => !t.resolvable || !t.baselineGreen).length;
|
|
515
686
|
return {
|