From da20d40d912e62c5616683ffbe8e2b1a943b2f77 Mon Sep 17 00:00:00 2001 From: mgillr Date: Sat, 17 Oct 2026 16:13:12 +0200 Subject: [PATCH] I refuse any unapproved contract change as one blocked line. The setup fixtures now commit their contract before the base. No assertion changes and no test is removed. weakenings() stays the explanation on that one line, including a real weakening. A change that clears ci.tasks is refused before the empty-list path. Verified against base acaf53c9a35bd5bbadd9add71e88c1f9bf64d02c: the new test is red on the base and green now, the suite exits 0 (157 of 157), and the 25 changed files are in scope. The main-branch criterion stays open until this lands. Signed-off-by: mgillr --- CHANGELOG.md | 1 + bin/proofline.mjs | 10 ++++- src/contract.mjs | 31 +++++++++++++ src/engine.mjs | 36 ++++++++++++++-- src/index.mjs | 9 +++- test/cache.test.mjs | 8 ++-- test/claims.test.mjs | 5 ++- test/contract_any_change.test.mjs | 67 +++++++++++++++++++++++++++++ test/contract_sig.test.mjs | 4 +- test/cycle.test.mjs | 5 ++- test/guided_watch.test.mjs | 6 ++- test/helpers.mjs | 15 +++++++ test/hook_base_repin.test.mjs | 3 +- test/hook_fresh_worktree.test.mjs | 4 +- test/hook_push_range.test.mjs | 6 ++- test/hook_push_sha.test.mjs | 3 +- test/hook_task_from_commit.test.mjs | 6 ++- test/hooks.test.mjs | 4 +- test/integration.test.mjs | 6 ++- test/isolated.test.mjs | 7 ++- test/machine.test.mjs | 3 +- test/owner.test.mjs | 3 +- test/remote.test.mjs | 3 +- test/steer.test.mjs | 4 +- test/verify.test.mjs | 12 +++++- 25 files changed, 229 insertions(+), 32 deletions(-) create mode 100644 test/contract_any_change.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 79192a3..2a5e7ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable project changes are recorded here. Dates use ISO 8601 (`YYYY-MM-DD`) ## [Unreleased] ### Added +- A contract whose canonical bytes differ from its base is refused at pre-commit, verify, and gate unless the approval marker names the new bytes. The field-level weakening list stays the explanation, and a tightening is still not a weakening. - The repository commits package-lock.json at lockfile version 3, made with npm install --package-lock-only --ignore-scripts, so a gate can install this revision from the revision itself. - `verify` and `gate` stay on the host unless `--isolated` is passed. That mode runs command, red-green and metric checks with no network, a fresh HOME, and the repository read-only except paths the check declares. The image digest is `env.image` on the contract under test. Without it, `--isolated` refuses and names `env.image`. The receipt records `isolated`, `env_digest` and `heuristic`. grep, wiring and the forbidden scan are one layer, not a boundary. - Every subcommand refuses a flag it does not accept, exits 2, and names the unknown flag and the accepted ones. Nothing runs after that refusal (#33). diff --git a/bin/proofline.mjs b/bin/proofline.mjs index 1e3c055..8dc3301 100755 --- a/bin/proofline.mjs +++ b/bin/proofline.mjs @@ -207,7 +207,15 @@ try { } const c = P.C.load(root); const ids = flags.all ? c.tasks.map((t) => t.id) : flags.ci ? (c.ci?.tasks || []) : [requested || P.activeTask(root)].filter(Boolean); - if (!ids.length) { if (flags.ci) { const e = P.C.validate(c); print({ decision: e.length ? 'FAIL' : 'PASS', problems: e }, e.length ? e.join('\n') : 'contract valid; no ci.tasks'); process.exitCode = e.length ? 1 : 0; break; } throw new Error('which task? (proofline next)'); } + if (!ids.length) { + if (flags.ci) { + // Clearing ci.tasks is still a contract change. Refuse it before the empty-list short-circuit. + const refusal = P.E.unapprovedContractChange(root, base()); + if (refusal) { print({ decision: 'BLOCKED', blocked: [refusal] }, `${refusal}\n\nDECISION: BLOCKED`); process.exitCode = 2; break; } + const e = P.C.validate(c); print({ decision: e.length ? 'FAIL' : 'PASS', problems: e }, e.length ? e.join('\n') : 'contract valid; no ci.tasks'); process.exitCode = e.length ? 1 : 0; break; + } + throw new Error('which task? (proofline next)'); + } const outs = ids.map((id) => P.E.gate(root, id, { claims: claims(), base: base(), fresh: flags.fresh === true, skipTypes: flags['skip-pushed'] || flags.ci ? ['pushed'] : [] })); const worst = outs.some((o) => o.decision === 'BLOCKED') ? 'BLOCKED' : outs.some((o) => o.decision === 'FAIL') ? 'FAIL' : 'PASS'; const note = settled ? `${settled.message}\n\n` : ''; diff --git a/src/contract.mjs b/src/contract.mjs index 2d4f44c..6d34ccd 100644 --- a/src/contract.mjs +++ b/src/contract.mjs @@ -256,6 +256,37 @@ export function weakenings(base, cur) { return out; } +/** Fields whose canonical bytes differ. This is the refusal. weakenings() stays the explanation. */ +export function contractFieldChanges(base, cur) { + if (!base || !cur) return []; + if (contractHash(base) === contractHash(cur)) return []; + const out = []; + const walk = (a, b, path) => { + if (Object.is(a, b)) return; + const aArr = Array.isArray(a); + const bArr = Array.isArray(b); + if (aArr || bArr) { + const left = aArr ? a : []; + const right = bArr ? b : []; + const n = Math.max(left.length, right.length); + for (let i = 0; i < n; i++) { + const id = (right[i] && right[i].id) || (left[i] && left[i].id) || String(i); + walk(left[i], right[i], path ? `${path}/${id}` : String(id)); + } + return; + } + if (a && b && typeof a === "object" && typeof b === "object") { + const keys = new Set([...Object.keys(a), ...Object.keys(b)]); + for (const k of keys) walk(a[k], b[k], path ? `${path}/${k}` : k); + return; + } + const shown = (v) => (v === undefined ? "absent" : JSON.stringify(v)); + out.push(`${path}: ${shown(a)} -> ${shown(b)}`); + }; + walk(base, cur, ""); + return out; +} + export function loadFile(f) { return JSON.parse(fs.readFileSync(f, 'utf8')); } /** End index of the JSON array named `key`, so an append can keep every byte before it. */ diff --git a/src/engine.mjs b/src/engine.mjs index 6acba86..56c8fe9 100644 --- a/src/engine.mjs +++ b/src/engine.mjs @@ -84,6 +84,26 @@ export function taskBase(root, c, t, explicit) { return { ref, source, base, problems }; } +/** One line when the working contract differs from `explicit` (or the default base) and no marker approves it. */ +export function unapprovedContractChange(root, explicit) { + const c = C.load(root); + const ref = explicit || C.defaultBase(c); + const base = resolveBase(root, ref); + if (!base) return null; + const s = git(['show', `${base}:proofline.json`], root); + if (!s.ok) return null; + let baseContract; + try { baseContract = JSON.parse(s.out); } catch { return null; } + if (C.contractHash(baseContract) === C.contractHash(c)) return null; + let raw; + try { raw = fs.readFileSync(path.join(root, 'proofline.json')); } catch { return null; } + if (approvalMarker(root, raw).approved) return null; + const weak = C.weakenings(baseContract, c); + return weak.length + ? `contract weakened vs base (needs owner approval): ${weak.join('; ')}` + : 'contract bytes changed vs base (needs owner approval)'; +} + const BASE_NEXT = 'gate against the revision the task started from: pass --base (for one commit, its parent), or start the task with `proofline next`, which pins the base. Do not pick a base that hides part of the work.'; /** The "## Base" section of a brief: which base verify and gate will use, and whether they can. */ @@ -135,9 +155,11 @@ export function verifyTask(root, taskId, opts = {}) { const weak = C.weakenings(baseContract, c); // The forbidden scan skips proofline.json (it declares the deny-list); a contract change is reported here instead. let contractChange = !base ? 'unknown' : !baseContract ? 'added' : C.contractHash(baseContract) === C.contractHash(c) ? 'unchanged' : 'changed'; + const fields = baseContract ? C.contractFieldChanges(baseContract, c) : []; let marker = null; - const approveWeakening = () => { - if (!weak.length) return false; + const hashChanged = contractChange === 'changed'; + const approveChange = () => { + if (!hashChanged) return false; let raw = null; try { raw = fs.readFileSync(path.join(root, 'proofline.json')); } catch { raw = null; } const mark = approvalMarker(root, raw); @@ -152,7 +174,13 @@ export function verifyTask(root, taskId, opts = {}) { if (errs.length) blocked.push(...errs.map((e) => `contract invalid: ${e}`)); blocked.push(...baseProblems); if (!chain.ok) blocked.push(`evidence log tampered at entry ${chain.at}: ${chain.reason}`); - if (!approveWeakening()) for (const w of weak) blocked.push(`contract weakened vs base (needs owner approval): ${w}`); + if (hashChanged && !approveChange()) { + // One blocked line. weakenings() is the explanation, so a real weakening stays length 1. + if (weak.length) blocked.push(`contract weakened vs base (needs owner approval): ${weak.join('; ')}`); + else blocked.push('contract bytes changed vs base (needs owner approval)'); + } else if (!hashChanged) { + for (const w of weak) blocked.push(`contract weakened vs base (needs owner approval): ${w}`); + } for (const r of results) { if (r.status === 'error') blocked.push(`${r.id}: could not run: ${r.detail}`); else if (r.status === 'blocked') blocked.push(`${r.id}: ${r.detail}`); @@ -179,7 +207,7 @@ export function verifyTask(root, taskId, opts = {}) { const pushed = pr ? pr.status === 'pass' : !!hi.head && !git(['status', '--porcelain'], root).out.split('\n').some((l) => l && !l.includes('.proofline/')) && git(['rev-parse', '--verify', '--quiet', `refs/remotes/${tgt.remote}/${tgt.branch}^{commit}`], root).out === hi.head; const reused = results.filter((r) => r.reused).map((r) => ({ id: r.id, type: r.type, ts: r.reused.ts, entry: r.reused.entry })); - return { isolated: iso.isolated, env_digest: iso.env_digest, heuristic: true, coverage_limits: COVERAGE_LIMITS, pushed, task: t.id, title: t.title, phase: t.phase, decision, base, base_ref: baseRef, base_source: baseSource, ...hi, contract_hash: C.contractHash(c), results, reuse: !reuse.on ? 'off' : fresh ? 'fresh' : 'on', reused, forbidden, weakenings: weak, contract_change: contractChange, marker, blocked, failing, measured: ctx.measured, claims: claimResults, changed: [...ctx.changed.keys()], ts: new Date().toISOString() }; + return { isolated: iso.isolated, env_digest: iso.env_digest, heuristic: true, coverage_limits: COVERAGE_LIMITS, pushed, task: t.id, title: t.title, phase: t.phase, decision, base, base_ref: baseRef, base_source: baseSource, ...hi, contract_hash: C.contractHash(c), results, reuse: !reuse.on ? 'off' : fresh ? 'fresh' : 'on', reused, forbidden, weakenings: weak, contract_fields: fields, contract_change: contractChange, marker, blocked, failing, measured: ctx.measured, claims: claimResults, changed: [...ctx.changed.keys()], ts: new Date().toISOString() }; } /** Corrective prompt tied to each failed criterion. */ diff --git a/src/index.mjs b/src/index.mjs index 40c1731..24c3283 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -299,11 +299,16 @@ export function hookPreCommit(root) { let cur; try { cur = JSON.parse(staged.toString('utf8')); } catch { return { ok: false, msg: 'staged proofline.json is not valid JSON' }; } let base = null; const s = git(['show', 'HEAD:proofline.json'], root); if (s.ok) try { base = JSON.parse(s.out); } catch {} w = C.weakenings(base, cur); - if (w.length) { + const fields = base && cur ? C.contractFieldChanges(base, cur) : []; + if (w.length || fields.length) { // Same check as verify and gate: a regular file named with the sha256 of these bytes. const mark = approvalMarker(root, staged); if (mark.approved) w = []; - else note = [`sha256 ${mark.hex}`, mark.marker ? `marker ${mark.marker}` : 'marker (no approvals directory configured)'].join('\n'); + else { + note = [`sha256 ${mark.hex}`, mark.marker ? `marker ${mark.marker}` : 'marker (no approvals directory configured)'].join('\n'); + // One explanation line. A non-weakening edit is still the hash refusal. + w = [w.length ? w.join('; ') : 'canonical bytes differ from HEAD']; + } } } else if (git(['cat-file', '-e', 'HEAD:proofline.json'], root).ok) { // Removed from the index. There are no staged bytes, so no marker name can approve the removal. diff --git a/test/cache.test.mjs b/test/cache.test.mjs index 80b6b5f..3f06e08 100644 --- a/test/cache.test.mjs +++ b/test/cache.test.mjs @@ -6,7 +6,7 @@ import { spawnSync } from 'node:child_process'; import * as P from '../src/index.mjs'; import { readLog, logPath } from '../src/evidence.mjs'; import { handle } from '../src/mcp.mjs'; -import { repo, write, addTask, commitAll, sh, BIN } from './helpers.mjs'; +import { repo, write, addTask, commitAll, commitBeforeBase, sh, BIN } from './helpers.mjs'; /** * A repo with one task T whose command criterion appends a line to a counter file outside the repo on every run. @@ -19,7 +19,7 @@ function counterRepo({ reuse = true, cmd = (count) => `echo run >> '${count}'`, if (reuse !== null) c.defaults.reuse_results = reuse; Object.assign(c.defaults, defaults); P.C.save(r, c); addTask(r, { id: 'T', title: 't', criteria: [{ statement: 'suite', verifier: { type: 'command', cmd: cmd(count) } }] }); - commitAll(r, 'task'); + commitBeforeBase(r); P.taskNext(r); const runs = () => (fs.existsSync(count) ? fs.readFileSync(count, 'utf8').split('\n').filter(Boolean).length : 0); return { r, runs, dir: path.dirname(r) }; @@ -104,7 +104,7 @@ test('only command and metric results are reused: a docs check on the same commi addTask(r, { id: 'T', title: 't', criteria: [ { verifier: { type: 'command', cmd: `echo run >> '${at('cmd')}'` } }, { verifier: { type: 'docs', cmd: `echo run >> '${at('docs')}'`, paths: ['package.json'] } }] }); - commitAll(r, 'task'); P.taskNext(r); + commitBeforeBase(r); P.taskNext(r); const runs = (n) => fs.readFileSync(at(n), 'utf8').split('\n').filter(Boolean).length; ok(cli(r, ['verify', 'T'])); ok(cli(r, ['gate', 'T'])); assert.equal(runs('cmd'), 1); assert.equal(runs('docs'), 2); @@ -180,7 +180,7 @@ test('a metric PASS is reused with its measured value, and only within max_load_ write(r, { 'bench/b.mjs': `import fs from 'node:fs'; fs.appendFileSync(${JSON.stringify(count)}, 'run\\n'); console.log(JSON.stringify({ flushMs: 1200 }));\n` }); const c = P.C.load(r); c.defaults.reuse_results = true; P.C.save(r, c); addTask(r, { id: 'M', title: 'm', criteria: [{ verifier: { type: 'metric', cmd: 'node bench/b.mjs', key: 'flushMs', op: '<', value: 2000 } }] }); - commitAll(r, 'task'); P.taskNext(r); + commitBeforeBase(r); P.taskNext(r); const runs = () => fs.readFileSync(count, 'utf8').split('\n').filter(Boolean).length; const machine = () => ({ load1: 3, cpus: 4 }); assert.equal(P.E.verifyTask(r, 'M', { machine }).decision, 'PASS'); assert.equal(runs(), 1); diff --git a/test/claims.test.mjs b/test/claims.test.mjs index 64a20e4..eb66d8c 100644 --- a/test/claims.test.mjs +++ b/test/claims.test.mjs @@ -2,7 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import * as P from '../src/index.mjs'; import { parseClaims } from '../src/claims.mjs'; -import { repo, write, addTask, commitAll, sh } from './helpers.mjs'; +import { repo, write, addTask, commitBeforeBase, sh } from './helpers.mjs'; test('parses status lines and ignores other tables', () => { const c = parseClaims('| X-1 | done | abc1234 | t.test.mjs | p50 12 ms | - |\nfoo | bar\nY | partial | - | - | - | seal', ['X-1', 'Y']); @@ -12,8 +12,8 @@ test('parses status lines and ignores other tables', () => { test('claimed numbers must match measured numbers', () => { const r = repo({ files: { 'bench/b.mjs': "console.log(JSON.stringify({ p50: 128 }))\n", 'test/t.test.mjs': "import test from 'node:test'; test('t', () => {});\n" } }); addTask(r, { id: 'P', title: 'p', criteria: [{ verifier: { type: 'metric', cmd: 'node bench/b.mjs', key: 'p50', op: '<', value: 200 } }] }); + commitBeforeBase(r); P.taskNext(r); - commitAll(r); sh(r, 'push', '-q', 'origin', 'main'); const head = sh(r, 'rev-parse', '--short', 'HEAD'); const g = P.E.verifyTask(r, 'P', { claims: `P | done | ${head} | t.test.mjs | p50 15 ms | -` }); assert.equal(g.decision, 'FAIL'); assert.match(g.failing.join(), /claimed p50 15 but measured 128/); @@ -24,6 +24,7 @@ test('claimed numbers must match measured numbers', () => { test('done with no measurements, unknown commit, missing test, deferral are all rejected', () => { const r = repo({ files: { 'bench/b.mjs': "console.log(JSON.stringify({ p50: 5 }))\n" } }); addTask(r, { id: 'P', title: 'p', criteria: [{ verifier: { type: 'metric', cmd: 'node bench/b.mjs', key: 'p50', op: '<', value: 20 } }] }); + commitBeforeBase(r); const g = P.E.verifyTask(r, 'P', { claims: 'P | done | deadbeef1 | ghost_test.mjs | fast | rest is a follow-up' }); const f = g.failing.join('\n'); for (const re of [/does not exist/, /ghost_test.mjs" not found/, /no measured numbers/, /follow-up/]) assert.match(f, re); diff --git a/test/contract_any_change.test.mjs b/test/contract_any_change.test.mjs new file mode 100644 index 0000000..d919a63 --- /dev/null +++ b/test/contract_any_change.test.mjs @@ -0,0 +1,67 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import * as P from '../src/index.mjs'; +import { repo, write, sh, commitBeforeBase, BIN } from './helpers.mjs'; + +function sample() { + const c = P.C.skeleton('t'); + const t = P.C.makeTask({ + id: 'T', + title: 't', + criteria: [{ verifier: { type: 'metric', cmd: 'node -e "console.log(JSON.stringify({p50:1}))"', key: 'p50', op: '<', value: 20 } }], + scope: ['src/**'], + }); + const v = t.criteria[0].verifier; + v.pattern = 'alpha'; + v.expect_exit = 0; + v.test_file = 'test/t.test.mjs'; + v.env = { MODE: 'a' }; + v.timeout_s = 30; + v.min_tests = 4; + c.tasks.push(t); + c.forbidden = { ...c.forbidden, ignore_paths: ['vendor/**'], protected_values: [{ file: 'pkg.json', pattern: '(\\d+)', direction: 'no_decrease' }] }; + c.ci = { tasks: ['T'] }; + return c; +} + +const edits = [ + ['min_tests', (c) => { c.tasks[0].criteria[0].verifier.min_tests = 1; }], + ['grep pattern', (c) => { c.tasks[0].criteria[0].verifier.pattern = 'beta'; }], + ['scope.allow', (c) => { c.tasks[0].scope.allow = ['docs/**']; }], + ['forbidden.ignore_paths', (c) => { c.forbidden.ignore_paths = ['vendor/**', 'tmp/**']; }], + ['protected_values', (c) => { c.forbidden.protected_values[0].direction = 'no_increase'; }], + ['ci.tasks', (c) => { c.ci.tasks = []; }], + ['expect_exit', (c) => { c.tasks[0].criteria[0].verifier.expect_exit = 1; }], + ['test_file', (c) => { c.tasks[0].criteria[0].verifier.test_file = 'test/other.test.mjs'; }], + ['env', (c) => { c.tasks[0].criteria[0].verifier.env = { MODE: 'b' }; }], + ['timeout_s', (c) => { c.tasks[0].criteria[0].verifier.timeout_s = 90; }], + ['tightening', (c) => { c.tasks[0].criteria[0].verifier.value = 10; }], +]; + +const cli = (r, args) => spawnSync(process.execPath, [BIN, ...args, '--root', r], { encoding: 'utf8' }); + +function refused(name, edit) { + const r = repo(); + write(r, { 'proofline.json': JSON.stringify(sample(), null, 2) + '\n' }); + commitBeforeBase(r); + const cur = sample(); + edit(cur); + write(r, { 'proofline.json': JSON.stringify(cur, null, 2) + '\n' }); + sh(r, 'add', '--', 'proofline.json'); + const hook = P.hookPreCommit(r); + assert.equal(hook.ok, false, `${name} pre-commit ${hook.msg}`); + const v = P.E.verifyTask(r, 'T'); + assert.equal(v.decision, 'BLOCKED', `${name} verify ${[...v.blocked, ...v.failing].join('\n')}`); + assert.equal(v.blocked.filter((b) => /needs owner approval/.test(b)).length, 1, v.blocked.join('\n')); + const g = P.E.gate(r, 'T'); + assert.equal(g.decision, 'BLOCKED', `${name} gate ${g.blocked.join('\n')}`); + const ci = cli(r, ['gate', '--ci']); + const text = `${ci.stdout}\n${ci.stderr}`; + assert.equal(ci.status, 2, `${name} gate --ci ${text}`); + assert.match(text, /needs owner approval/); +} + +test('each field edit, including a tightening, is refused at pre-commit, verify, gate and gate --ci', () => { + for (const [name, edit] of edits) refused(name, edit); +}); diff --git a/test/contract_sig.test.mjs b/test/contract_sig.test.mjs index ca490c7..99c2746 100644 --- a/test/contract_sig.test.mjs +++ b/test/contract_sig.test.mjs @@ -5,7 +5,7 @@ import { spawnSync } from 'node:child_process'; import test from 'node:test'; import assert from 'node:assert/strict'; import * as P from '../src/index.mjs'; -import { repo, commitAll, BIN } from './helpers.mjs'; +import { repo, commitBeforeBase, BIN } from './helpers.mjs'; const AGENTKEY = '/Users/imac27/programme-b/agentkey/bin/agentkey.mjs'; @@ -59,7 +59,7 @@ test('a pinned contract is refused until the owner signature matches, and the pu fs.writeFileSync(path.join(root, 'guard', 'owner.pub'), pub(box.env)); const signed = run(root, ['contract', 'sign'], box.env); assert.equal(signed.status, 0, signed.stderr); - commitAll(root, 'signed contract'); + commitBeforeBase(root); for (const args of COMMANDS) { const r = run(root, args, box.env); const text = `${r.stdout || ''}${r.stderr || ''}`; diff --git a/test/cycle.test.mjs b/test/cycle.test.mjs index cc6127a..39f9f54 100644 --- a/test/cycle.test.mjs +++ b/test/cycle.test.mjs @@ -1,7 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import * as P from '../src/index.mjs'; -import { repo, addTask } from './helpers.mjs'; +import { repo, addTask, commitBeforeBase, sh } from './helpers.mjs'; const ok = [{ verifier: { type: 'command', cmd: 'true' } }]; const bad = [{ verifier: { type: 'command', cmd: 'false' } }]; @@ -11,10 +11,13 @@ test('next returns one task at a time, gated by phase and dependencies', () => { addTask(r, { id: 'IMP-2', title: 'b', phase: 'implement', depends_on: ['IMP-1'], criteria: ok }); addTask(r, { id: 'IMP-1', title: 'a', phase: 'implement', criteria: ok }); addTask(r, { id: 'DES-1', title: 'd', phase: 'design', criteria: bad }); + commitBeforeBase(r); let n = P.taskNext(r); assert.equal(n.task, 'DES-1'); assert.match(n.brief, /# TASK DES-1/); assert.equal(P.E.gate(r, 'DES-1').decision, 'FAIL'); assert.equal(P.taskNext(r).task, 'DES-1', 'design phase not verified: implement must wait'); const c = P.C.load(r); c.tasks.find((t) => t.id === 'DES-1').criteria = ok; P.C.save(r, c); + commitBeforeBase(r); + P.taskNext(r, { base: sh(r, 'rev-parse', 'HEAD') }); P.E.gate(r, 'DES-1'); n = P.taskNext(r); assert.equal(n.task, 'IMP-1'); P.E.gate(r, 'IMP-1'); diff --git a/test/guided_watch.test.mjs b/test/guided_watch.test.mjs index aba1ea4..3edcc44 100644 --- a/test/guided_watch.test.mjs +++ b/test/guided_watch.test.mjs @@ -4,7 +4,7 @@ import { spawn, execFileSync } from 'node:child_process'; import test from 'node:test'; import assert from 'node:assert/strict'; import * as P from '../src/index.mjs'; -import { repo, commitAll, BIN } from './helpers.mjs'; +import { repo, commitBeforeBase, parentAfterContract, BIN } from './helpers.mjs'; function flagRepo() { const root = repo(); @@ -14,7 +14,9 @@ function flagRepo() { criteria: [{ statement: 'the flag exists', verifier: { type: 'command', cmd: 'test -f ready.flag', expect_exit: 0 } }], })); P.C.save(root, c); - commitAll(root, 'task'); + commitBeforeBase(root); + // The default base must contain the setup contract and must not be HEAD. + parentAfterContract(root); return root; } diff --git a/test/helpers.mjs b/test/helpers.mjs index 1be4bb0..4ed4719 100644 --- a/test/helpers.mjs +++ b/test/helpers.mjs @@ -25,3 +25,18 @@ export function repo({ hooks = false, files = {} } = {}) { export function write(r, files) { for (const [f, s] of Object.entries(files)) { fs.mkdirSync(path.dirname(path.join(r, f)), { recursive: true }); fs.writeFileSync(path.join(r, f), s); } } export function addTask(r, spec) { const c = P.C.load(r); c.tasks.push(P.C.makeTask(spec)); P.C.save(r, c); } export function commitAll(r, m = 'wip') { sh(r, 'add', '-A'); sh(r, 'commit', '-q', '--no-verify', '-m', m); } + +/** Commit the setup contract and point origin/main at it, so the default base already contains that write. */ +export function commitBeforeBase(r) { + commitAll(r, 'contract'); + const head = sh(r, 'rev-parse', 'HEAD'); + const origin = sh(r, 'remote', 'get-url', 'origin'); + // The bare origin fetches the commit. A client push would run the hook this fixture is not testing. + sh(origin, 'fetch', '-q', r, 'HEAD:refs/heads/main'); + sh(r, 'update-ref', 'refs/remotes/origin/main', head); +} + +/** One empty commit so HEAD~1 is the commit that already holds the setup contract. */ +export function parentAfterContract(r) { + sh(r, 'commit', '-q', '--allow-empty', '-m', 'work'); +} diff --git a/test/hook_base_repin.test.mjs b/test/hook_base_repin.test.mjs index cd7f311..7c0709f 100644 --- a/test/hook_base_repin.test.mjs +++ b/test/hook_base_repin.test.mjs @@ -2,11 +2,12 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; import * as P from '../src/index.mjs'; -import { repo, write, addTask, sh } from './helpers.mjs'; +import { repo, write, addTask, commitBeforeBase, sh } from './helpers.mjs'; test('a second start of the same task moves the base, and the refusal names the base used', () => { const r = repo({ hooks: true }); addTask(r, { id: 'T', title: 't', criteria: [{ verifier: { type: 'command', cmd: 'false' } }] }); + commitBeforeBase(r); const first = P.taskNext(r); const b1 = first.base; assert.equal(b1, sh(r, 'rev-parse', 'origin/main')); diff --git a/test/hook_fresh_worktree.test.mjs b/test/hook_fresh_worktree.test.mjs index 9faea0c..0ca54ec 100644 --- a/test/hook_fresh_worktree.test.mjs +++ b/test/hook_fresh_worktree.test.mjs @@ -3,11 +3,12 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import { spawnSync } from 'node:child_process'; -import { repo, write, addTask, sh } from './helpers.mjs'; +import { repo, write, addTask, commitBeforeBase, sh } from './helpers.mjs'; test('a push from a worktree with no evidence is gated, and a failing task is refused', () => { const r = repo({ hooks: true }); addTask(r, { id: 'T', title: 't', criteria: [{ verifier: { type: 'command', cmd: 'false' } }] }); + commitBeforeBase(r); write(r, { 'src/a.mjs': 'export const a = 1;\n' }); sh(r, 'add', '-A'); sh(r, 'commit', '-q', '-m', 'bad work (T)'); @@ -26,6 +27,7 @@ test('a push from a worktree with no evidence is gated, and a failing task is re test('stale evidence in the pushing worktree cannot block a commit that passes', () => { const r = repo({ hooks: true }); addTask(r, { id: 'T', title: 't', criteria: [{ verifier: { type: 'command', cmd: 'true' } }] }); + commitBeforeBase(r); write(r, { 'src/a.mjs': 'export const a = 1;\n' }); sh(r, 'add', '-A'); sh(r, 'commit', '-q', '-m', 'good work (T)'); diff --git a/test/hook_push_range.test.mjs b/test/hook_push_range.test.mjs index 34ae9b1..4620293 100644 --- a/test/hook_push_range.test.mjs +++ b/test/hook_push_range.test.mjs @@ -1,7 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; -import { repo, write, addTask, sh } from './helpers.mjs'; +import { repo, write, addTask, commitBeforeBase, sh } from './helpers.mjs'; import { hookPrePush } from '../src/index.mjs'; function push(r, args) { @@ -12,6 +12,7 @@ test('a rebased branch does not gate a commit already on main', () => { const r = repo({ hooks: true }); addTask(r, { id: 'MAIN', title: 'm', criteria: [{ verifier: { type: 'command', cmd: 'true' } }] }); addTask(r, { id: 'BRANCH', title: 'b', criteria: [{ verifier: { type: 'command', cmd: 'true' } }] }); + commitBeforeBase(r); write(r, { 'src/tasks.mjs': 'export const tasks = 1;\n' }); sh(r, 'add', '-A'); sh(r, 'commit', '-q', '-m', 'add the two tasks'); @@ -40,6 +41,7 @@ test('a commit that is not on a remote yet is still gated', () => { const r = repo({ hooks: true }); addTask(r, { id: 'MAIN', title: 'm', criteria: [{ verifier: { type: 'command', cmd: 'false' } }] }); addTask(r, { id: 'BRANCH', title: 'b', criteria: [{ verifier: { type: 'command', cmd: 'true' } }] }); + commitBeforeBase(r); write(r, { 'src/tasks.mjs': 'export const tasks = 1;\n' }); sh(r, 'add', '-A'); sh(r, 'commit', '-q', '-m', 'add the two tasks'); @@ -65,6 +67,7 @@ const send = (r, args) => spawnSync('git', ['push', ...args], { cwd: r, encoding function setup() { const r = repo({ hooks: true }); addTask(r, { id: 'MAIN', title: 'm', criteria: [{ verifier: { type: 'command', cmd: 'false' } }] }); + commitBeforeBase(r); write(r, { 'src/tasks.mjs': 'export const tasks = 1;\n' }); sh(r, 'add', '-A'); sh(r, 'commit', '-q', '-m', 'add the task'); send(r, ['origin', 'HEAD:main']); @@ -83,6 +86,7 @@ test('an unreadable server gates every commit in the range', () => { const r = repo({ hooks: true }); addTask(r, { id: 'MAIN', title: 'm', criteria: [{ verifier: { type: 'command', cmd: 'true' } }] }); addTask(r, { id: 'BRANCH', title: 'b', criteria: [{ verifier: { type: 'command', cmd: 'true' } }] }); + commitBeforeBase(r); write(r, { 'src/main.mjs': 'export const main = 1;\n' }); sh(r, 'add', '-A'); sh(r, 'commit', '-q', '-m', 'main work (MAIN)'); diff --git a/test/hook_push_sha.test.mjs b/test/hook_push_sha.test.mjs index 9fc9ffa..4dd7069 100644 --- a/test/hook_push_sha.test.mjs +++ b/test/hook_push_sha.test.mjs @@ -1,11 +1,12 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; -import { repo, write, addTask, sh } from './helpers.mjs'; +import { repo, write, addTask, commitBeforeBase, sh } from './helpers.mjs'; test('pushing the first commit of a two-commit branch gates that commit, not the tip', () => { const r = repo({ hooks: true }); addTask(r, { id: 'T', title: 't', criteria: [{ verifier: { type: 'command', cmd: 'grep -qx first mark.txt' } }] }); + commitBeforeBase(r); write(r, { 'mark.txt': 'first\n' }); sh(r, 'add', '-A'); sh(r, 'commit', '-q', '-m', 'first commit (T)'); diff --git a/test/hook_task_from_commit.test.mjs b/test/hook_task_from_commit.test.mjs index 8d0d4fe..938bfe2 100644 --- a/test/hook_task_from_commit.test.mjs +++ b/test/hook_task_from_commit.test.mjs @@ -3,13 +3,14 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import { spawnSync } from 'node:child_process'; -import { repo, write, addTask, sh } from './helpers.mjs'; +import { repo, write, addTask, commitBeforeBase, sh } from './helpers.mjs'; const push = (r) => spawnSync('git', ['push', 'origin', 'HEAD:main'], { cwd: r, encoding: 'utf8' }); test('a commit that names no task is gated on the active task', () => { const r = repo({ hooks: true }); addTask(r, { id: 'OLD', title: 'old', criteria: [{ verifier: { type: 'command', cmd: 'false' } }] }); + commitBeforeBase(r); fs.writeFileSync(path.join(r, '.proofline', 'active'), 'OLD\n'); write(r, { 'src/x.mjs': 'export const x = 1;\n' }); sh(r, 'add', '-A'); @@ -24,6 +25,7 @@ test('a Proofline-Task trailer names the task, so a failing active task is not g const r = repo({ hooks: true }); addTask(r, { id: 'OLD', title: 'old', criteria: [{ verifier: { type: 'command', cmd: 'false' } }] }); addTask(r, { id: 'NEW', title: 'new', criteria: [{ verifier: { type: 'command', cmd: 'true' } }] }); + commitBeforeBase(r); fs.writeFileSync(path.join(r, '.proofline', 'active'), 'OLD\n'); write(r, { 'src/y.mjs': 'export const y = 1;\n' }); sh(r, 'add', '-A'); @@ -35,6 +37,7 @@ test('a Proofline-Task trailer names the task, so a failing active task is not g test('before any task has a start record, a push that names no task passes and prints proofline next', () => { const r = repo({ hooks: true }); addTask(r, { id: 'OLD', title: 'old', criteria: [{ verifier: { type: 'command', cmd: 'true' } }] }); + commitBeforeBase(r); write(r, { 'src/boot.mjs': 'export const boot = 1;\n' }); sh(r, 'add', '-A'); sh(r, 'commit', '-q', '-m', 'baseline'); @@ -46,6 +49,7 @@ test('before any task has a start record, a push that names no task passes and p test('a push with no active task and no named task is refused once a task has started', () => { const r = repo({ hooks: true }); addTask(r, { id: 'OLD', title: 'old', criteria: [{ verifier: { type: 'command', cmd: 'true' } }] }); + commitBeforeBase(r); spawnSync(process.execPath, [new URL('../bin/proofline.mjs', import.meta.url).pathname, 'next', '--root', r], { encoding: 'utf8' }); fs.rmSync(path.join(r, '.proofline', 'active'), { force: true }); write(r, { 'src/z.mjs': 'export const z = 1;\n' }); diff --git a/test/hooks.test.mjs b/test/hooks.test.mjs index 51454a2..9a0a102 100644 --- a/test/hooks.test.mjs +++ b/test/hooks.test.mjs @@ -4,7 +4,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { spawnSync } from 'node:child_process'; import * as P from '../src/index.mjs'; -import { repo, write, addTask, sh } from './helpers.mjs'; +import { repo, write, addTask, commitBeforeBase, sh } from './helpers.mjs'; const hooksDir = (r) => path.join(r, '.git', 'hooks'); /** A hook that logs its args and stdin next to the repo (not inside it), then exits with `code`. */ @@ -39,7 +39,9 @@ test('an existing pre-push hook that passes lets the proofline gate run', () => const { out } = ownHook(r, 'pre-push', 0); P.init(r); addTask(r, { id: 'A', title: 'a', criteria: [{ verifier: { type: 'command', cmd: 'false' } }] }); + commitBeforeBase(r); P.taskNext(r); + write(r, { 'src/c.mjs': 'export const c = 1;\n' }); sh(r, 'add', '-A'); sh(r, 'commit', '-q', '-m', 'c'); const p = push(r); assert.notEqual(p.status, 0); diff --git a/test/integration.test.mjs b/test/integration.test.mjs index 9e39d16..5d55fdb 100644 --- a/test/integration.test.mjs +++ b/test/integration.test.mjs @@ -4,7 +4,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { spawnSync, spawn } from 'node:child_process'; import { runDemo } from '../examples/demo/demo.mjs'; -import { repo, write, addTask, sh, BIN } from './helpers.mjs'; +import { repo, write, addTask, commitBeforeBase, sh, BIN } from './helpers.mjs'; import * as P from '../src/index.mjs'; test('demo: claimed done without evidence FAILS, real work PASSES', () => { @@ -17,6 +17,7 @@ test('CLI exit codes: 0 PASS, 1 FAIL, 2 BLOCKED; --json output', () => { addTask(r, { id: 'A', title: 'a', criteria: [{ verifier: { type: 'command', cmd: 'true' } }] }); addTask(r, { id: 'B', title: 'b', criteria: [{ verifier: { type: 'command', cmd: 'false' } }] }); addTask(r, { id: 'C', title: 'c', criteria: [{ verifier: { type: 'metric', cmd: 'node nope.mjs', key: 'x', op: '<', value: 1 } }] }); + commitBeforeBase(r); const run = (...a) => spawnSync(process.execPath, [BIN, ...a, '--root', r], { encoding: 'utf8' }); // The work is uncommitted on top of origin/main, so that is the base the tasks started from. assert.equal(run('gate', 'A', '--base', 'origin/main').status, 0); assert.equal(run('gate', 'B', '--base', 'origin/main').status, 1); assert.equal(run('gate', 'C', '--base', 'origin/main').status, 2); @@ -35,7 +36,9 @@ test('hooks: pre-commit refuses a skipped test; bypass env var does not help', ( test('hooks: pre-push refuses when the active task does not gate PASS', () => { const r = repo({ hooks: true }); addTask(r, { id: 'A', title: 'a', criteria: [{ verifier: { type: 'command', cmd: 'false' } }] }); + commitBeforeBase(r); P.taskNext(r); + write(r, { 'src/c.mjs': 'export const c = 1;\n' }); sh(r, 'add', '-A'); sh(r, 'commit', '-q', '-m', 'c'); const p = spawnSync('git', ['push', 'origin', 'main'], { cwd: r, encoding: 'utf8' }); assert.notEqual(p.status, 0); assert.match(p.stderr, /pre-push: REFUSED/); @@ -44,6 +47,7 @@ test('hooks: pre-push refuses when the active task does not gate PASS', () => { test('MCP server: initialize, tools/list, task_next and verify over stdio', async () => { const r = repo(); addTask(r, { id: 'A', title: 'a', criteria: [{ verifier: { type: 'command', cmd: 'true' } }] }); + commitBeforeBase(r); const p = spawn(process.execPath, [BIN, 'mcp', '--root', r]); const msgs = [ { jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05' } }, diff --git a/test/isolated.test.mjs b/test/isolated.test.mjs index 11b542e..b3dd594 100644 --- a/test/isolated.test.mjs +++ b/test/isolated.test.mjs @@ -4,12 +4,13 @@ import fs from 'node:fs'; import path from 'node:path'; import * as E from '../src/engine.mjs'; import * as P from '../src/index.mjs'; -import { repo, addTask, commitAll } from './helpers.mjs'; +import { repo, addTask, commitBeforeBase, parentAfterContract } from './helpers.mjs'; function withTask(criteria) { const r = repo(); addTask(r, { id: 'T', title: 'Isolation', criteria }); - commitAll(r, 'task'); + commitBeforeBase(r); + parentAfterContract(r); return r; } @@ -38,6 +39,8 @@ test('the fixture pins the image digest and an isolated command records it', () const c = P.C.load(r); c.env = { image }; P.C.save(r, c); + commitBeforeBase(r); + parentAfterContract(r); const out = E.verifyTask(r, 'T', { base: 'HEAD~1', isolated: true }); assert.equal(out.isolated, true, out.blocked?.join('\n') || out.failing?.join('\n')); assert.ok(out.env_digest?.includes(image)); diff --git a/test/machine.test.mjs b/test/machine.test.mjs index 32c63d4..b013064 100644 --- a/test/machine.test.mjs +++ b/test/machine.test.mjs @@ -2,7 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { execSync } from 'node:child_process'; import * as P from '../src/index.mjs'; -import { repo, addTask } from './helpers.mjs'; +import { repo, addTask, commitBeforeBase } from './helpers.mjs'; const BENCH = (v) => `console.log(JSON.stringify({ flushMs: ${v} }));\n`; const NODE = execSync('node --version', { encoding: 'utf8' }).trim(); @@ -11,6 +11,7 @@ function metricRepo({ flushMs = 1200, max } = {}) { const r = repo({ files: { 'bench/b.mjs': BENCH(flushMs) } }); if (max !== undefined) { const c = P.C.load(r); c.defaults.max_load_per_cpu = max; P.C.save(r, c); } addTask(r, { id: 'M', title: 'm', criteria: [{ verifier: { type: 'metric', cmd: 'node bench/b.mjs', key: 'flushMs', op: '<', value: 2000 } }] }); + commitBeforeBase(r); P.taskNext(r); return r; } diff --git a/test/owner.test.mjs b/test/owner.test.mjs index 6e89368..01ef147 100644 --- a/test/owner.test.mjs +++ b/test/owner.test.mjs @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; import * as P from '../src/index.mjs'; import { handle } from '../src/mcp.mjs'; -import { repo, addTask, BIN } from './helpers.mjs'; +import { repo, addTask, commitBeforeBase, BIN } from './helpers.mjs'; const ok = [{ verifier: { type: 'command', cmd: 'true' } }]; /** Three tasks in one phase: owner b first, then owner a, then unowned. */ @@ -35,6 +35,7 @@ test('validate refuses an owner outside defaults.owners, and any owner is fine w test('next --owner returns the owner\'s task or an unowned one, never another owner\'s', () => { const r = lanes({ owners: ['a', 'b'] }); + commitBeforeBase(r); assert.equal(P.E.next(r).task, 'B-1', 'without --owner, next is unchanged'); let n = P.taskNext(r, { owner: 'a' }); assert.equal(n.task, 'A-1'); assert.match(n.brief, /Owner: a/); diff --git a/test/remote.test.mjs b/test/remote.test.mjs index 2d05c44..5446b27 100644 --- a/test/remote.test.mjs +++ b/test/remote.test.mjs @@ -4,7 +4,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import * as P from '../src/index.mjs'; -import { write, commitAll, sh } from './helpers.mjs'; +import { write, commitAll, parentAfterContract, sh } from './helpers.mjs'; /** A repo whose only remote is named github, with proofline initialised and a task that must be pushed. */ function githubRepo() { @@ -75,6 +75,7 @@ test('a pushed criterion with its own remote wins over defaults.remote in the co sh(path.dirname(r), 'init', '-q', '--bare', '-b', 'main', mirror); sh(r, 'remote', 'add', 'mirror', mirror); const c = P.C.load(r); c.tasks.push(P.C.makeTask({ id: 'U', title: 'u', criteria: [{ verifier: { type: 'pushed', remote: 'mirror', branch: 'release' } }] })); P.C.save(r, c); commitAll(r, 'U'); sh(r, 'push', '-q', 'github', 'main'); + parentAfterContract(r); const g = P.E.verifyTask(r, 'U', { base: 'HEAD~1' }); assert.equal(g.decision, 'FAIL'); assert.match(P.E.steer(g, P.C.load(r)), /`git push mirror release`/); diff --git a/test/steer.test.mjs b/test/steer.test.mjs index 2691699..6c8e0d4 100644 --- a/test/steer.test.mjs +++ b/test/steer.test.mjs @@ -1,7 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import * as P from '../src/index.mjs'; -import { repo, write, addTask, commitAll, sh } from './helpers.mjs'; +import { repo, write, addTask, commitAll, commitBeforeBase, sh } from './helpers.mjs'; const PUSH_AGAIN = /Commit, push|push to |git push|task_next/; @@ -9,6 +9,7 @@ for (const withPushed of [true, false]) { test(`PASS closing text before and after the push (${withPushed ? 'pushed criterion' : 'no pushed criterion'})`, () => { const r = repo(); addTask(r, { id: 'T', title: 't', criteria: [{ verifier: { type: 'command', cmd: 'true' } }, ...(withPushed ? [{ verifier: { type: 'pushed' } }] : [])] }); + commitBeforeBase(r); P.taskNext(r); write(r, { 'src/x.mjs': 'export const x = 1;\n' }); commitAll(r, 'T: x'); @@ -32,6 +33,7 @@ for (const withPushed of [true, false]) { test('after accepted claims on the pushed HEAD, the closing text points to next', () => { const r = repo({ files: { 'test/t.test.mjs': "import test from 'node:test';\ntest('t', () => {});\n" } }); addTask(r, { id: 'T', title: 't', criteria: [{ verifier: { type: 'command', cmd: 'true' } }, { verifier: { type: 'pushed' } }] }); + commitBeforeBase(r); P.taskNext(r); write(r, { 'src/x.mjs': 'export const x = 1;\n' }); commitAll(r, 'T: x'); sh(r, 'push', '-q', 'origin', 'main'); diff --git a/test/verify.test.mjs b/test/verify.test.mjs index 0698cf2..8f6142f 100644 --- a/test/verify.test.mjs +++ b/test/verify.test.mjs @@ -5,7 +5,7 @@ import os from 'node:os'; import path from 'node:path'; import * as P from '../src/index.mjs'; import { sha256 } from '../src/util.mjs'; -import { repo, write, addTask, commitAll, sh } from './helpers.mjs'; +import { repo, write, addTask, commitAll, commitBeforeBase, sh } from './helpers.mjs'; const BENCH = (v) => `console.log('progress'); console.log(JSON.stringify({ flushMs: ${v}, writers1: { p50: 12 }, writers32: { savesPerSec: 640 } }));\n`; @@ -14,6 +14,7 @@ test('metric: threshold met passes, missed fails with measured value', () => { addTask(r, { id: 'M', title: 'm', criteria: [ { verifier: { type: 'metric', cmd: 'node bench/b.mjs', key: 'flushMs', op: '<', value: 2000 } }, { verifier: { type: 'metric', cmd: 'node bench/b.mjs', key: 'writers32.savesPerSec', op: '>', value: 500 } }] }); + commitBeforeBase(r); P.taskNext(r); let g = P.E.verifyTask(r, 'M'); assert.equal(g.decision, 'PASS'); assert.equal(g.measured.flushMs, 1200); write(r, { 'bench/b.mjs': BENCH(14400) }); @@ -23,6 +24,7 @@ test('metric: threshold met passes, missed fails with measured value', () => { test('metric: missing bench rig is BLOCKED, never PASS', () => { const r = repo(); addTask(r, { id: 'M', title: 'm', criteria: [{ verifier: { type: 'metric', cmd: 'node bench/missing.mjs', key: 'flushMs', op: '<', value: 2000 } }] }); + commitBeforeBase(r); P.taskNext(r); const g = P.E.verifyTask(r, 'M'); assert.equal(g.decision, 'BLOCKED'); assert.deepEqual(g.blocked.map((b) => b.split(':')[0]), ['M-AC01']); }); @@ -31,6 +33,7 @@ test('metric: regression vs baseline beyond tolerance fails even under the thres const r = repo({ files: { 'bench/b.mjs': BENCH(1900) } }); const c = P.C.load(r); c.baseline.metrics.flushMs = 1000; P.C.save(r, c); addTask(r, { id: 'M', title: 'm', criteria: [{ verifier: { type: 'metric', cmd: 'node bench/b.mjs', key: 'flushMs', op: '<', value: 2000 } }] }); + commitBeforeBase(r); P.taskNext(r); const g = P.E.verifyTask(r, 'M'); assert.equal(g.decision, 'FAIL'); assert.match(g.failing.join(), /REGRESSION/); }); @@ -38,6 +41,7 @@ test('metric: regression vs baseline beyond tolerance fails even under the thres test('command: flaky (fails then passes) is not a pass', () => { const r = repo({ files: { 'flaky.sh': 'if [ -f .once ]; then exit 0; fi; touch .once; exit 1\n' } }); addTask(r, { id: 'F', title: 'f', criteria: [{ verifier: { type: 'command', cmd: 'bash flaky.sh' } }] }); + commitBeforeBase(r); P.taskNext(r); const g = P.E.verifyTask(r, 'F'); assert.equal(g.results[0].status, 'flaky'); assert.equal(g.decision, 'FAIL'); }); @@ -45,6 +49,7 @@ test('command: flaky (fails then passes) is not a pass', () => { test('command: hung/cancelled tests and too few tests fail', () => { const r = repo({ files: { 'fake.sh': 'echo "# tests 3"; echo "# pass 2"; echo "# cancelled 1"; exit 0\n' } }); addTask(r, { id: 'H', title: 'h', criteria: [{ verifier: { type: 'command', cmd: 'bash fake.sh', min_tests: 10 } }] }); + commitBeforeBase(r); P.taskNext(r); const g = P.E.verifyTask(r, 'H'); assert.equal(g.decision, 'FAIL'); assert.match(g.failing[0], /cancelled/); assert.match(g.failing[0], /requires ≥ 10/); }); @@ -52,6 +57,7 @@ test('command: hung/cancelled tests and too few tests fail', () => { test('wiring: code built but unreachable from the entry fails; wired passes', () => { const r = repo({ files: { 'src/main.mjs': "import { a } from './a.mjs';\na();\n", 'src/a.mjs': 'export function a() {}\n', 'src/fast.mjs': 'export function fastSeal() {}\n' } }); addTask(r, { id: 'W', title: 'w', criteria: [{ verifier: { type: 'wiring', entry: 'src/main.mjs', symbol: 'fastSeal' } }] }); + commitBeforeBase(r); P.taskNext(r); assert.equal(P.E.verifyTask(r, 'W').decision, 'FAIL'); write(r, { 'src/a.mjs': "import { fastSeal } from './fast.mjs';\nexport function a() { fastSeal(); }\n" }); @@ -61,6 +67,7 @@ test('wiring: code built but unreachable from the entry fails; wired passes', () test('grep: default config flag must be on', () => { const r = repo({ files: { 'src/fastops.mjs': "export const defaults = { wait_default: 'sealed' };\n" } }); addTask(r, { id: 'G', title: 'g', criteria: [{ verifier: { type: 'grep', file: 'src/fastops.mjs', pattern: "wait_default:\\s*'durable'" } }] }); + commitBeforeBase(r); P.taskNext(r); assert.equal(P.E.verifyTask(r, 'G').decision, 'FAIL'); }); @@ -68,6 +75,7 @@ test('grep: default config flag must be on', () => { test('scope drift is detected', () => { const r = repo(); addTask(r, { id: 'S', title: 's', scope: ['src/**'], criteria: [{ verifier: { type: 'scope' } }] }); + commitBeforeBase(r); P.taskNext(r); write(r, { 'src/ok.mjs': '1', 'infra/deploy.yml': 'x' }); const g = P.E.verifyTask(r, 'S'); assert.equal(g.decision, 'FAIL'); assert.match(g.failing[0], /infra\/deploy.yml/); @@ -125,6 +133,7 @@ test('a marker for the current contract bytes approves the weakenings and names test('tampered evidence log BLOCKS the gate', () => { const r = repo(); addTask(r, { id: 'X', title: 'x', criteria: [{ verifier: { type: 'command', cmd: 'true' } }] }); + commitBeforeBase(r); P.taskNext(r); assert.equal(P.E.gate(r, 'X').decision, 'PASS'); const f = path.join(r, '.proofline', 'evidence.jsonl'); fs.writeFileSync(f, fs.readFileSync(f, 'utf8').replace('"PASS"', '"FAIL"')); @@ -134,6 +143,7 @@ test('tampered evidence log BLOCKS the gate', () => { test('gate writes Markdown + JSON reports and a corrective prompt per failed criterion', () => { const r = repo(); addTask(r, { id: 'X', title: 'x', criteria: [{ statement: 'suite', verifier: { type: 'command', cmd: 'exit 3' } }] }); + commitBeforeBase(r); P.taskNext(r); const g = P.E.gate(r, 'X'); assert.equal(g.decision, 'FAIL');