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');