@sabaiway/agent-workflow-kit 2.1.0 → 3.0.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 +45 -0
- package/README.md +6 -5
- package/SKILL.md +12 -8
- package/bin/install.mjs +1 -1
- package/bridges/antigravity-cli-bridge/SKILL.md +8 -1
- package/bridges/antigravity-cli-bridge/bin/agy-review-honesty.test.mjs +150 -0
- package/bridges/antigravity-cli-bridge/bin/agy-review-model-screen.test.mjs +76 -0
- package/bridges/antigravity-cli-bridge/bin/agy-review.sh +80 -15
- package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +95 -4
- package/bridges/antigravity-cli-bridge/capability.json +3 -2
- package/bridges/codex-cli-bridge/SKILL.md +8 -1
- package/bridges/codex-cli-bridge/bin/codex-review-honesty.test.mjs +143 -0
- package/bridges/codex-cli-bridge/bin/codex-review.sh +71 -10
- package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +93 -10
- package/bridges/codex-cli-bridge/capability.json +3 -2
- package/capability.json +1 -1
- package/migrations/3.0.0-hardened-core-loop.md +44 -0
- package/migrations/README.md +1 -1
- package/package.json +2 -2
- package/references/hooks/gate-approve.mjs +1 -1
- package/references/modes/bootstrap.md +3 -3
- package/references/modes/commit-guard.md +19 -0
- package/references/modes/core-evidence.md +15 -0
- package/references/modes/coverage-check.md +12 -0
- package/references/modes/doc-parity.md +7 -6
- package/references/modes/gates.md +7 -6
- package/references/modes/grounding.md +1 -2
- package/references/modes/hook.md +1 -1
- package/references/modes/review-state.md +3 -3
- package/references/modes/upgrade.md +6 -6
- package/references/modes/velocity.md +2 -2
- package/references/scripts/archive-decisions.mjs +1 -1
- package/references/scripts/install-git-hooks-repo-exec.test.mjs +82 -0
- package/references/scripts/install-git-hooks.mjs +90 -18
- package/references/scripts/install-git-hooks.test.mjs +102 -0
- package/references/scripts/migrate-gates-branches.test.mjs +157 -0
- package/references/scripts/migrate-gates.mjs +395 -0
- package/references/scripts/migrate-gates.test.mjs +284 -0
- package/references/templates/agent_rules.md +2 -2
- package/tools/ack-write.mjs +1 -1
- package/tools/atomic-write.mjs +2 -2
- package/tools/autonomy-config.mjs +1 -1
- package/tools/autonomy-doctor.mjs +1 -1
- package/tools/autonomy-write.mjs +1 -1
- package/tools/bridge-settings-read.mjs +1 -1
- package/tools/bridge-settings.mjs +1 -1
- package/tools/changed-surface.mjs +8 -69
- package/tools/cheap-agents.mjs +2 -2
- package/tools/commands.mjs +17 -10
- package/tools/commit-guard.mjs +167 -0
- package/tools/core-evidence.mjs +914 -0
- package/tools/coverage-check.mjs +260 -0
- package/tools/delegation.mjs +1 -1
- package/tools/detect-backends.mjs +3 -3
- package/tools/doc-parity.mjs +11 -27
- package/tools/engine-source.mjs +1 -1
- package/tools/family-members.mjs +1 -1
- package/tools/family-registry.mjs +1 -1
- package/tools/fs-safe.mjs +1 -1
- package/tools/gate-hook.mjs +1 -1
- package/tools/{seed-gates.mjs → gates-init.mjs} +58 -52
- package/tools/grounding.mjs +6 -83
- package/tools/hide-footprint.mjs +1 -1
- package/tools/inject-methodology.mjs +1 -1
- package/tools/known-footprint.mjs +1 -1
- package/tools/labels.mjs +1 -1
- package/tools/lcov.mjs +6 -10
- package/tools/lens-region.mjs +1 -1
- package/tools/manifest/validate.mjs +19 -0
- package/tools/migrate-adr-store.mjs +2 -2
- package/tools/orchestration-config.mjs +1 -1
- package/tools/orchestration-write.mjs +1 -1
- package/tools/presentation.mjs +1 -1
- package/tools/procedures.mjs +2 -2
- package/tools/recipes.mjs +60 -9
- package/tools/recommendations.mjs +63 -7
- package/tools/release-scan.mjs +1 -1
- package/tools/renderers.mjs +1 -1
- package/tools/review-state.mjs +288 -340
- package/tools/run-gates.mjs +216 -92
- package/tools/sandbox-masks.mjs +1 -1
- package/tools/semver-lite.mjs +1 -1
- package/tools/set-autonomy.mjs +1 -1
- package/tools/set-recipe.mjs +1 -1
- package/tools/setup-backends.mjs +1 -1
- package/tools/surface.mjs +1 -1
- package/tools/uninstall.mjs +1 -1
- package/tools/velocity-profile.mjs +2 -2
- package/tools/view-model.mjs +1 -1
- package/references/modes/fold-completeness.md +0 -30
- package/references/modes/review-ledger.md +0 -34
- package/references/templates/verification-profile.json +0 -10
- package/tools/fold-completeness-run.mjs +0 -1120
- package/tools/fold-completeness.mjs +0 -672
- package/tools/review-ledger-core.mjs +0 -428
- package/tools/review-ledger-write.mjs +0 -647
- package/tools/review-ledger.mjs +0 -630
- package/tools/sarif.mjs +0 -52
- package/tools/verification-profile.mjs +0 -219
|
@@ -1,1120 +0,0 @@
|
|
|
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 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.
|
|
24
|
-
// The researched mutation half (M3b) was SHELVED — bounded local-boundary mutation adds too little
|
|
25
|
-
// over coverage and is not language-independent — so the `mutation` field stays the reserved empty shape.
|
|
26
|
-
//
|
|
27
|
-
// HONEST residuals (see fold-completeness.mjs header for the full list): coverage proves execution not
|
|
28
|
-
// assertion; testIds/records are forgeable (a self-discipline mechanism, not a security boundary);
|
|
29
|
-
// TS/JSX source is out of scope v1. Dependency-free, Node >= 18. No side effects on import.
|
|
30
|
-
|
|
31
|
-
import { readFileSync, readdirSync, mkdtempSync, rmSync, realpathSync, lstatSync } from 'node:fs';
|
|
32
|
-
import { join, dirname, basename, isAbsolute, normalize, posix, sep } from 'node:path';
|
|
33
|
-
import { tmpdir } from 'node:os';
|
|
34
|
-
import { pathToFileURL, fileURLToPath } from 'node:url';
|
|
35
|
-
import { spawnSync } from 'node:child_process';
|
|
36
|
-
import { createHash } from 'node:crypto';
|
|
37
|
-
import { writeContainedFileAtomic } from './atomic-write.mjs';
|
|
38
|
-
import { computeTreeFingerprint, plansInFlight } from './review-state.mjs';
|
|
39
|
-
import { resolveLedgerPath, resolveBase, readLedger, isWellFormedTestId, splitTestId, collectOverrides } from './review-ledger.mjs';
|
|
40
|
-
import {
|
|
41
|
-
RESULT_SCHEMA_VERSION,
|
|
42
|
-
REATTEST_KIND,
|
|
43
|
-
resolveResultsPath,
|
|
44
|
-
validateRunRecord,
|
|
45
|
-
collectBoundTestIds,
|
|
46
|
-
probeVerdict,
|
|
47
|
-
readResults,
|
|
48
|
-
filterSegmentResults,
|
|
49
|
-
isRedProbeRecord,
|
|
50
|
-
isReattestRecord,
|
|
51
|
-
} from './fold-completeness.mjs';
|
|
52
|
-
// The changed-surface computation lives in the NEUTRAL shared module (BUGFREE-2 / AD-048, D4): the
|
|
53
|
-
// review-ledger writer's diff-size cap and this runner's coverage domain consume ONE computation,
|
|
54
|
-
// and the writer never imports this runner (the sole-tree-toucher boundary — import-split pinned).
|
|
55
|
-
// Re-exported below so the runner's tests (and any consumer) keep one entry point per concern.
|
|
56
|
-
import { classifyChangedPath, parseUnifiedDiff, unquoteDiffPath, computeChangedSurface, DIFF_FLAGS, parsePositiveIntKnob } from './changed-surface.mjs';
|
|
57
|
-
// The verification PROFILE (BUGFREE-3 / AD-049): the read-core generalizes the coverage SOURCE and
|
|
58
|
-
// the single-test RESULT FORMAT so this runner drives the fold gate on another language/runner. An
|
|
59
|
-
// absent profile reproduces today's exact behaviour (V8 + node:test TAP on stdout).
|
|
60
|
-
import { loadProfile, resolveCoverage, resolveSingleTest, resolveSarifPath, FILE_BASED_FORMATS } from './verification-profile.mjs';
|
|
61
|
-
import { lcovCoveredMap, uncoveredChangedFromLcov } from './lcov.mjs';
|
|
62
|
-
import { parseSarif, renderSarifFindings } from './sarif.mjs';
|
|
63
|
-
|
|
64
|
-
export { classifyChangedPath, parseUnifiedDiff, unquoteDiffPath, computeChangedSurface };
|
|
65
|
-
|
|
66
|
-
const ACTIVITY = 'plan-execution';
|
|
67
|
-
const GIT_MAX_BUFFER = 256 * 1024 * 1024; // a full-tree diff / full-suite TAP can be large; never truncate
|
|
68
|
-
|
|
69
|
-
// A typed STOP — a deliberate refusal we surface (loop derivation / a malformed override / a malformed
|
|
70
|
-
// record / an fs error), distinct from a native fs error. The codebase's typed-error idiom (no classes).
|
|
71
|
-
export const FOLD_RUN_STOP = 'FOLD_RUN_STOP';
|
|
72
|
-
const stop = (message) => Object.assign(new Error(`[agent-workflow-kit] ${message}`), { name: 'FoldRunStop', code: FOLD_RUN_STOP });
|
|
73
|
-
const usageFail = (message) => Object.assign(new Error(`[agent-workflow-kit] ${message}`), { exitCode: 2 });
|
|
74
|
-
|
|
75
|
-
const isoNow = () => new Date().toISOString();
|
|
76
|
-
|
|
77
|
-
// Node sets NODE_TEST_CONTEXT for any process running UNDER `node --test`; a fresh `node --test` that
|
|
78
|
-
// inherits it silently SKIPS running its files (the recursive-run guard). The runner spawns `node
|
|
79
|
-
// --test` for the suite + the bound-test probes, so it MUST strip that var — otherwise, whenever the
|
|
80
|
-
// runner is itself invoked from within a test context (e.g. this kit's own fold-completeness-run
|
|
81
|
-
// tests, or a consumer's), the child runs nothing and every file reads as uncovered. Unset in normal
|
|
82
|
-
// (non-test) invocation, so stripping is a no-op there.
|
|
83
|
-
export const childTestEnv = (env, extra = {}) => {
|
|
84
|
-
const out = { ...env, ...extra };
|
|
85
|
-
delete out.NODE_TEST_CONTEXT;
|
|
86
|
-
return out;
|
|
87
|
-
};
|
|
88
|
-
|
|
89
|
-
// ── Decision 6: V8 coverage → uncovered changed lines (innermost-range-wins) ──────────────────────
|
|
90
|
-
|
|
91
|
-
// lineStartOffsets(sourceText) → char offset of each line start (index i == line i+1). Splitting on
|
|
92
|
-
// '\n' keeps any trailing '\r' inside the line length, so CRLF offsets stay correct.
|
|
93
|
-
export const lineStartOffsets = (sourceText) => {
|
|
94
|
-
const offs = [0];
|
|
95
|
-
for (let i = 0; i < sourceText.length; i += 1) if (sourceText[i] === '\n') offs.push(i + 1);
|
|
96
|
-
return offs;
|
|
97
|
-
};
|
|
98
|
-
|
|
99
|
-
// effectiveCount(ranges, offset) → the execution count of the SMALLEST (innermost) range containing
|
|
100
|
-
// offset; 0 when no range contains it (absent from this process's report). This is the v8-to-istanbul
|
|
101
|
-
// rule reduced to Node built-ins: a nested count-0 block shadows its executed parent.
|
|
102
|
-
export const effectiveCount = (ranges, offset) => {
|
|
103
|
-
let best = null;
|
|
104
|
-
for (const r of ranges) {
|
|
105
|
-
if (r.startOffset <= offset && offset < r.endOffset) {
|
|
106
|
-
const width = r.endOffset - r.startOffset;
|
|
107
|
-
if (best === null || width < best.width) best = { width, count: r.count };
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
return best === null ? 0 : best.count;
|
|
111
|
-
};
|
|
112
|
-
|
|
113
|
-
// computeUncoveredLines({ perProcessRanges, sourceText, changedLines }) → the sorted changed lines that
|
|
114
|
-
// NO process executed. perProcessRanges is one flat range-list per process (process isolation); a line
|
|
115
|
-
// is covered iff ANY process gives its first non-whitespace char a positive effective count. Blank /
|
|
116
|
-
// whitespace-only lines are never executable and never flagged. Callers handle file-absent (an empty
|
|
117
|
-
// perProcessRanges means the file never loaded — a file-level RED decided by the caller, not here).
|
|
118
|
-
//
|
|
119
|
-
// GRANULARITY (stated residual, codex R1 → inherent-layer-residual): this is LINE-ENTRY coverage — the
|
|
120
|
-
// question "was this line entered by some test?", the same granularity c8 reports as line coverage. A
|
|
121
|
-
// same-line uncovered sub-branch (the false arm of `a ? b : c`, the RHS of `a && b()`) is NOT flagged
|
|
122
|
-
// when the line's leading statement executed — flagging it would need branch/AST analysis, and a naive
|
|
123
|
-
// "any count-0 char on the line" rule would FALSE-POSITIVE on an inline uncalled-function definition
|
|
124
|
-
// (`const f = () => never()`, whose body span is count-0 though the assignment ran), i.e. exactly the
|
|
125
|
-
// churn the plan's PRIMARY RISK warns against. Same-line branch gaps would need branch-level analysis
|
|
126
|
-
// (the shelved mutation half / a future parser-backed signal), not M3a line coverage. Chasing sub-line
|
|
127
|
-
// precision here without an AST is explicitly out of scope — a stated residual.
|
|
128
|
-
export const computeUncoveredLines = ({ perProcessRanges, sourceText, changedLines }) => {
|
|
129
|
-
const offs = lineStartOffsets(sourceText);
|
|
130
|
-
const total = sourceText.length;
|
|
131
|
-
const uncovered = [];
|
|
132
|
-
for (const n of changedLines) {
|
|
133
|
-
const start = offs[n - 1];
|
|
134
|
-
if (start === undefined) continue; // a changed line beyond EOF (defensive; should not happen)
|
|
135
|
-
const end = offs[n] ?? total;
|
|
136
|
-
const rel = sourceText.slice(start, end).search(/\S/);
|
|
137
|
-
if (rel < 0) continue; // blank / whitespace-only → not executable
|
|
138
|
-
const offset = start + rel;
|
|
139
|
-
const covered = perProcessRanges.some((ranges) => effectiveCount(ranges, offset) > 0);
|
|
140
|
-
if (!covered) uncovered.push(n);
|
|
141
|
-
}
|
|
142
|
-
return [...new Set(uncovered)].sort((a, b) => a - b);
|
|
143
|
-
};
|
|
144
|
-
|
|
145
|
-
// ── Decision 3 / 10: the bound-test probe ─────────────────────────────────────────────────────────
|
|
146
|
-
|
|
147
|
-
const PROBE_RESULT_RE = /^(ok|not ok) \d+ - (.*)$/; // a column-0 TAP result line (verb, description)
|
|
148
|
-
const PROBE_FAIL_RE = /^# fail (\d+)$/;
|
|
149
|
-
const PROBE_DIRECTIVE_RE = /#\s*(?:skip|todo)\b/i; // a TAP SKIP/TODO directive — the test did NOT run
|
|
150
|
-
|
|
151
|
-
// parseProbeOutput({ stdout, code, fileArg }) → { resolvable, executed, baselineGreen }. The TAP
|
|
152
|
-
// strategy (both tap-stdout and tap-file — a tap-file is this SAME parser applied to the file's text).
|
|
153
|
-
// A node:test run with a pattern that matches NOTHING emits only a file-wrapper result whose
|
|
154
|
-
// description is the file path itself (`ok N - <file>`) on newer node — but node 18/20 ALSO emit every
|
|
155
|
-
// pattern-FILTERED test as `ok N - <name> # SKIP test name does not match pattern`, so a result line
|
|
156
|
-
// carrying a TAP SKIP/TODO directive must never count: the test was not executed, and counting it
|
|
157
|
-
// green-vouches a nonexistent testId on exactly the node versions the kit supports (caught by CI's
|
|
158
|
-
// 18/20 matrix). So `resolvable` = at least one column-0, directive-free result whose description is
|
|
159
|
-
// not the file we passed; `baselineGreen` = resolvable AND the run was green — exit 0, NO directive-free
|
|
160
|
-
// `not ok` result, and `# fail 0` (a `not ok` is counted directly, so a generic TAP producer that
|
|
161
|
-
// omits the `# fail N` summary yet exits 0 still reads RED — the fail-closed posture, BUGFREE-3). The
|
|
162
|
-
// wrapper is matched by BASENAME, not literally: node normalizes the echoed path ('./x' → 'x', or an
|
|
163
|
-
// absolute path), so a literal desc===fileArg compare would count the wrapper as a real match and
|
|
164
|
-
// falsely report resolvable/green (codex R1). A basename compare is invariant to ./ / abs / rel.
|
|
165
|
-
export const parseProbeOutput = ({ stdout, code, fileArg }) => {
|
|
166
|
-
let matched = 0;
|
|
167
|
-
let notOk = 0;
|
|
168
|
-
let failCount = null;
|
|
169
|
-
const wanted = basename(String(fileArg).trim());
|
|
170
|
-
for (const line of String(stdout).split('\n')) {
|
|
171
|
-
const m = PROBE_RESULT_RE.exec(line);
|
|
172
|
-
if (m && !PROBE_DIRECTIVE_RE.test(m[2]) && basename(m[2].trim()) !== wanted) {
|
|
173
|
-
matched += 1;
|
|
174
|
-
if (m[1] === 'not ok') notOk += 1;
|
|
175
|
-
}
|
|
176
|
-
const f = PROBE_FAIL_RE.exec(line.trim());
|
|
177
|
-
if (f) failCount = Number(f[1]);
|
|
178
|
-
}
|
|
179
|
-
const resolvable = matched > 0;
|
|
180
|
-
const fails = (failCount ?? 0) + notOk; // either signal marks a fail (only the ===0 green check matters)
|
|
181
|
-
return { resolvable, executed: matched, baselineGreen: resolvable && code === 0 && fails === 0 };
|
|
182
|
-
};
|
|
183
|
-
|
|
184
|
-
// parseJunitXml({ resultText }) → { resolvable, executed, baselineGreen }. A dependency-free JUnit-XML
|
|
185
|
-
// reader (regex over well-formed testcase elements — no XML lib): a <testcase> carrying <skipped> did
|
|
186
|
-
// NOT run (excluded, the TAP SKIP/TODO analogue); a <testcase> carrying <failure> or <error> is red.
|
|
187
|
-
// resolvable = at least one NON-skipped testcase (so an empty report / tests="0" reads UNRESOLVABLE,
|
|
188
|
-
// never green — the "0 tests never green" invariant); baselineGreen = resolvable AND no non-skipped
|
|
189
|
-
// failure/error. The XML report is authoritative (fail-closed: a failure element is red regardless of
|
|
190
|
-
// the process exit code — a reporter that exits 0 while recording failures still reads RED).
|
|
191
|
-
// CDATA + comment CONTENT is arbitrary text (a test's captured stdout may legally contain '<skipped',
|
|
192
|
-
// '<failure>', or even a literal '</testcase>'). Stripped BEFORE the regex scan so it can never
|
|
193
|
-
// fabricate a skip/failure match nor desync the lazy body capture (the fail-closed posture: a real
|
|
194
|
-
// <failure> is never dropped, a real element boundary is never truncated).
|
|
195
|
-
const stripXmlNoise = (xml) => String(xml).replace(/<!\[CDATA\[[\s\S]*?\]\]>/g, '').replace(/<!--[\s\S]*?-->/g, '');
|
|
196
|
-
export const parseJunitXml = ({ resultText }) => {
|
|
197
|
-
let executed = 0;
|
|
198
|
-
let failed = 0;
|
|
199
|
-
const text = stripXmlNoise(resultText ?? '');
|
|
200
|
-
// The /g regex is LOCAL per call: a fresh matcher owns its own lastIndex — no shared
|
|
201
|
-
// module-level state to reset, no reentrancy risk under any future refactor.
|
|
202
|
-
const caseRe = /<testcase\b[^>]*?(\/>|>([\s\S]*?)<\/testcase>)/g;
|
|
203
|
-
let m;
|
|
204
|
-
while ((m = caseRe.exec(text)) !== null) {
|
|
205
|
-
const body = m[2] ?? '';
|
|
206
|
-
if (/<skipped\b/.test(body)) continue; // skipped → did not run
|
|
207
|
-
executed += 1;
|
|
208
|
-
if (/<(?:failure|error)\b/.test(body)) failed += 1;
|
|
209
|
-
}
|
|
210
|
-
const resolvable = executed > 0;
|
|
211
|
-
return { resolvable, executed, baselineGreen: resolvable && failed === 0 };
|
|
212
|
-
};
|
|
213
|
-
|
|
214
|
-
// parseProbeResult({ format, stdout, code, fileArg, resultText }) → the SAME
|
|
215
|
-
// { resolvable, executed, baselineGreen } shape, dispatched by the profile's singleTest.resultFormat.
|
|
216
|
-
// A file-based format whose result file was NOT written (resultText == null — the probe crashed or the
|
|
217
|
-
// pattern selected nothing) reads UNRESOLVABLE (never green): the freshness invariant (a fresh
|
|
218
|
-
// out-of-tree path per probe run — see probeBound) means a stale file can never be re-read as green.
|
|
219
|
-
export const parseProbeResult = ({ format = 'tap-stdout', stdout, code, fileArg, resultText }) => {
|
|
220
|
-
if (format === 'tap-file') {
|
|
221
|
-
if (resultText == null) return { resolvable: false, executed: 0, baselineGreen: false };
|
|
222
|
-
return parseProbeOutput({ stdout: resultText, code, fileArg });
|
|
223
|
-
}
|
|
224
|
-
if (format === 'junit-xml') {
|
|
225
|
-
if (resultText == null) return { resolvable: false, executed: 0, baselineGreen: false };
|
|
226
|
-
const r = parseJunitXml({ resultText });
|
|
227
|
-
// Fail-closed + symmetric with tap-file: an all-pass report with a NONZERO process exit reads RED
|
|
228
|
-
// (a report may be written before a post-test hook/crash fails the process). Internal sweep fold.
|
|
229
|
-
return { ...r, baselineGreen: r.baselineGreen && code === 0 };
|
|
230
|
-
}
|
|
231
|
-
return parseProbeOutput({ stdout, code, fileArg }); // tap-stdout (default) — unchanged
|
|
232
|
-
};
|
|
233
|
-
|
|
234
|
-
// defaultBoundArgv(file, pattern) → the shell-free node:test argv (testId content never reaches a
|
|
235
|
-
// shell). The pattern rides in the `=`-joined form: as a SEPARATE argv token a pattern beginning
|
|
236
|
-
// with "-"/"--" (a test name like "--telemetry refuses …") parses as an OPTION and the probe
|
|
237
|
-
// silently selects no test — the pattern-half sibling of the AD-047 dash-spawn file fix (found
|
|
238
|
-
// live by this plan's own --red loop).
|
|
239
|
-
export const defaultBoundArgv = (file, pattern) => ['node', '--test', '--test-reporter', 'tap', `--test-name-pattern=${pattern}`, file];
|
|
240
|
-
|
|
241
|
-
// ── the shared safe test-file resolver (BUGFREE-1, codex R1+R2) ───────────────────────────────────
|
|
242
|
-
// Custody hashing and every probe spawn go through THIS one resolver — the testId format itself is
|
|
243
|
-
// deliberately suffix-free and format-only (review-ledger.mjs), so path safety lives here: the file
|
|
244
|
-
// half must be repo-relative (absolute + parent-escaping refused), a REGULAR file under the no-follow
|
|
245
|
-
// lstat discipline, and its RESOLVED real path must be contained under the REAL repo root — a leaf
|
|
246
|
-
// check alone would let a symlinked PARENT directory escape the work tree.
|
|
247
|
-
|
|
248
|
-
// resolveTestFile(rootTop, rel, deps?) → { ok: true, abs } | { ok: false, reason } (never throws).
|
|
249
|
-
// deps.{lstat,realpath} are injectable so the defensive fs-race catch is unit-testable (the family
|
|
250
|
-
// deps idiom — review-ledger-write.mjs).
|
|
251
|
-
export const resolveTestFile = (rootTop, rel, deps = {}) => {
|
|
252
|
-
const lstat = deps.lstat ?? lstatSync;
|
|
253
|
-
const realpath = deps.realpath ?? realpathSync;
|
|
254
|
-
if (typeof rel !== 'string' || rel.length === 0) return { ok: false, reason: 'empty file path' };
|
|
255
|
-
if (isAbsolute(rel)) return { ok: false, reason: `absolute path "${rel}" — the testId file half must be repo-relative` };
|
|
256
|
-
const norm = normalize(rel);
|
|
257
|
-
if (norm === '..' || norm.startsWith(`..${sep}`)) return { ok: false, reason: `path "${rel}" escapes the repo root` };
|
|
258
|
-
const abs = join(rootTop, norm);
|
|
259
|
-
let st;
|
|
260
|
-
try {
|
|
261
|
-
st = lstat(abs);
|
|
262
|
-
} catch {
|
|
263
|
-
return { ok: false, reason: `file "${rel}" does not exist` };
|
|
264
|
-
}
|
|
265
|
-
if (!st.isFile()) return { ok: false, reason: `"${rel}" is not a regular file (a symlink/directory/device is never followed — fail closed)` };
|
|
266
|
-
let realAbs;
|
|
267
|
-
let realRoot;
|
|
268
|
-
try {
|
|
269
|
-
realAbs = realpath(abs);
|
|
270
|
-
realRoot = realpath(rootTop);
|
|
271
|
-
} catch {
|
|
272
|
-
return { ok: false, reason: `cannot resolve the real path of "${rel}"` };
|
|
273
|
-
}
|
|
274
|
-
if (!containsPath(realRoot, realAbs)) return { ok: false, reason: `"${rel}" resolves outside the repo root (a symlinked parent directory) — fail closed` };
|
|
275
|
-
return { ok: true, abs: realAbs };
|
|
276
|
-
};
|
|
277
|
-
|
|
278
|
-
// containsPath(realRoot, realAbs) → realAbs is strictly INSIDE realRoot. Segment-safe ('/a' never
|
|
279
|
-
// contains '/ab') and correct for a repo at the filesystem root, where realRoot already ends with
|
|
280
|
-
// the separator ('/'+sep would be '//' and reject every valid path — agy R1).
|
|
281
|
-
export const containsPath = (realRoot, realAbs) => realAbs.startsWith(realRoot.endsWith(sep) ? realRoot : realRoot + sep);
|
|
282
|
-
|
|
283
|
-
// The D5 custody hash: sha-256 over the file's BYTES (no encoding normalization). null on a read
|
|
284
|
-
// failure — the caller reads that as an unresolvable file (exported for the unit test of exactly
|
|
285
|
-
// that fail-closed edge).
|
|
286
|
-
export const hashFileBytes = (abs) => {
|
|
287
|
-
try {
|
|
288
|
-
return createHash('sha256').update(readFileSync(abs)).digest('hex');
|
|
289
|
-
} catch {
|
|
290
|
-
return null;
|
|
291
|
-
}
|
|
292
|
-
};
|
|
293
|
-
|
|
294
|
-
// resolveBoundArgv(env, profile?) → (file, pattern, resultPath?) => argv[]. PRECEDENCE (env WINS,
|
|
295
|
-
// Decision 3): AW_FOLD_BOUND_CMD (a JSON argv array — the universality escape hatch) beats the
|
|
296
|
-
// profile's singleTest.argv template, which beats the built-in node:test shape. Placeholders
|
|
297
|
-
// {file}/{pattern} are always substituted; {resultPath} is the FILE-BASED-format placeholder (the
|
|
298
|
-
// runner substitutes a fresh out-of-tree path per probe — see probeBound; validateProfile requires it
|
|
299
|
-
// for a file-based profile argv). A malformed override is a typed refusal, never a silent fall to a
|
|
300
|
-
// shell. Substitution uses function replacers so a `$` in a testId/path is literal.
|
|
301
|
-
export const resolveBoundArgv = (env = process.env, profile = null) => {
|
|
302
|
-
const raw = env.AW_FOLD_BOUND_CMD;
|
|
303
|
-
let tmpl = null;
|
|
304
|
-
if (raw) {
|
|
305
|
-
try {
|
|
306
|
-
tmpl = JSON.parse(raw);
|
|
307
|
-
} catch (err) {
|
|
308
|
-
throw stop(`AW_FOLD_BOUND_CMD is not valid JSON (${err.message}) — expected a JSON array of argv strings`);
|
|
309
|
-
}
|
|
310
|
-
if (!Array.isArray(tmpl) || tmpl.length === 0 || !tmpl.every((a) => typeof a === 'string')) {
|
|
311
|
-
throw stop('AW_FOLD_BOUND_CMD must be a non-empty JSON array of argv strings with {file}/{pattern} placeholders');
|
|
312
|
-
}
|
|
313
|
-
} else if (Array.isArray(profile?.singleTest?.argv) && profile.singleTest.argv.length > 0) {
|
|
314
|
-
tmpl = profile.singleTest.argv;
|
|
315
|
-
}
|
|
316
|
-
if (!tmpl) return (file, pattern) => defaultBoundArgv(file, pattern);
|
|
317
|
-
return (file, pattern, resultPath) =>
|
|
318
|
-
tmpl.map((a) =>
|
|
319
|
-
a
|
|
320
|
-
.replace(/\{file\}/g, () => file)
|
|
321
|
-
.replace(/\{pattern\}/g, () => pattern)
|
|
322
|
-
.replace(/\{resultPath\}/g, () => resultPath ?? ''),
|
|
323
|
-
);
|
|
324
|
-
};
|
|
325
|
-
|
|
326
|
-
// ── read-only git plumbing (the changed-surface computation itself lives in changed-surface.mjs) ──
|
|
327
|
-
|
|
328
|
-
const runGit = (args, cwd) => spawnSync('git', args, { cwd, maxBuffer: GIT_MAX_BUFFER, encoding: 'utf8', windowsHide: true });
|
|
329
|
-
const gitStdout = (args, cwd) => {
|
|
330
|
-
const r = runGit(args, cwd);
|
|
331
|
-
return r.error || r.status > 1 ? null : r.stdout;
|
|
332
|
-
};
|
|
333
|
-
const readFileSafe = (path) => {
|
|
334
|
-
try {
|
|
335
|
-
return readFileSync(path, 'utf8');
|
|
336
|
-
} catch {
|
|
337
|
-
return null;
|
|
338
|
-
}
|
|
339
|
-
};
|
|
340
|
-
const canon = (path) => {
|
|
341
|
-
try {
|
|
342
|
-
return realpathSync(path);
|
|
343
|
-
} catch {
|
|
344
|
-
return path;
|
|
345
|
-
}
|
|
346
|
-
};
|
|
347
|
-
// ── the oracle-tamper surface (BUGFREE-1 Phase 2.2, Approach-3) ───────────────────────────────────
|
|
348
|
-
|
|
349
|
-
const DIFF_HUNK_OLD_RE = /^@@ -\d+(?:,(\d+))? \+\d+(?:,\d+)? @@/;
|
|
350
|
-
|
|
351
|
-
// parseDiffOldSide(diffText) → Map<oldRel, { removals: boolean }> — the OLD-side view of a -U0
|
|
352
|
-
// tracked diff: which pre-existing (HEAD) files carry any removed/modified line (a hunk whose
|
|
353
|
-
// old-count > 0). A file ADDED by the diff (--- /dev/null) has no old side and never appears; a
|
|
354
|
-
// DELETED file's hunks remove every line, so it reads removals: true. Renames under --no-renames
|
|
355
|
-
// read as delete+add — the delete side lands here (stated).
|
|
356
|
-
export const parseDiffOldSide = (diffText) => {
|
|
357
|
-
const map = new Map();
|
|
358
|
-
let current = null;
|
|
359
|
-
let inHeader = false;
|
|
360
|
-
for (const line of String(diffText).split('\n')) {
|
|
361
|
-
if (line.startsWith('diff --git ')) {
|
|
362
|
-
current = null;
|
|
363
|
-
inHeader = true;
|
|
364
|
-
continue;
|
|
365
|
-
}
|
|
366
|
-
if (inHeader && line.startsWith('--- ')) {
|
|
367
|
-
// Strip ONLY the git-appended trailing TAB (space-carrying paths) and a CRLF \r — never a
|
|
368
|
-
// legitimate trailing character of the filename itself (agy R6; a raw TAB in a name is
|
|
369
|
-
// C-quoted anyway, so [\t\r]+$ can only match git artifacts).
|
|
370
|
-
const p = unquoteDiffPath(line.slice(4).replace(/[\t\r]+$/, ''));
|
|
371
|
-
current = p === '/dev/null' ? null : p.startsWith('a/') ? p.slice(2) : p;
|
|
372
|
-
if (current != null && !map.has(current)) map.set(current, { removals: false });
|
|
373
|
-
continue;
|
|
374
|
-
}
|
|
375
|
-
if (inHeader && line.startsWith('+++ ')) {
|
|
376
|
-
inHeader = false;
|
|
377
|
-
continue;
|
|
378
|
-
}
|
|
379
|
-
const m = DIFF_HUNK_OLD_RE.exec(line);
|
|
380
|
-
if (m) {
|
|
381
|
-
inHeader = false;
|
|
382
|
-
if (current == null) continue;
|
|
383
|
-
const oldCount = m[1] === undefined ? 1 : Number(m[1]);
|
|
384
|
-
if (oldCount > 0) map.get(current).removals = true;
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
return map;
|
|
388
|
-
};
|
|
389
|
-
|
|
390
|
-
// computeTamperedTests(root, boundFiles) → { tampered: [rel...] } — the tamper surface is the union
|
|
391
|
-
// of test-classified paths (TEST_FILE_RE) and the loop's bound-testId file halves (the testId format
|
|
392
|
-
// carries no suffix rule, so a bound test at a nonstandard path must not escape the guard),
|
|
393
|
-
// restricted to files that exist at HEAD (having an old side in the tracked diff IS existing at
|
|
394
|
-
// HEAD). Tampered = any removed/modified line; pure additions and new/untracked files never trip it
|
|
395
|
-
// (widening to any-change-is-tamper would flag the standard append-a-new-test flow — the FP churn
|
|
396
|
-
// this series exists to kill).
|
|
397
|
-
export const computeTamperedTests = (root, boundFiles = new Set()) => {
|
|
398
|
-
const trackedDiff = gitStdout(['diff', 'HEAD', ...DIFF_FLAGS], root)
|
|
399
|
-
?? gitStdout(['diff', ...DIFF_FLAGS], root) // no HEAD yet (unborn branch)
|
|
400
|
-
?? '';
|
|
401
|
-
// The file halves are user-authored — './checks/x.mjs' probes and hashes as 'checks/x.mjs', so
|
|
402
|
-
// the surface compares NORMALIZED halves or a modified bound file escapes the guard (codex R5).
|
|
403
|
-
// Normalization happens in GIT/POSIX path space (codex+agy R6): node's OS-local normalize emits
|
|
404
|
-
// backslashes on Windows while git diff paths stay slash-separated — the compare must never
|
|
405
|
-
// depend on the host separator.
|
|
406
|
-
const boundSet = new Set([...boundFiles].map((f) => posix.normalize(f)));
|
|
407
|
-
const tampered = [];
|
|
408
|
-
for (const [rel, info] of parseDiffOldSide(trackedDiff)) {
|
|
409
|
-
if (!info.removals) continue;
|
|
410
|
-
if (classifyChangedPath(rel) === 'excluded-test' || boundSet.has(rel)) tampered.push(rel);
|
|
411
|
-
}
|
|
412
|
-
return { tampered: tampered.sort() };
|
|
413
|
-
};
|
|
414
|
-
|
|
415
|
-
// ── coverage (run the suite once under NODE_V8_COVERAGE, outside the tree) ─────────────────────────
|
|
416
|
-
|
|
417
|
-
// readCoverage(covDir) → Map<canonicalAbsPath, Array<Array<range>>> — one flat range-list per process.
|
|
418
|
-
const readCoverage = (covDir) => {
|
|
419
|
-
const byPath = new Map();
|
|
420
|
-
let files;
|
|
421
|
-
try {
|
|
422
|
-
files = readdirSync(covDir);
|
|
423
|
-
} catch {
|
|
424
|
-
return byPath;
|
|
425
|
-
}
|
|
426
|
-
for (const f of files) {
|
|
427
|
-
if (!f.endsWith('.json')) continue;
|
|
428
|
-
let parsed;
|
|
429
|
-
try {
|
|
430
|
-
parsed = JSON.parse(readFileSync(join(covDir, f), 'utf8'));
|
|
431
|
-
} catch {
|
|
432
|
-
continue;
|
|
433
|
-
}
|
|
434
|
-
if (!parsed || !Array.isArray(parsed.result)) continue;
|
|
435
|
-
for (const entry of parsed.result) {
|
|
436
|
-
if (typeof entry.url !== 'string' || !entry.url.startsWith('file:')) continue;
|
|
437
|
-
let abs;
|
|
438
|
-
try {
|
|
439
|
-
abs = canon(fileURLToPath(entry.url));
|
|
440
|
-
} catch {
|
|
441
|
-
continue;
|
|
442
|
-
}
|
|
443
|
-
const ranges = [];
|
|
444
|
-
for (const fn of entry.functions ?? []) for (const r of fn.ranges ?? []) ranges.push({ startOffset: r.startOffset, endOffset: r.endOffset, count: r.count });
|
|
445
|
-
if (!byPath.has(abs)) byPath.set(abs, []);
|
|
446
|
-
byPath.get(abs).push(ranges);
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
return byPath;
|
|
450
|
-
};
|
|
451
|
-
|
|
452
|
-
// ── suite command discovery (Decision 10) ─────────────────────────────────────────────────────────
|
|
453
|
-
|
|
454
|
-
const GATES_REL = 'docs/ai/gates.json';
|
|
455
|
-
const resolveSuiteCmd = (root, env, explicit) => {
|
|
456
|
-
if (explicit) return explicit;
|
|
457
|
-
if (env.AW_FOLD_SUITE_CMD) return env.AW_FOLD_SUITE_CMD;
|
|
458
|
-
const raw = readFileSafe(join(root, GATES_REL));
|
|
459
|
-
if (raw) {
|
|
460
|
-
try {
|
|
461
|
-
const gate = (JSON.parse(raw).gates ?? []).find((g) => g && g.id === 'unit-tests');
|
|
462
|
-
if (gate && typeof gate.cmd === 'string') return gate.cmd;
|
|
463
|
-
} catch {
|
|
464
|
-
/* fall through to the loud refusal */
|
|
465
|
-
}
|
|
466
|
-
}
|
|
467
|
-
throw stop(`cannot resolve the suite command — no unit-tests gate in ${GATES_REL}; pass --suite "<cmd>" or set AW_FOLD_SUITE_CMD`);
|
|
468
|
-
};
|
|
469
|
-
|
|
470
|
-
// ── the append primitive (whole-file read → add one JSONL line → atomic rewrite) ─────────────────
|
|
471
|
-
|
|
472
|
-
const appendRecord = (resultsPath, record) => {
|
|
473
|
-
let existing = '';
|
|
474
|
-
try {
|
|
475
|
-
existing = readFileSync(resultsPath, 'utf8');
|
|
476
|
-
} catch (err) {
|
|
477
|
-
if (err && err.code === 'ENOENT') existing = '';
|
|
478
|
-
else throw stop(`cannot read the result ledger before appending (${(err && err.code) || (err && err.message) || err}) — refusing to overwrite it (fail closed)`);
|
|
479
|
-
}
|
|
480
|
-
const prefix = existing === '' ? '' : existing.endsWith('\n') ? existing : `${existing}\n`;
|
|
481
|
-
const body = `${prefix}${JSON.stringify(record)}\n`;
|
|
482
|
-
writeContainedFileAtomic(dirname(resultsPath), resultsPath, body, {}, { stop, label: resultsPath });
|
|
483
|
-
return { writtenPath: resultsPath, record };
|
|
484
|
-
};
|
|
485
|
-
|
|
486
|
-
// ── the run ───────────────────────────────────────────────────────────────────────────────────────
|
|
487
|
-
|
|
488
|
-
export const budgetsFromEnv = (env) => ({
|
|
489
|
-
mutantsMax: Number.parseInt(env.AW_FOLD_MUTANTS_MAX ?? '200', 10) || 200,
|
|
490
|
-
hunkMutantsMax: Number.parseInt(env.AW_FOLD_HUNK_MUTANTS_MAX ?? '25', 10) || 25,
|
|
491
|
-
timeBudgetS: Number.parseInt(env.AW_FOLD_TIME_BUDGET_S ?? '600', 10) || 600,
|
|
492
|
-
// D4: N reruns per probe side; the per-RUN probe timeout (each of the N runs gets its own budget,
|
|
493
|
-
// never one shared series budget — agy R1). Probes only: the suite run keeps the no-timeout status
|
|
494
|
-
// quo. The fail-closed parser is the shared one (changed-surface.mjs), thrown as THIS tool's STOP.
|
|
495
|
-
foldReruns: parsePositiveIntKnob(env, 'AW_FOLD_RERUNS', 3, stop),
|
|
496
|
-
probeTimeoutS: parsePositiveIntKnob(env, 'AW_FOLD_PROBE_TIMEOUT_S', 120, stop),
|
|
497
|
-
});
|
|
498
|
-
|
|
499
|
-
// ── the shared N-rerun probe (D4) — ONE helper for both the run's green side and the --red verb ───
|
|
500
|
-
|
|
501
|
-
// probeBound({ id, rootTop, env, boundArgv, reruns, timeoutS }) → { entry, resolveReason }. The entry
|
|
502
|
-
// is the v2 per-testId record shape: rerun counts (evidence) + the custody content hash + the derived
|
|
503
|
-
// booleans. The custody hash is taken BEFORE the runs (the content the observation attests). A
|
|
504
|
-
// timed-out or signal-killed run is neither red nor green — it lands in `timeouts` (quarantine fuel).
|
|
505
|
-
const probeBound = ({ id, rootTop, env, boundArgv, resultFormat = 'tap-stdout', reruns, timeoutS }) => {
|
|
506
|
-
const { file, pattern } = splitTestId(id);
|
|
507
|
-
const resolved = resolveTestFile(rootTop, file);
|
|
508
|
-
const fileHash = resolved.ok ? hashFileBytes(resolved.abs) : null;
|
|
509
|
-
const fileBased = FILE_BASED_FORMATS.has(resultFormat);
|
|
510
|
-
let executed = 0;
|
|
511
|
-
let greens = 0;
|
|
512
|
-
let reds = 0;
|
|
513
|
-
let timeouts = 0;
|
|
514
|
-
if (resolved.ok && fileHash != null) {
|
|
515
|
-
for (let i = 0; i < reruns; i += 1) {
|
|
516
|
-
// FILE-BASED formats (tap-file / junit-xml): a FRESH out-of-tree result path PER probe run (the
|
|
517
|
-
// mkdtempSync-outside-tree precedent) — this realizes the freshness invariant: a stale green
|
|
518
|
-
// file from a previous run / a crashed or zero-match probe can never be re-read as green.
|
|
519
|
-
let resultDir = null;
|
|
520
|
-
let resultPath = null;
|
|
521
|
-
if (fileBased) {
|
|
522
|
-
resultDir = mkdtempSync(join(tmpdir(), 'agent-workflow-fold-probe-'));
|
|
523
|
-
resultPath = join(resultDir, resultFormat === 'junit-xml' ? 'result.xml' : 'result.tap');
|
|
524
|
-
}
|
|
525
|
-
try {
|
|
526
|
-
// ALWAYS spawn the resolver's canonical absolute path — the executed file must be the hashed
|
|
527
|
-
// file independent of runner path semantics (codex R1+R2, BUGFREE-1 live loop): a raw
|
|
528
|
-
// leading-dash filename parses as an OPTION (and a ./-prefix does not survive node's runner
|
|
529
|
-
// normalization), and a raw traversal path like linkdir/../x lets an OS-resolving runner
|
|
530
|
-
// execute a different filesystem target than the lexically-normalized file the hash covers.
|
|
531
|
-
const argv = boundArgv(resolved.abs, pattern, resultPath);
|
|
532
|
-
const res = spawnSync(argv[0], argv.slice(1), {
|
|
533
|
-
cwd: rootTop, env: childTestEnv(env), encoding: 'utf8', maxBuffer: GIT_MAX_BUFFER, timeout: timeoutS * 1000,
|
|
534
|
-
});
|
|
535
|
-
if ((res.error && res.error.code === 'ETIMEDOUT') || res.signal != null) {
|
|
536
|
-
timeouts += 1;
|
|
537
|
-
continue;
|
|
538
|
-
}
|
|
539
|
-
const resultText = fileBased ? readFileSafe(resultPath) : null;
|
|
540
|
-
const p = parseProbeResult({ format: resultFormat, stdout: res.stdout ?? '', code: res.error ? 1 : res.status ?? 1, fileArg: file, resultText });
|
|
541
|
-
executed = Math.max(executed, p.executed);
|
|
542
|
-
if (!p.resolvable) continue; // an unresolved run (the pattern selected nothing / no result file)
|
|
543
|
-
if (p.baselineGreen) greens += 1;
|
|
544
|
-
else reds += 1;
|
|
545
|
-
} finally {
|
|
546
|
-
if (resultDir) rmSync(resultDir, { recursive: true, force: true });
|
|
547
|
-
}
|
|
548
|
-
}
|
|
549
|
-
}
|
|
550
|
-
const entry = {
|
|
551
|
-
id, executed, runs: reruns, greens, reds, timeouts, fileHash,
|
|
552
|
-
resolvable: greens + reds === reruns, baselineGreen: greens === reruns,
|
|
553
|
-
};
|
|
554
|
-
return { entry, resolveReason: resolved.ok ? (fileHash == null ? `cannot read "${file}"` : null) : resolved.reason };
|
|
555
|
-
};
|
|
556
|
-
|
|
557
|
-
// runFoldCompleteness({ cwd, env, suiteCmd }) → { writtenPath, record }. THROWS a typed STOP (loop
|
|
558
|
-
// derivation / suite discovery / a malformed record / an fs error) or a native fs error.
|
|
559
|
-
export const runFoldCompleteness = ({ cwd = process.cwd(), env = process.env, suiteCmd } = {}) => {
|
|
560
|
-
const root = gitStdout(['rev-parse', '--show-toplevel'], cwd);
|
|
561
|
-
if (root == null) throw stop('not a git work tree — nothing to assess');
|
|
562
|
-
const rootTop = root.replace(/\r?\n$/, '');
|
|
563
|
-
|
|
564
|
-
// Decision: the loop = the single in-flight plan stem; 0 or >1 → refuse (ambiguous).
|
|
565
|
-
const plans = plansInFlight(rootTop);
|
|
566
|
-
if (plans.length === 0) throw stop('no plan in flight (docs/plans/ holds no active plan) — nothing to assess');
|
|
567
|
-
if (plans.length > 1) throw stop(`more than one plan in flight (${plans.join(', ')}) — ambiguous loop id; resolve to one active plan`);
|
|
568
|
-
const loop = plans[0].replace(/\.md$/, '');
|
|
569
|
-
|
|
570
|
-
const fingerprint = computeTreeFingerprint(cwd);
|
|
571
|
-
const cmd = resolveSuiteCmd(rootTop, env, suiteCmd);
|
|
572
|
-
// The verification profile (BUGFREE-3 / AD-049) decides the coverage SOURCE and the single-test
|
|
573
|
-
// RESULT FORMAT + argv template. Absent → today's V8 + node:test-TAP-on-stdout. Loaded BEFORE any
|
|
574
|
-
// spawn so a malformed profile / unsafe declared path (Decision 4) — and a malformed
|
|
575
|
-
// AW_FOLD_BOUND_CMD — both refuse loudly before the suite runs.
|
|
576
|
-
const { profile } = loadProfile(rootTop);
|
|
577
|
-
const boundArgv = resolveBoundArgv(env, profile);
|
|
578
|
-
const { resultFormat } = resolveSingleTest(profile);
|
|
579
|
-
const budgets = budgetsFromEnv(env);
|
|
580
|
-
|
|
581
|
-
const { assessable, unsupported, outOfDomain } = computeChangedSurface(rootTop);
|
|
582
|
-
|
|
583
|
-
// Coverage SOURCE: absent / kind "v8" → today's V8 path; kind "lcov" → the consumer's suite leaves
|
|
584
|
-
// an LCOV file at the declared path (validated gitignored/out-of-tree by loadProfile — Decision 4).
|
|
585
|
-
const { kind: coverageKind, lcovPath } = resolveCoverage(profile);
|
|
586
|
-
const lcovAbs = coverageKind === 'lcov' ? (isAbsolute(lcovPath) ? lcovPath : join(rootTop, lcovPath)) : null;
|
|
587
|
-
// FRESHNESS: remove any STALE LCOV before the suite runs — symmetric with
|
|
588
|
-
// the V8 fresh mkdtemp covDir. A suite that fails/is misconfigured and does NOT re-emit LCOV then reads
|
|
589
|
-
// ABSENT (a loud STOP below), never a leftover file that could mask an uncovered changed line as green.
|
|
590
|
-
if (coverageKind === 'lcov') rmSync(lcovAbs, { force: true });
|
|
591
|
-
|
|
592
|
-
// M3a: run the suite ONCE, then map every changed executable line to covered/uncovered. V8 injects
|
|
593
|
-
// NODE_V8_COVERAGE into a dir OUTSIDE the work tree (Decision 8); LCOV runs the suite clean and
|
|
594
|
-
// reads the file the suite itself wrote (the env stays untouched). Either source resolves to the
|
|
595
|
-
// SAME canonical-abs key space, so the ONE per-file loop below consumes both.
|
|
596
|
-
const covDir = coverageKind === 'v8' ? mkdtempSync(join(tmpdir(), 'agent-workflow-fold-cov-')) : null;
|
|
597
|
-
let coverage; // v8 → Map<absKey, Array<Array<range>>>; lcov → Map<absKey, Map<line, hits>>
|
|
598
|
-
let suiteExit = null; // (a) v4: the suite exit code — the credit fires only on exit 0
|
|
599
|
-
try {
|
|
600
|
-
const suiteEnv = coverageKind === 'v8' ? childTestEnv(env, { NODE_V8_COVERAGE: covDir }) : childTestEnv(env);
|
|
601
|
-
const suite = spawnSync('bash', ['-c', cmd], { cwd: rootTop, env: suiteEnv, encoding: 'utf8', maxBuffer: GIT_MAX_BUFFER });
|
|
602
|
-
if (suite.error && suite.error.code === 'ENOENT') throw stop('bash is unavailable — the suite command is a bash command line');
|
|
603
|
-
suiteExit = suite.status; // number, or null when signal-killed (a null exit never credits — like nonzero)
|
|
604
|
-
if (coverageKind === 'lcov') {
|
|
605
|
-
const lcovText = readFileSafe(lcovAbs);
|
|
606
|
-
if (lcovText == null) {
|
|
607
|
-
throw stop(`coverage.kind is "lcov" but no LCOV file was found at "${lcovPath}" after the suite ran — ensure the suite writes LCOV there (see docs/ai/verification-profile.json)`);
|
|
608
|
-
}
|
|
609
|
-
coverage = lcovCoveredMap(lcovText, rootTop, { canon });
|
|
610
|
-
} else {
|
|
611
|
-
coverage = readCoverage(covDir);
|
|
612
|
-
}
|
|
613
|
-
} finally {
|
|
614
|
-
if (covDir) rmSync(covDir, { recursive: true, force: true });
|
|
615
|
-
}
|
|
616
|
-
// (a) v4 suite-execution evidence: the ONE suite spawn per fingerprint, recorded so run-gates
|
|
617
|
-
// --record can CREDIT the unit-tests gate from it (fingerprint-bound + tree-unchanged + cmd-identity
|
|
618
|
-
// + exit-0 — the ledger writer enforces that). The POST fingerprint proves the suite left the tree
|
|
619
|
-
// unchanged (coverage went out-of-tree / to a gitignored LCOV path).
|
|
620
|
-
const fingerprintAfter = computeTreeFingerprint(cwd);
|
|
621
|
-
const suite = { cmd, exit: suiteExit ?? null, fingerprintBefore: fingerprint, fingerprintAfter };
|
|
622
|
-
const uncoveredChanged = [];
|
|
623
|
-
for (const [rel, lines] of assessable) {
|
|
624
|
-
const key = canon(join(rootTop, rel));
|
|
625
|
-
if (coverageKind === 'lcov') {
|
|
626
|
-
// LCOV supplies the per-file uncovered set directly (computeUncoveredLines stays the V8-only
|
|
627
|
-
// path — D10); a file absent from the LCOV → a file-level RED, exactly like the V8 case.
|
|
628
|
-
const uncov = uncoveredChangedFromLcov(coverage, key, lines);
|
|
629
|
-
if (uncov === null) {
|
|
630
|
-
uncoveredChanged.push({ file: rel, line: null });
|
|
631
|
-
continue;
|
|
632
|
-
}
|
|
633
|
-
for (const n of uncov) uncoveredChanged.push({ file: rel, line: n });
|
|
634
|
-
} else {
|
|
635
|
-
const perProc = coverage.get(key);
|
|
636
|
-
if (!perProc || perProc.length === 0) {
|
|
637
|
-
uncoveredChanged.push({ file: rel, line: null }); // absent from coverage → file-level RED (Decision 6)
|
|
638
|
-
continue;
|
|
639
|
-
}
|
|
640
|
-
const src = readFileSafe(join(rootTop, rel));
|
|
641
|
-
if (src == null) continue;
|
|
642
|
-
for (const n of computeUncoveredLines({ perProcessRanges: perProc, sourceText: src, changedLines: lines })) uncoveredChanged.push({ file: rel, line: n });
|
|
643
|
-
}
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
// Decision 3 / 10 + D4: probe each of the SEGMENT's fixable-bug bound testIds N times
|
|
647
|
-
// (shell-free, per-run timeout). Segment scope (D7): a committed phase's folds are closed
|
|
648
|
-
// obligations — only triages recorded at the current base bind.
|
|
649
|
-
const ledgerPath = resolveLedgerPath(cwd, env);
|
|
650
|
-
const { records: reviewRecords } = ledgerPath ? readLedger(ledgerPath) : { records: [] };
|
|
651
|
-
const base = resolveBase(cwd);
|
|
652
|
-
const boundTestIds = collectBoundTestIds(reviewRecords, { activity: ACTIVITY, loop, base });
|
|
653
|
-
const testIds = boundTestIds.map(
|
|
654
|
-
(id) => probeBound({ id, rootTop, env, boundArgv, resultFormat, reruns: budgets.foldReruns, timeoutS: budgets.probeTimeoutS }).entry,
|
|
655
|
-
);
|
|
656
|
-
|
|
657
|
-
// Approach-3: the oracle-tamper pass over the tracked working-vs-HEAD diff, restricted to the
|
|
658
|
-
// test surface ∪ the bound-testId file halves. Recorded; the checker enforces overrides.
|
|
659
|
-
const tamper = computeTamperedTests(rootTop, new Set(boundTestIds.map((id) => splitTestId(id).file)));
|
|
660
|
-
|
|
661
|
-
const record = {
|
|
662
|
-
schema: RESULT_SCHEMA_VERSION,
|
|
663
|
-
kind: 'run',
|
|
664
|
-
loop,
|
|
665
|
-
base,
|
|
666
|
-
fingerprint,
|
|
667
|
-
boundTestIds,
|
|
668
|
-
testIds,
|
|
669
|
-
unsupported,
|
|
670
|
-
outOfDomain,
|
|
671
|
-
coverage: { uncoveredChanged },
|
|
672
|
-
tamper,
|
|
673
|
-
suite, // (a) v4 suite-execution evidence
|
|
674
|
-
mutation: { total: 0, killed: 0, survived: [], skipped: 0, killSetBasis: null }, // reserved — mutation not shipped (shelved)
|
|
675
|
-
budgets,
|
|
676
|
-
timestamp: isoNow(),
|
|
677
|
-
};
|
|
678
|
-
const v = validateRunRecord(record);
|
|
679
|
-
if (!v.ok) throw stop(`refusing to write a malformed result record: ${v.reason}`);
|
|
680
|
-
|
|
681
|
-
const resultsPath = resolveResultsPath(cwd, env);
|
|
682
|
-
if (resultsPath == null) throw stop('cannot resolve the result-ledger path — not a git work tree and AW_FOLD_RESULTS is unset');
|
|
683
|
-
return appendRecord(resultsPath, record);
|
|
684
|
-
};
|
|
685
|
-
|
|
686
|
-
// ── the --red verb (D6): observe RED when it actually happens, mint the custody receipt ──────────
|
|
687
|
-
|
|
688
|
-
// runRedProbe({ cwd, env, testId }) → { writtenPath, record }. Observes `testId` on the CURRENT
|
|
689
|
-
// (pre-fold) tree: resolvable + failing on N/N runs → appends a red-probe receipt (testId, counts,
|
|
690
|
-
// the test file's content hash, fingerprint, timestamp) to the fold results ledger. Observed green,
|
|
691
|
-
// unresolvable, mixed, or timed out → a typed refusal DISTINGUISHED by name, and NOTHING is written
|
|
692
|
-
// (D4: mixed/timeout is QUARANTINE — it never converts and has no override lane). No triage-order
|
|
693
|
-
// requirement: the checker joins receipts to the bound set at gate time (D6).
|
|
694
|
-
export const runRedProbe = ({ cwd = process.cwd(), env = process.env, testId } = {}) => {
|
|
695
|
-
if (!isWellFormedTestId(testId)) {
|
|
696
|
-
throw usageFail(`--red needs a well-formed testId "<test-file>#<test-name-pattern>" (a "#" separator, both halves non-empty; got ${JSON.stringify(testId)})`);
|
|
697
|
-
}
|
|
698
|
-
const root = gitStdout(['rev-parse', '--show-toplevel'], cwd);
|
|
699
|
-
if (root == null) throw stop('not a git work tree — nothing to observe');
|
|
700
|
-
const rootTop = root.replace(/\r?\n$/, '');
|
|
701
|
-
const plans = plansInFlight(rootTop);
|
|
702
|
-
if (plans.length === 0) throw stop('no plan in flight (docs/plans/ holds no active plan) — nothing to observe');
|
|
703
|
-
if (plans.length > 1) throw stop(`more than one plan in flight (${plans.join(', ')}) — ambiguous loop id; resolve to one active plan`);
|
|
704
|
-
const loop = plans[0].replace(/\.md$/, '');
|
|
705
|
-
|
|
706
|
-
const { profile } = loadProfile(rootTop);
|
|
707
|
-
const boundArgv = resolveBoundArgv(env, profile);
|
|
708
|
-
const { resultFormat } = resolveSingleTest(profile);
|
|
709
|
-
const budgets = budgetsFromEnv(env);
|
|
710
|
-
const { entry, resolveReason } = probeBound({ id: testId, rootTop, env, boundArgv, resultFormat, reruns: budgets.foldReruns, timeoutS: budgets.probeTimeoutS });
|
|
711
|
-
const verdict = probeVerdict(entry);
|
|
712
|
-
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)`;
|
|
713
|
-
if (verdict === 'unresolvable') {
|
|
714
|
-
throw stop(
|
|
715
|
-
`--red refused for "${testId}": unresolvable — ${resolveReason ?? 'the pattern selects no test'} (${counts}). ` +
|
|
716
|
-
`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; ` +
|
|
717
|
-
`if the red is genuinely unestablishable, the loud escape is a recorded red-proof override (review-ledger-write override). Nothing was recorded.`,
|
|
718
|
-
);
|
|
719
|
-
}
|
|
720
|
-
if (verdict === 'green') {
|
|
721
|
-
throw stop(
|
|
722
|
-
`--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. ` +
|
|
723
|
-
`Write a test that FAILS before the fix is applied, then re-run --red BEFORE folding the fix. Nothing was recorded.`,
|
|
724
|
-
);
|
|
725
|
-
}
|
|
726
|
-
if (verdict === 'quarantine') {
|
|
727
|
-
const flavor = entry.timeouts > 0
|
|
728
|
-
? `${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`
|
|
729
|
-
: `mixed outcomes (${counts}) — a flaky test can launder a fake red`;
|
|
730
|
-
throw stop(
|
|
731
|
-
`--red refused for "${testId}": QUARANTINE — ${flavor}. QUARANTINE never converts and has no override lane: ` +
|
|
732
|
-
`${entry.timeouts > 0 ? 'raise the timeout or make the test faster' : 'replace the flaky test'}, then re-observe. Nothing was recorded.`,
|
|
733
|
-
);
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
const record = {
|
|
737
|
-
schema: RESULT_SCHEMA_VERSION,
|
|
738
|
-
kind: 'red-probe',
|
|
739
|
-
loop,
|
|
740
|
-
base: resolveBase(cwd), // the SEGMENT frame (D7): a receipt attests red within its segment
|
|
741
|
-
testId,
|
|
742
|
-
fileHash: entry.fileHash,
|
|
743
|
-
runs: entry.runs,
|
|
744
|
-
reds: entry.reds,
|
|
745
|
-
fingerprint: computeTreeFingerprint(cwd),
|
|
746
|
-
timestamp: isoNow(),
|
|
747
|
-
};
|
|
748
|
-
const v = validateRunRecord(record);
|
|
749
|
-
if (!v.ok) throw stop(`refusing to write a malformed red-probe record: ${v.reason}`);
|
|
750
|
-
const resultsPath = resolveResultsPath(cwd, env);
|
|
751
|
-
if (resultsPath == null) throw stop('cannot resolve the result-ledger path — not a git work tree and AW_FOLD_RESULTS is unset');
|
|
752
|
-
return appendRecord(resultsPath, record);
|
|
753
|
-
};
|
|
754
|
-
|
|
755
|
-
// ── the --reattest verb (c): re-anchor custody at a bound test file's CURRENT bytes ────────────────
|
|
756
|
-
|
|
757
|
-
// runReattest({ cwd, env, testId }) → { writtenPath, record }. Records the test file's CURRENT hash as
|
|
758
|
-
// a custody anchor after a green-only append — the honest replacement for mis-using a red-proof waiver
|
|
759
|
-
// (a green append has no red to observe). Operator-ASSERTED, never auto-detected: "additions-only" is
|
|
760
|
-
// unsafe to auto-relax (an in-body `return;` is additions-only yet weakening). Re-attest ONLY re-anchors
|
|
761
|
-
// custody — the N/N-green probe and observed-red receipt requirements are unchanged, so a red test
|
|
762
|
-
// still fails the gate, and the custody guard still fails closed on any un-reattested change.
|
|
763
|
-
export const runReattest = ({ cwd = process.cwd(), env = process.env, testId } = {}) => {
|
|
764
|
-
if (!isWellFormedTestId(testId)) {
|
|
765
|
-
throw usageFail(`--reattest needs a well-formed testId "<test-file>#<test-name-pattern>" (a "#" separator, both halves non-empty; got ${JSON.stringify(testId)})`);
|
|
766
|
-
}
|
|
767
|
-
const root = gitStdout(['rev-parse', '--show-toplevel'], cwd);
|
|
768
|
-
if (root == null) throw stop('not a git work tree — nothing to re-attest');
|
|
769
|
-
const rootTop = root.replace(/\r?\n$/, '');
|
|
770
|
-
const plans = plansInFlight(rootTop);
|
|
771
|
-
if (plans.length === 0) throw stop('no plan in flight (docs/plans/ holds no active plan) — nothing to re-attest');
|
|
772
|
-
if (plans.length > 1) throw stop(`more than one plan in flight (${plans.join(', ')}) — ambiguous loop id; resolve to one active plan`);
|
|
773
|
-
const loop = plans[0].replace(/\.md$/, '');
|
|
774
|
-
|
|
775
|
-
const { file } = splitTestId(testId);
|
|
776
|
-
const resolved = resolveTestFile(rootTop, file);
|
|
777
|
-
if (!resolved.ok) throw stop(`--reattest refused for "${testId}": ${resolved.reason} — cannot anchor custody to a file that does not resolve safely`);
|
|
778
|
-
const fileHash = hashFileBytes(resolved.abs);
|
|
779
|
-
if (fileHash == null) throw stop(`--reattest refused for "${testId}": cannot read "${file}" — nothing to anchor`);
|
|
780
|
-
|
|
781
|
-
const record = {
|
|
782
|
-
schema: RESULT_SCHEMA_VERSION,
|
|
783
|
-
kind: REATTEST_KIND,
|
|
784
|
-
loop,
|
|
785
|
-
base: resolveBase(cwd), // the SEGMENT frame (D7): a re-attest never crosses a commit boundary
|
|
786
|
-
testId,
|
|
787
|
-
fileHash,
|
|
788
|
-
fingerprint: computeTreeFingerprint(cwd),
|
|
789
|
-
timestamp: isoNow(),
|
|
790
|
-
};
|
|
791
|
-
const v = validateRunRecord(record);
|
|
792
|
-
if (!v.ok) throw stop(`refusing to write a malformed re-attest record: ${v.reason}`);
|
|
793
|
-
const resultsPath = resolveResultsPath(cwd, env);
|
|
794
|
-
if (resultsPath == null) throw stop('cannot resolve the result-ledger path — not a git work tree and AW_FOLD_RESULTS is unset');
|
|
795
|
-
return appendRecord(resultsPath, record);
|
|
796
|
-
};
|
|
797
|
-
|
|
798
|
-
// ── the --preflight verb (f): the CHEAP half — the overrides/re-attests to record BEFORE coverage ──
|
|
799
|
-
|
|
800
|
-
// runPreflight({ cwd, env }) → { loop, base, fingerprint, boundTestIds, tamper, actions }. Read-only:
|
|
801
|
-
// runs only the cheap set (ledger reads + tamper + per-bound-file custody hashing) and returns the
|
|
802
|
-
// actions to RECORD before the expensive coverage/probe pass, routed by kind — `oracle-change` for a
|
|
803
|
-
// tampered test file, `reattest` for a green-only custody delta, `red` for a bound testId with no
|
|
804
|
-
// observed-red receipt. Coverage is never predicted, the suite is never spawned, nothing is written.
|
|
805
|
-
export const runPreflight = ({ cwd = process.cwd(), env = process.env } = {}) => {
|
|
806
|
-
const root = gitStdout(['rev-parse', '--show-toplevel'], cwd);
|
|
807
|
-
if (root == null) throw stop('not a git work tree — nothing to preflight');
|
|
808
|
-
const rootTop = root.replace(/\r?\n$/, '');
|
|
809
|
-
const plans = plansInFlight(rootTop);
|
|
810
|
-
if (plans.length === 0) throw stop('no plan in flight (docs/plans/ holds no active plan) — nothing to preflight');
|
|
811
|
-
if (plans.length > 1) throw stop(`more than one plan in flight (${plans.join(', ')}) — ambiguous loop id; resolve to one active plan`);
|
|
812
|
-
const loop = plans[0].replace(/\.md$/, '');
|
|
813
|
-
const base = resolveBase(cwd);
|
|
814
|
-
const fingerprint = computeTreeFingerprint(cwd);
|
|
815
|
-
|
|
816
|
-
// Cheap reads only — the ledgers + the git diff. No suite, no probes, no coverage (the reorder note:
|
|
817
|
-
// these live AFTER the coverage block in runFoldCompleteness; the preflight pulls them forward).
|
|
818
|
-
const ledgerPath = resolveLedgerPath(cwd, env);
|
|
819
|
-
// Fail CLOSED on an unreadable/malformed review ledger — the SAME posture decideCheck takes (a
|
|
820
|
-
// dropped line could hide a bound testId / an override); never a false all-clear.
|
|
821
|
-
const reviewRead = ledgerPath ? readLedger(ledgerPath) : { records: [], malformed: 0 };
|
|
822
|
-
if (reviewRead.readError) throw stop(`cannot read the review ledger (${reviewRead.readError}) — failing closed; inspect ${ledgerPath}`);
|
|
823
|
-
if (reviewRead.malformed > 0) throw stop(`the review ledger has ${reviewRead.malformed} malformed line(s) — failing closed; inspect ${ledgerPath}`);
|
|
824
|
-
const reviewRecords = reviewRead.records;
|
|
825
|
-
const boundTestIds = collectBoundTestIds(reviewRecords, { activity: ACTIVITY, loop, base });
|
|
826
|
-
const boundSet = new Set(boundTestIds);
|
|
827
|
-
const boundFiles = new Set(boundTestIds.map((id) => splitTestId(id).file));
|
|
828
|
-
const tamper = computeTamperedTests(rootTop, boundFiles); // the SAME tamper surface the run records
|
|
829
|
-
const tamperedSet = new Set(tamper.tampered);
|
|
830
|
-
const overrides = collectOverrides(reviewRecords, { activity: ACTIVITY, loop });
|
|
831
|
-
|
|
832
|
-
const resultsPath = resolveResultsPath(cwd, env);
|
|
833
|
-
const resultRead = resultsPath ? readResults(resultsPath) : { records: [], malformed: 0 };
|
|
834
|
-
if (resultRead.readError) throw stop(`cannot read the result ledger (${resultRead.readError}) — failing closed; inspect ${resultsPath}`);
|
|
835
|
-
if (resultRead.malformed > 0) throw stop(`the result ledger has ${resultRead.malformed} malformed line(s) — failing closed; inspect ${resultsPath}`);
|
|
836
|
-
const segRecords = filterSegmentResults(resultRead.records, loop, base);
|
|
837
|
-
const anchors = segRecords.filter((r) => isRedProbeRecord(r) || isReattestRecord(r)); // custody anchors
|
|
838
|
-
const receipts = segRecords.filter((r) => isRedProbeRecord(r)); // observed-red receipts only
|
|
839
|
-
|
|
840
|
-
const actions = [];
|
|
841
|
-
// 1. tampered test-surface files → oracle-change (unless already covered). ORTHOGONAL to the
|
|
842
|
-
// per-testId chain below: decideCheck's tamper guard and its per-testId observed-red + custody
|
|
843
|
-
// chain are independent guards — a tampered bound file needs BOTH an oracle-change AND a
|
|
844
|
-
// current-bytes custody anchor for each of its bound testIds.
|
|
845
|
-
for (const f of tamper.tampered) {
|
|
846
|
-
if (overrides.oracleChangeFiles.has(f)) continue;
|
|
847
|
-
actions.push({
|
|
848
|
-
kind: 'oracle-change',
|
|
849
|
-
file: f,
|
|
850
|
-
command: `node review-ledger-write.mjs override --json '{"loop":"${loop}","round":<n>,"scope":"oracle-change","files":${JSON.stringify([f])},"reason":"<why the expectation legitimately changed>"}'`,
|
|
851
|
-
});
|
|
852
|
-
}
|
|
853
|
-
// 2. per bound testId — mirror decideCheck's per-testId requirements. There is NO tamper skip: a
|
|
854
|
-
// tampered file's bound testIds STILL face the receipt + custody chain, so skipping them read as a
|
|
855
|
-
// false all-clear. Ordered as decideCheck evaluates: unresolvable (a hard fail before any override
|
|
856
|
-
// lane) → missing receipt → custody delta.
|
|
857
|
-
const reattestedFiles = new Set(); // one re-attest re-anchors the whole file (decideCheck keys custody by file) — dedup
|
|
858
|
-
const unresolvableFiles = new Set(); // recovery is file-level (restore / re-triage) — dedup
|
|
859
|
-
for (const id of boundTestIds) {
|
|
860
|
-
const { file } = splitTestId(id);
|
|
861
|
-
const resolved = resolveTestFile(rootTop, file);
|
|
862
|
-
const currentHash = resolved.ok ? hashFileBytes(resolved.abs) : null;
|
|
863
|
-
if (currentHash == null) {
|
|
864
|
-
// the bound file does not resolve → decideCheck fails `unresolvable` UNCONDITIONALLY, BEFORE the
|
|
865
|
-
// red-proof / oracle-change lanes (probeVerdict `unresolvable` precedes the red-proof `continue`) —
|
|
866
|
-
// no override lifts it. This check MUST precede the red-proof skip below, else a red-proof'd deleted
|
|
867
|
-
// bound file reads clear here yet fails decideCheck. A deleted file is also tampered (the
|
|
868
|
-
// oracle-change above fires), but that does NOT rescue a deletion; surface the blocker.
|
|
869
|
-
if (!unresolvableFiles.has(file)) {
|
|
870
|
-
unresolvableFiles.add(file);
|
|
871
|
-
actions.push({
|
|
872
|
-
kind: 'unresolvable',
|
|
873
|
-
testId: id,
|
|
874
|
-
file,
|
|
875
|
-
note: `the bound test file ${file} does not resolve — the probe reads unresolvable and no override (oracle-change / red-proof / re-attest) lifts it; restore the file or re-triage the fixable-bug binding`,
|
|
876
|
-
});
|
|
877
|
-
}
|
|
878
|
-
continue;
|
|
879
|
-
}
|
|
880
|
-
if (overrides.redProofTestIds.has(id)) continue; // red-proof waives the receipt + custody proof — but ONLY for a resolvable file (the unresolvable guard above runs first, per decideCheck)
|
|
881
|
-
const own = receipts.filter((r) => r.testId === id);
|
|
882
|
-
if (own.length === 0) {
|
|
883
|
-
// no observed-red receipt — strictly per-testId (never deduped by file).
|
|
884
|
-
actions.push({
|
|
885
|
-
kind: 'red',
|
|
886
|
-
testId: id,
|
|
887
|
-
command: `node fold-completeness-run.mjs --red ${JSON.stringify(id)}`,
|
|
888
|
-
note: tamperedSet.has(file)
|
|
889
|
-
? 'the test file was modified (tampered) — observe red at the modified expectations before folding; if the red is genuinely unestablishable, record a red-proof override instead'
|
|
890
|
-
: 'observe red BEFORE folding the fix; if the red is genuinely unestablishable, record a red-proof override instead',
|
|
891
|
-
});
|
|
892
|
-
continue;
|
|
893
|
-
}
|
|
894
|
-
const fileAnchors = anchors.filter((r) => boundSet.has(r.testId) && splitTestId(r.testId).file === file);
|
|
895
|
-
const latestAnchor = fileAnchors[fileAnchors.length - 1];
|
|
896
|
-
if (latestAnchor && latestAnchor.fileHash === currentHash) continue; // custody intact → no action
|
|
897
|
-
if (tamperedSet.has(file)) {
|
|
898
|
-
// a tampered (modified/removed old-side) file → re-observe red: the prior red-probe proved the OLD
|
|
899
|
-
// oracle and is now stale; --reattest is scoped to a green-only append and cannot honestly anchor a
|
|
900
|
-
// real edit (decideCheck's own recovery for a real edit is to re-observe red).
|
|
901
|
-
actions.push({
|
|
902
|
-
kind: 'red',
|
|
903
|
-
testId: id,
|
|
904
|
-
command: `node fold-completeness-run.mjs --red ${JSON.stringify(id)}`,
|
|
905
|
-
note: 're-observe red at the modified (tampered) expectations — --reattest is scoped to a green-only append and cannot anchor a real edit',
|
|
906
|
-
});
|
|
907
|
-
} else if (!reattestedFiles.has(file)) {
|
|
908
|
-
// an additions-only custody delta (NOT tampered — no old-side removal) → CANDIDATE for re-attest.
|
|
909
|
-
// The tamper flag catches removed/modified old-side lines, but an additions-only edit can still
|
|
910
|
-
// WEAKEN a bound test (an inserted early `return;` before the assertions) — undetectable without
|
|
911
|
-
// AST (the AD-047 residual). So preflight only SUGGESTS re-attest, with a caveat: re-attest is
|
|
912
|
-
// honest for a genuine green-only APPEND (a new sibling test); for an in-body insertion, re-observe
|
|
913
|
-
// red (--red) instead. The custody guard stays fail-closed until the operator records one or other.
|
|
914
|
-
reattestedFiles.add(file);
|
|
915
|
-
actions.push({
|
|
916
|
-
kind: 'reattest',
|
|
917
|
-
testId: id,
|
|
918
|
-
file,
|
|
919
|
-
command: `node fold-completeness-run.mjs --reattest ${JSON.stringify(id)}`,
|
|
920
|
-
note: 'valid only for a genuine green-only APPEND (a new sibling test); if the change INSERTS into an existing bound test body (an additions-only edit can still weaken it), re-observe red instead: node fold-completeness-run.mjs --red ' + JSON.stringify(id),
|
|
921
|
-
});
|
|
922
|
-
}
|
|
923
|
-
}
|
|
924
|
-
return { loop, base, fingerprint, boundTestIds, tamper, actions };
|
|
925
|
-
};
|
|
926
|
-
|
|
927
|
-
// renderPreflight(state) → a human block: the loop/base + the routed actions (or an all-clear note).
|
|
928
|
-
export const renderPreflight = ({ loop, boundTestIds, tamper, actions }) => {
|
|
929
|
-
const lines = [
|
|
930
|
-
`fold-completeness preflight — loop "${loop}" (cheap half; the suite was NOT run, nothing was written)`,
|
|
931
|
-
` bound testIds: ${boundTestIds.length ? boundTestIds.join(', ') : '(none)'}`,
|
|
932
|
-
` tampered test-surface files: ${tamper.tampered.length ? tamper.tampered.join(', ') : 'none'}`,
|
|
933
|
-
];
|
|
934
|
-
if (actions.length === 0) {
|
|
935
|
-
lines.push(' ✓ no overrides / re-attests needed before the coverage pass — run: node fold-completeness-run.mjs');
|
|
936
|
-
return lines.join('\n');
|
|
937
|
-
}
|
|
938
|
-
lines.push(` ${actions.length} action(s) to resolve BEFORE the coverage pass:`);
|
|
939
|
-
const head = (a) => {
|
|
940
|
-
if (a.kind === 'oracle-change') return `oracle-change for ${a.file}`;
|
|
941
|
-
if (a.kind === 'reattest') return `re-attest ${a.testId} (green-only custody delta)`;
|
|
942
|
-
if (a.kind === 'unresolvable') return `unresolvable bound file ${a.file} — restore or re-triage`;
|
|
943
|
-
return `observe red for ${a.testId}`;
|
|
944
|
-
};
|
|
945
|
-
for (const a of actions) {
|
|
946
|
-
lines.push(` [${a.kind}] ${head(a)}`);
|
|
947
|
-
if (a.command) lines.push(` ${a.command}`);
|
|
948
|
-
if (a.note) lines.push(` (${a.note})`);
|
|
949
|
-
}
|
|
950
|
-
return lines.join('\n');
|
|
951
|
-
};
|
|
952
|
-
|
|
953
|
-
// ── the --findings verb (1.4): OPTIONAL SARIF advisory intake — print-only, NEVER recorded ────────
|
|
954
|
-
|
|
955
|
-
// runFindings({ cwd, env }) → { findings, note }. Reads the profile's findings.sarifPath, ADVISORY
|
|
956
|
-
// ONLY: nothing is written and the fold gate never reads SARIF, so it can never block a fold. Absent
|
|
957
|
-
// path / missing file → a no-op note; a malformed SARIF throws (a loud advisory failure), --check
|
|
958
|
-
// unaffected.
|
|
959
|
-
export const runFindings = ({ cwd = process.cwd(), env = process.env } = {}) => {
|
|
960
|
-
const root = gitStdout(['rev-parse', '--show-toplevel'], cwd);
|
|
961
|
-
const rootTop = root == null ? cwd : root.replace(/\r?\n$/, '');
|
|
962
|
-
const { profile } = loadProfile(rootTop);
|
|
963
|
-
const sarifPath = resolveSarifPath(profile);
|
|
964
|
-
if (!sarifPath) return { findings: [], note: 'no findings.sarifPath declared in the verification profile — nothing to read (SARIF advisory is opt-in)' };
|
|
965
|
-
const abs = isAbsolute(sarifPath) ? sarifPath : join(rootTop, sarifPath);
|
|
966
|
-
const text = readFileSafe(abs);
|
|
967
|
-
if (text == null) return { findings: [], note: `no SARIF file at "${sarifPath}" — the suite may not have written it yet (advisory, non-blocking)` };
|
|
968
|
-
const { findings } = parseSarif(text); // throws on malformed → the CLI exits nonzero (advisory-loud)
|
|
969
|
-
return { findings, note: null };
|
|
970
|
-
};
|
|
971
|
-
|
|
972
|
-
// ── CLI ───────────────────────────────────────────────────────────────────────────────────────────
|
|
973
|
-
|
|
974
|
-
const HELP = `fold-completeness-run — the M3 fold-completeness RUNNER (agent-workflow family, AD-046 + AD-047).
|
|
975
|
-
|
|
976
|
-
Usage:
|
|
977
|
-
node fold-completeness-run.mjs [--suite "<cmd>"] [--cwd <dir>]
|
|
978
|
-
node fold-completeness-run.mjs --red "<test-file>#<test-name-pattern>" [--cwd <dir>]
|
|
979
|
-
node fold-completeness-run.mjs --reattest "<test-file>#<test-name-pattern>" [--cwd <dir>]
|
|
980
|
-
node fold-completeness-run.mjs --preflight [--cwd <dir>]
|
|
981
|
-
node fold-completeness-run.mjs --findings [--cwd <dir>]
|
|
982
|
-
|
|
983
|
-
The default run: runs the in-flight plan-execution loop's suite ONCE under coverage, maps every
|
|
984
|
-
changed executable line to covered/uncovered, probes each of the SEGMENT's fixable-bug bound testIds
|
|
985
|
-
N times (AW_FOLD_RERUNS, default 3) for resolvability + an N/N-green baseline, records each bound test
|
|
986
|
-
file's content hash (custody), the suite-execution evidence (cmd + exit + pre/post fingerprints), and
|
|
987
|
-
appends one v4 run record — segment-framed (base = git rev-parse
|
|
988
|
-
HEAD; the bound set is the current segment's, AD-048 D7) — to
|
|
989
|
-
<git dir>/${'agent-workflow-fold-completeness.jsonl'} (AW_FOLD_RESULTS overrides).
|
|
990
|
-
|
|
991
|
-
--red observes a testId RED on the CURRENT (pre-fold) tree — the honest fold-time order is: classify
|
|
992
|
-
the fixable-bug with its testId → write the test → --red observes it FAIL (N/N) BEFORE the fix is
|
|
993
|
-
applied → fold the fix → the normal run observes green → the gate checks receipt + order + custody.
|
|
994
|
-
An N/N red mints a red-probe receipt (testId, counts, content hash, fingerprint, and base — the
|
|
995
|
-
current SEGMENT frame: a receipt never crosses a commit boundary); observed-green /
|
|
996
|
-
unresolvable / mixed / timed-out are DISTINGUISHED typed refusals and nothing is written
|
|
997
|
-
(mixed/timeout = QUARANTINE — never converts, no override lane).
|
|
998
|
-
|
|
999
|
-
--reattest re-anchors a bound test FILE's custody at its CURRENT bytes WITHOUT observing red — the
|
|
1000
|
-
honest replacement for a red-proof waiver after a GREEN-ONLY test-file append (there is no red to
|
|
1001
|
-
observe). It mints a fold-v4 re-attest receipt (testId + the current file hash). It is
|
|
1002
|
-
operator-asserted (self-discipline, the same trust model as the recorded overrides), NOT auto-detected:
|
|
1003
|
-
the custody guard still fails CLOSED on any un-reattested change, and re-attest never converts a red
|
|
1004
|
-
baseline or waives the observed-red proof (a weakened/red test still fails the gate).
|
|
1005
|
-
|
|
1006
|
-
Suite command: --suite "<cmd>" or AW_FOLD_SUITE_CMD, else the unit-tests gate cmd in docs/ai/gates.json.
|
|
1007
|
-
Bound-test probes default to node --test --test-name-pattern (shell-free); AW_FOLD_BOUND_CMD overrides
|
|
1008
|
-
with a JSON argv array using {file}/{pattern}. Probe knobs (fail-closed positive integers):
|
|
1009
|
-
AW_FOLD_RERUNS (default 3) · AW_FOLD_PROBE_TIMEOUT_S (default 120, per probe RUN, probes only).
|
|
1010
|
-
Inert budgets: AW_FOLD_MUTANTS_MAX / AW_FOLD_HUNK_MUTANTS_MAX / AW_FOLD_TIME_BUDGET_S (mutation shelved).
|
|
1011
|
-
|
|
1012
|
-
The VERIFICATION PROFILE (docs/ai/verification-profile.json, BUGFREE-3) generalizes the coverage
|
|
1013
|
-
SOURCE (coverage.kind v8|lcov + lcovPath) and the single-test RESULT FORMAT (singleTest.argv +
|
|
1014
|
-
resultFormat tap-stdout|tap-file|junit-xml; a file-based format's argv carries {resultPath}). Absent →
|
|
1015
|
-
today's exact behaviour (V8 + node:test TAP on stdout). Env knobs still override (AW_FOLD_SUITE_CMD /
|
|
1016
|
-
AW_FOLD_BOUND_CMD win over the profile).
|
|
1017
|
-
|
|
1018
|
-
--preflight runs ONLY the cheap half (the tamper surface + custody deltas from the git diff + the
|
|
1019
|
-
ledgers, seconds) WITHOUT the coverage suite run: it prints the overrides / re-attests to RECORD
|
|
1020
|
-
BEFORE the expensive pass — routed by kind: oracle-change for a tampered test file, --reattest for a
|
|
1021
|
-
green-only custody delta, --red (or a red-proof override if the red is unestablishable) for a bound
|
|
1022
|
-
testId with no observed-red receipt yet. It spawns no suite, runs no probe, predicts no coverage, and
|
|
1023
|
-
writes nothing.
|
|
1024
|
-
|
|
1025
|
-
--findings reads the profile's OPTIONAL findings.sarifPath and PRINTS the SARIF findings — ADVISORY
|
|
1026
|
-
ONLY: nothing is recorded, and the fold gate (fold-completeness --check) never reads SARIF, so it can
|
|
1027
|
-
never block a fold. Absent path / missing file → a stated no-op; a malformed SARIF exits nonzero (a
|
|
1028
|
-
loud advisory failure) but leaves --check unaffected.
|
|
1029
|
-
|
|
1030
|
-
The read-only gate is a SEPARATE tool: node fold-completeness.mjs --check / --status / --json.
|
|
1031
|
-
|
|
1032
|
-
Sandbox-safe: the RUNNER itself needs no network and writes only repo-local state (the D4 sandbox
|
|
1033
|
-
lane). The spawned suite/bound commands are COMMAND-SHAPE dependent — --suite / AW_FOLD_SUITE_CMD /
|
|
1034
|
-
AW_FOLD_BOUND_CMD / the verification profile can name project-defined commands; the default
|
|
1035
|
-
(node --test on stdout TAP) is plain and no-network — keep overrides sandbox-safe by shape.
|
|
1036
|
-
|
|
1037
|
-
Exit codes: 0 written / advisory printed; 1 a typed STOP (loop derivation / suite discovery / a --red
|
|
1038
|
-
or --reattest refusal / a malformed SARIF on --findings / malformed record / fs error); 2 usage.`;
|
|
1039
|
-
|
|
1040
|
-
const parseArgs = (argv) => {
|
|
1041
|
-
const opts = { cwd: undefined, suite: undefined, red: undefined, reattest: undefined, findings: false, preflight: false };
|
|
1042
|
-
for (let i = 0; i < argv.length; i += 1) {
|
|
1043
|
-
const a = argv[i];
|
|
1044
|
-
if (a === '--cwd') {
|
|
1045
|
-
opts.cwd = argv[i + 1];
|
|
1046
|
-
if (opts.cwd === undefined) throw usageFail('--cwd needs a directory');
|
|
1047
|
-
i += 1;
|
|
1048
|
-
} else if (a === '--suite') {
|
|
1049
|
-
opts.suite = argv[i + 1];
|
|
1050
|
-
if (opts.suite === undefined) throw usageFail('--suite needs a command');
|
|
1051
|
-
i += 1;
|
|
1052
|
-
} else if (a === '--red') {
|
|
1053
|
-
opts.red = argv[i + 1];
|
|
1054
|
-
if (opts.red === undefined) throw usageFail('--red needs a testId ("<test-file>#<test-name-pattern>")');
|
|
1055
|
-
i += 1;
|
|
1056
|
-
} else if (a === '--reattest') {
|
|
1057
|
-
opts.reattest = argv[i + 1];
|
|
1058
|
-
if (opts.reattest === undefined) throw usageFail('--reattest needs a testId ("<test-file>#<test-name-pattern>")');
|
|
1059
|
-
i += 1;
|
|
1060
|
-
} else if (a === '--findings') {
|
|
1061
|
-
opts.findings = true;
|
|
1062
|
-
} else if (a === '--preflight') {
|
|
1063
|
-
opts.preflight = true;
|
|
1064
|
-
} else {
|
|
1065
|
-
throw usageFail(`unknown argument: ${a}`);
|
|
1066
|
-
}
|
|
1067
|
-
}
|
|
1068
|
-
return opts;
|
|
1069
|
-
};
|
|
1070
|
-
|
|
1071
|
-
export const main = (argv, ctx = {}) => {
|
|
1072
|
-
const cwd0 = ctx.cwd ?? process.cwd();
|
|
1073
|
-
const env = ctx.env ?? process.env;
|
|
1074
|
-
try {
|
|
1075
|
-
if (argv.includes('--help') || argv.includes('-h')) return { code: 0, stdout: HELP, stderr: '' };
|
|
1076
|
-
const opts = parseArgs(argv);
|
|
1077
|
-
const cwd = opts.cwd ?? cwd0;
|
|
1078
|
-
if (opts.preflight) {
|
|
1079
|
-
return { code: 0, stdout: renderPreflight(runPreflight({ cwd, env })), stderr: '' };
|
|
1080
|
-
}
|
|
1081
|
-
if (opts.findings) {
|
|
1082
|
-
const { findings, note } = runFindings({ cwd, env });
|
|
1083
|
-
return { code: 0, stdout: note ?? renderSarifFindings(findings), stderr: '' };
|
|
1084
|
-
}
|
|
1085
|
-
if (opts.red !== undefined) {
|
|
1086
|
-
const { writtenPath, record } = runRedProbe({ cwd, env, testId: opts.red });
|
|
1087
|
-
return {
|
|
1088
|
-
code: 0,
|
|
1089
|
-
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}`,
|
|
1090
|
-
stderr: '',
|
|
1091
|
-
};
|
|
1092
|
-
}
|
|
1093
|
-
if (opts.reattest !== undefined) {
|
|
1094
|
-
const { writtenPath, record } = runReattest({ cwd, env, testId: opts.reattest });
|
|
1095
|
-
return {
|
|
1096
|
-
code: 0,
|
|
1097
|
-
stdout: `fold-completeness-run: minted a custody re-attest for "${record.testId}" (loop "${record.loop}", hash ${record.fileHash.slice(0, 12)}…) → ${writtenPath}`,
|
|
1098
|
-
stderr: '',
|
|
1099
|
-
};
|
|
1100
|
-
}
|
|
1101
|
-
const { writtenPath, record } = runFoldCompleteness({ cwd, env, suiteCmd: opts.suite });
|
|
1102
|
-
const uncovered = record.coverage.uncoveredChanged.length;
|
|
1103
|
-
const unresolved = record.testIds.filter((t) => !t.resolvable || !t.baselineGreen).length;
|
|
1104
|
-
return {
|
|
1105
|
-
code: 0,
|
|
1106
|
-
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}`,
|
|
1107
|
-
stderr: '',
|
|
1108
|
-
};
|
|
1109
|
-
} catch (err) {
|
|
1110
|
-
return { code: err.exitCode ?? 1, stdout: '', stderr: `fold-completeness-run: ${err.message}` };
|
|
1111
|
-
}
|
|
1112
|
-
};
|
|
1113
|
-
|
|
1114
|
-
const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
1115
|
-
if (isDirectRun) {
|
|
1116
|
-
const r = main(process.argv.slice(2));
|
|
1117
|
-
if (r.stdout) process.stdout.write(r.stdout.endsWith('\n') ? r.stdout : `${r.stdout}\n`);
|
|
1118
|
-
if (r.stderr) process.stderr.write(r.stderr.endsWith('\n') ? r.stderr : `${r.stderr}\n`);
|
|
1119
|
-
process.exitCode = r.code;
|
|
1120
|
-
}
|