@optitransfer/proofline 0.0.0-stage → 0.2.0-next.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/checks.mjs ADDED
@@ -0,0 +1,271 @@
1
+ // Deterministic verifiers. Each returns { status: pass|fail|flaky|error|blocked, detail, evidence }.
2
+ // "error" means the check could not be run (missing tool/file/base) and leads to BLOCKED, never PASS.
3
+ // "blocked" means it must not be run now (a metric on a busy machine) and also leads to BLOCKED.
4
+ import fs from 'node:fs';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import { run, git, tail, pick, lastJson, matchAny, onRemote } from './util.mjs';
8
+
9
+ /** The machine a metric runs on: 1-minute load average and CPU count. verifyTask takes a replacement as `machine`. */
10
+ export const readMachine = () => ({ load1: os.loadavg()[0], cpus: os.cpus().length });
11
+
12
+ /** `node --version` as the bench command would resolve it (same cwd and env), or null. */
13
+ function nodeVersion(root, env) {
14
+ const r = run('node --version', { cwd: root, timeoutS: 30, env });
15
+ return r.exit === 0 ? r.stdout.trim() : null;
16
+ }
17
+
18
+ export const TEST_FILE_RE = /(^|\/)(test|tests|__tests__)\/|\.(test|spec)\.[mc]?[jt]sx?$/;
19
+ const sub = (s, vars) => s.replace(/\{(\w+)\}/g, (m, k) => (vars[k] ?? m));
20
+
21
+ export function resolveBase(root, ref) {
22
+ const r = git(['rev-parse', '--verify', '--quiet', `${ref}^{commit}`], root);
23
+ return r.ok ? r.out : null;
24
+ }
25
+
26
+ /** Changed files between base and the working tree (tracked + untracked). */
27
+ export function changedFiles(root, base) {
28
+ const out = new Map();
29
+ const d = git(['diff', '--name-status', '-M', base, '--'], root);
30
+ for (const l of d.out.split('\n').filter(Boolean)) {
31
+ const [st, a, b] = l.split('\t');
32
+ out.set(b || a, st[0]);
33
+ }
34
+ for (const f of git(['ls-files', '--others', '--exclude-standard'], root).out.split('\n').filter(Boolean)) out.set(f, 'A');
35
+ for (const f of [...out.keys()]) if (f.startsWith('.proofline/')) out.delete(f);
36
+ return out;
37
+ }
38
+
39
+ /** Parse a unified diff (-U0) into per-file hunks of added/removed lines. */
40
+ export function diffHunks(root, base) {
41
+ const d = git(['diff', '-U0', base, '--'], root).out;
42
+ const files = [];
43
+ let cur = null, hunk = null;
44
+ for (const l of d.split('\n')) {
45
+ if (l.startsWith('diff --git')) { cur = { file: l.split(' b/').pop(), hunks: [] }; files.push(cur); continue; }
46
+ if (!cur) continue;
47
+ if (l.startsWith('+++') || l.startsWith('---')) continue;
48
+ if (l.startsWith('@@')) { hunk = { add: [], del: [] }; cur.hunks.push(hunk); continue; }
49
+ if (!hunk) continue;
50
+ if (l[0] === '+') hunk.add.push(l.slice(1)); else if (l[0] === '-') hunk.del.push(l.slice(1));
51
+ }
52
+ // untracked files count as fully added
53
+ for (const f of git(['ls-files', '--others', '--exclude-standard'], root).out.split('\n').filter(Boolean)) {
54
+ if (f.startsWith('.proofline/')) continue;
55
+ let txt = ''; try { txt = fs.readFileSync(path.join(root, f), 'utf8'); } catch {}
56
+ files.push({ file: f, hunks: [{ add: txt.split('\n'), del: [] }] });
57
+ }
58
+ return files;
59
+ }
60
+
61
+ function runCounted(cmd, root, v) {
62
+ const r = run(cmd, { cwd: root, timeoutS: v.timeout_s || 900, env: v.env });
63
+ const txt = r.stdout + '\n' + r.stderr;
64
+ const num = (k) => { const m = txt.match(new RegExp(`^[#ℹ]\\s*${k}\\s+(\\d+)`, 'm')); return m ? +m[1] : undefined; };
65
+ r.counts = { tests: num('tests'), pass: num('pass'), fail: num('fail'), skipped: num('skipped'), todo: num('todo'), cancelled: num('cancelled') };
66
+ return r;
67
+ }
68
+
69
+ export const FAILING_CAP = 100;
70
+ const ANSI_RE = /\x1b\[[0-9;]*m/g;
71
+ // TAP: `not ok <n> - <name>`, subtests indented. node writes a `#` inside a name as `\#`, so an unescaped ` #` starts the directive.
72
+ const TAP_FAIL_RE = /^\s*not ok\s+\d+(?:\s+-)?(?:\s+(.*))?$/;
73
+ const TAP_DIRECTIVE_RE = /(?:^|\s)#\s*(\w*)/;
74
+ // spec and dot reporters: `✖ <name> (<ms>ms)`. The duration comes last, except for a todo or skip, which node marks with
75
+ // ` # <reason>` (or `# TODO`, `# SKIP`) after it. The `failing tests:` and `Failed tests:` summaries repeat these lines.
76
+ const SPEC_FAIL_RE = /^\s*\u2716\s+(.*)\s\(\d+(?:\.\d+)?ms\)(\s+#.*)?$/;
77
+
78
+ /**
79
+ * Names of the failed tests anywhere in a run's output, in order of first appearance, without repeats, todos or skips.
80
+ * At most `cap` names; `failing_total` is set only when there were more.
81
+ */
82
+ export function failingTests(text, cap = FAILING_CAP) {
83
+ const seen = new Set();
84
+ for (const line of String(text).replace(ANSI_RE, '').split(/\r?\n/)) {
85
+ let m = line.match(TAP_FAIL_RE), name;
86
+ if (m) {
87
+ const rest = m[1] || '', d = rest.match(TAP_DIRECTIVE_RE);
88
+ if (!d || !/^(todo|skip)/i.test(d[1])) name = (d ? rest.slice(0, d.index) : rest).replace(/\\([\\#])/g, '$1');
89
+ } else if ((m = line.match(SPEC_FAIL_RE)) && !m[2]) name = m[1];
90
+ if (name && name.trim()) seen.add(name.trim());
91
+ }
92
+ const all = [...seen];
93
+ return all.length > cap ? { failing: all.slice(0, cap), failing_total: all.length } : { failing: all };
94
+ }
95
+
96
+ const ev = (r) => ({ cmd: r.cmd, exit: r.exit, ms: r.ms, counts: r.counts, timedOut: r.timedOut, ...failingTests(r.stdout + '\n' + r.stderr), out_tail: tail(r.stdout + (r.stderr ? '\n[stderr]\n' + r.stderr : ''), 1200) });
97
+ /** "12 tests failed: a, b, ... and 2 more" from a run's evidence (first 10 names), or '' when it names none. */
98
+ const named = (e) => {
99
+ const n = e.failing_total ?? e.failing.length, show = e.failing.slice(0, 10);
100
+ return n ? `${n} test${n === 1 ? '' : 's'} failed: ${show.join(', ')}${n > show.length ? ` and ${n - show.length} more` : ''}` : '';
101
+ };
102
+
103
+ export const verifiers = {
104
+ command(v, ctx) {
105
+ const expect = v.expect_exit ?? 0;
106
+ let r = runCounted(v.cmd, ctx.root, v);
107
+ const first = ev(r), firstExit = r.exit;
108
+ if (r.exit === 127) return { status: 'error', detail: `command not found: ${v.cmd}`, evidence: first };
109
+ const tooMany = (r2) => {
110
+ const c = r2.counts; const p = [];
111
+ if ((c.cancelled || 0) > 0) p.push(`${c.cancelled} cancelled/hung`);
112
+ if (v.max_skipped !== undefined && (c.skipped || 0) + (c.todo || 0) > v.max_skipped) p.push(`${(c.skipped || 0) + (c.todo || 0)} skipped/todo > allowed ${v.max_skipped}`);
113
+ if (v.min_tests !== undefined && (c.tests ?? c.pass ?? 0) < v.min_tests) p.push(`only ${c.tests ?? c.pass ?? 0} tests ran, contract requires ≥ ${v.min_tests} (tests deleted or not discovered?)`);
114
+ return p;
115
+ };
116
+ if (r.exit === expect && !tooMany(r).length) return { status: 'pass', detail: `exit ${r.exit} in ${r.ms} ms`, evidence: first };
117
+ if (r.exit === expect) return { status: 'fail', detail: [...tooMany(r), named(first)].filter(Boolean).join('; '), evidence: first };
118
+ // Both runs keep their failing names: on a flaky result the first run's list is the tests that flapped.
119
+ for (let i = 0; i < (ctx.flakyReruns ?? 1); i++) {
120
+ r = runCounted(v.cmd, ctx.root, v);
121
+ if (r.exit === expect && !tooMany(r).length) return { status: 'flaky', detail: `failed (exit ${firstExit}${named(first) ? `; ${named(first)}` : ''}) then passed on re-run ${i + 1}: flaky, not a pass. Root-cause the non-determinism.`, evidence: { first, rerun: ev(r) } };
122
+ }
123
+ const rerun = ev(r), n = named(rerun.failing.length ? rerun : first);
124
+ return { status: 'fail', detail: `exit ${r.exit} (expected ${expect})${r.timedOut ? ', timed out' : ''}; failed again on re-run, so it is real${n ? `; ${n}` : ''}`, evidence: { first, rerun } };
125
+ },
126
+
127
+ file_exists(v, ctx) {
128
+ return fs.existsSync(path.join(ctx.root, v.path)) ? { status: 'pass', detail: `${v.path} exists` } : { status: 'fail', detail: `${v.path} does not exist in the repo` };
129
+ },
130
+
131
+ test_exists(v, ctx) {
132
+ const f = path.join(ctx.root, v.test_file);
133
+ if (!fs.existsSync(f)) return { status: 'fail', detail: `test file ${v.test_file} not found` };
134
+ if (v.test_name && !fs.readFileSync(f, 'utf8').includes(v.test_name)) return { status: 'fail', detail: `test "${v.test_name}" not found in ${v.test_file}` };
135
+ return { status: 'pass', detail: 'test present' };
136
+ },
137
+
138
+ /** The test must exist now, FAIL at the base revision (with the new test copied in), and PASS now. */
139
+ test_red_green(v, ctx) {
140
+ const exists = verifiers.test_exists(v, ctx);
141
+ if (exists.status !== 'pass') return exists;
142
+ if (!ctx.base) return { status: 'error', detail: 'base revision unresolved; cannot prove red-green' };
143
+ const wt = fs.mkdtempSync(path.join(os.tmpdir(), 'proofline-base-'));
144
+ try {
145
+ const add = git(['worktree', 'add', '--detach', '--force', wt, ctx.base], ctx.root);
146
+ if (!add.ok) return { status: 'error', detail: `git worktree add failed: ${add.err}` };
147
+ for (const f of [v.test_file, ...(v.support_files || [])]) {
148
+ fs.mkdirSync(path.dirname(path.join(wt, f)), { recursive: true });
149
+ fs.copyFileSync(path.join(ctx.root, f), path.join(wt, f));
150
+ }
151
+ if (v.setup) run(v.setup, { cwd: wt, timeoutS: 900 });
152
+ const cmd = sub(v.cmd, { test: v.test_file, name: v.test_name || '' });
153
+ const red = runCounted(cmd, wt, v);
154
+ const green = runCounted(cmd, ctx.root, v);
155
+ const evd = { base: ctx.base, red: ev(red), green: ev(green) };
156
+ if (red.exit === 127 || green.exit === 127) return { status: 'error', detail: 'test command not found', evidence: evd };
157
+ if (red.exit === 0) return { status: 'fail', detail: `test PASSES on base ${ctx.base.slice(0, 8)} without the change: it does not prove the change (tests how, not what?)`, evidence: evd };
158
+ if (green.exit !== 0) return { status: 'fail', detail: `test fails on the current tree (exit ${green.exit})`, evidence: evd };
159
+ if (v.test_name && !(green.stdout + green.stderr).includes(v.test_name)) return { status: 'fail', detail: `"${v.test_name}" did not appear in the run output: it was not executed`, evidence: evd };
160
+ return { status: 'pass', detail: `red on base (exit ${red.exit}), green now`, evidence: evd };
161
+ } finally {
162
+ git(['worktree', 'remove', '--force', wt], ctx.root);
163
+ fs.rmSync(wt, { recursive: true, force: true });
164
+ }
165
+ },
166
+
167
+ metric(v, ctx) {
168
+ // One bench run serves every metric criterion that uses the same command (benches are slow).
169
+ // The load is read before the run: a number taken on a busy machine is not evidence either way.
170
+ ctx.cmdCache ??= {};
171
+ const ck = v.cmd + JSON.stringify(v.env || {});
172
+ const max = ctx.contract?.defaults?.max_load_per_cpu;
173
+ const { r, machine } = ctx.cmdCache[ck] ??= (() => {
174
+ const m = (ctx.machine || readMachine)();
175
+ const machine = { load1: m.load1, cpus: m.cpus, load_per_cpu: +(m.load1 / Math.max(1, m.cpus)).toFixed(2), node: nodeVersion(ctx.root, v.env) };
176
+ if (typeof max === 'number' && m.load1 / Math.max(1, m.cpus) > max) return { r: null, machine };
177
+ return { r: run(v.cmd, { cwd: ctx.root, timeoutS: v.timeout_s || 1800, env: v.env }), machine };
178
+ })();
179
+ const on = `load ${machine.load1.toFixed(2)} / ${machine.cpus} CPUs, node ${machine.node || 'not found'}`;
180
+ if (!r) return { status: 'blocked', detail: `machine busy: load ${machine.load1.toFixed(2)} over ${machine.cpus} CPUs = ${machine.load_per_cpu.toFixed(2)} per CPU > max_load_per_cpu ${max}; the bench was not run. Re-run when the machine is idle.`, evidence: { cmd: v.cmd, machine } };
181
+ let data;
182
+ if (v.output_file) { try { data = JSON.parse(fs.readFileSync(path.resolve(ctx.root, sub(v.output_file, process.env)), 'utf8')); } catch {} }
183
+ else data = lastJson(r.stdout);
184
+ const e = { cmd: v.cmd, exit: r.exit, ms: r.ms, machine, out_tail: tail(r.stdout + r.stderr, 800) };
185
+ if (r.exit === 127) return { status: 'error', detail: `bench command not found: ${v.cmd} (is the bench rig committed in the repo?)`, evidence: e };
186
+ if (data === undefined) return { status: 'error', detail: `no JSON output from bench (exit ${r.exit})`, evidence: e };
187
+ const val = pick(data, v.key);
188
+ e.key = v.key; e.measured = val; e.threshold = `${v.op} ${v.value}`;
189
+ if (typeof val !== 'number') return { status: 'error', detail: `metric ${v.key} missing from bench output`, evidence: e };
190
+ const ok = { '<': val < v.value, '<=': val <= v.value, '>': val > v.value, '>=': val >= v.value, '==': val === v.value }[v.op];
191
+ const name = v.name || v.key;
192
+ ctx.measured[name] = val;
193
+ const b = ctx.baseline?.[name];
194
+ let reg = '';
195
+ if (typeof b === 'number' && b !== 0) {
196
+ const tol = (ctx.tolerancePct ?? 20) / 100;
197
+ const worse = v.op.startsWith('<') ? val > b * (1 + tol) : v.op.startsWith('>') ? val < b * (1 - tol) : false;
198
+ e.baseline = b;
199
+ if (worse) reg = ` REGRESSION vs baseline ${b} (> ${ctx.tolerancePct ?? 20}%)`;
200
+ }
201
+ if (!ok) return { status: 'fail', detail: `${name} = ${val}, needs ${v.op} ${v.value}${reg} (${on})`, evidence: e };
202
+ if (reg) return { status: 'fail', detail: `${v.key} = ${val} meets threshold but${reg} (${on})`, evidence: e };
203
+ return { status: 'pass', detail: `${v.key} = ${val} ${v.op} ${v.value} (${on})`, evidence: e };
204
+ },
205
+
206
+ grep(v, ctx) {
207
+ const f = path.join(ctx.root, v.file);
208
+ if (!fs.existsSync(f)) return { status: 'fail', detail: `${v.file} not found` };
209
+ const txt = fs.readFileSync(f, 'utf8');
210
+ const re = new RegExp(v.pattern, v.flags || 'm');
211
+ const m = txt.match(re);
212
+ const want = (v.must || 'present') === 'present';
213
+ if (!!m === want) return { status: 'pass', detail: `/${v.pattern}/ ${want ? 'present' : 'absent'} in ${v.file}`, evidence: m ? { line: txt.slice(0, m.index).split('\n').length, text: m[0] } : undefined };
214
+ return { status: 'fail', detail: `/${v.pattern}/ ${want ? 'NOT found' : 'still present'} in ${v.file}${v.why ? ` (${v.why})` : ''}` };
215
+ },
216
+
217
+ /** The symbol must be referenced (not just defined) in a file reachable by static imports from the default entry point. */
218
+ wiring(v, ctx) {
219
+ const entry = path.join(ctx.root, v.entry);
220
+ if (!fs.existsSync(entry)) return { status: 'fail', detail: `entry ${v.entry} not found` };
221
+ const seen = new Map([[entry, null]]); const q = [entry];
222
+ const exts = ['', '.mjs', '.js', '.cjs', '.ts', '/index.mjs', '/index.js', '/index.ts'];
223
+ const defRe = new RegExp(`(function\\*?|class|const|let|var)\\s+${v.symbol}\\b|^\\s*(async\\s+)?${v.symbol}\\s*\\([^)]*\\)\\s*\\{`, 'm');
224
+ const useRe = new RegExp(`\\b${v.symbol}\\b`);
225
+ while (q.length) {
226
+ const f = q.shift(); let src = '';
227
+ try { src = fs.readFileSync(f, 'utf8'); } catch { continue; }
228
+ const uses = src.split('\n').filter((l) => useRe.test(l) && !defRe.test(l) && !/^\s*(\/\/|\*|import\b|export\s*\{)/.test(l));
229
+ if (uses.length) {
230
+ const chain = []; for (let x = f; x; x = seen.get(x)) chain.unshift(path.relative(ctx.root, x));
231
+ return { status: 'pass', detail: `${v.symbol} used on the default path: ${chain.join(' → ')}`, evidence: { chain, line: uses[0].trim().slice(0, 200) } };
232
+ }
233
+ for (const m of src.matchAll(/(?:import|export)[^'"]*?from\s*['"](\.[^'"]+)['"]|import\s*\(\s*['"](\.[^'"]+)['"]\s*\)|require\(\s*['"](\.[^'"]+)['"]\s*\)|^\s*import\s+['"](\.[^'"]+)['"]/gm)) {
234
+ const spec = m[1] || m[2] || m[3] || m[4];
235
+ for (const e of exts) {
236
+ const p = path.resolve(path.dirname(f), spec + e);
237
+ if (fs.existsSync(p) && fs.statSync(p).isFile()) { if (!seen.has(p)) { seen.set(p, f); q.push(p); } break; }
238
+ }
239
+ }
240
+ }
241
+ return { status: 'fail', detail: `${v.symbol} is not referenced from the default path (${v.entry}; ${seen.size} files reachable). Built but not wired in.` };
242
+ },
243
+
244
+ scope(v, ctx) {
245
+ if (!ctx.base) return { status: 'error', detail: 'base unresolved; cannot compute scope' };
246
+ const allow = v.allow || ctx.task.scope?.allow || ['**'];
247
+ const out = [...ctx.changed.keys()].filter((f) => !matchAny(f, allow));
248
+ return out.length ? { status: 'fail', detail: `changes outside the approved scope: ${out.join(', ')}`, evidence: { outside: out, allow } } : { status: 'pass', detail: `${ctx.changed.size} changed files, all in scope` };
249
+ },
250
+
251
+ docs(v, ctx) {
252
+ const r = run(v.cmd, { cwd: ctx.root, timeoutS: v.timeout_s || 300 });
253
+ if (r.exit !== 0) return { status: r.exit === 127 ? 'error' : 'fail', detail: `docs generator exit ${r.exit}`, evidence: ev(r) };
254
+ const st = git(['status', '--porcelain', '--', ...v.paths], ctx.root).out;
255
+ return st ? { status: 'fail', detail: `generated docs differ from what is committed:\n${st}`, evidence: { changed: st } } : { status: 'pass', detail: 'docs regenerated, no diff' };
256
+ },
257
+
258
+ pushed(v, ctx) {
259
+ const branch = v.branch || ctx.contract.defaults?.branch || 'main';
260
+ const remote = v.remote || ctx.contract.defaults?.remote || 'origin';
261
+ const dirty = git(['status', '--porcelain'], ctx.root).out.split('\n').filter((l) => l && !l.includes('.proofline/'));
262
+ if (dirty.length) return { status: 'fail', detail: `working tree not clean (${dirty.length} paths); work held locally`, evidence: { dirty: dirty.slice(0, 20) } };
263
+ const head = git(['rev-parse', 'HEAD'], ctx.root).out;
264
+ // HEAD passes as the branch's tip or as a commit under it: a task whose commit sits inside a pushed stack is pushed
265
+ const o = onRemote(ctx.root, head, remote, branch);
266
+ if (!o.ok) return { status: 'error', detail: `cannot reach ${remote}: ${o.err}` };
267
+ if (o.at) return { status: 'pass', detail: `HEAD ${head.slice(0, 8)} is ${remote}/${branch}`, evidence: { tip: o.tip, behind: 0 } };
268
+ if (o.contains) return { status: 'pass', detail: `HEAD ${head.slice(0, 8)} is on ${remote}/${branch}, ${o.behind} commit${o.behind === 1 ? '' : 's'} under its tip ${o.tip.slice(0, 8)}`, evidence: { tip: o.tip, behind: o.behind } };
269
+ return { status: 'fail', detail: `HEAD ${head.slice(0, 8)} not pushed: ${remote}/${branch} is ${(o.tip || 'missing').slice(0, 8)}${o.unknown ? ', which could not be fetched to see whether it holds HEAD' : ''}`, evidence: { tip: o.tip || null, unknown: o.unknown } };
270
+ },
271
+ };
package/src/claims.mjs ADDED
@@ -0,0 +1,60 @@
1
+ // Parse an agent's status report and check every claim against evidence. Claims are never trusted.
2
+ import { git, onRemote } from './util.mjs';
3
+
4
+ const DONE = /^(done|complete(d)?|verified|pass(ed)?|fixed|finished|green)$/i;
5
+ const HEDGE = /\b(should work|likely|probably|untested|not tested|later|follow[- ]?up|out of scope|defer(red)?|todo|next session|in a future)\b/i;
6
+
7
+ /** Lines: TASK-ID | status | commit | tests | measured vs target | missing */
8
+ export function parseClaims(text, taskIds) {
9
+ const out = [];
10
+ for (const raw of String(text || '').split('\n')) {
11
+ if (!raw.includes('|')) continue;
12
+ const cells = raw.split('|').map((s) => s.trim()).filter((s, i, a) => !(s === '' && (i === 0 || i === a.length - 1)));
13
+ if (cells.length < 2) continue;
14
+ const id = cells[0].replace(/[*`]/g, '');
15
+ if (taskIds && !taskIds.includes(id)) continue;
16
+ out.push({ raw: raw.trim(), id, status: cells[1] || '', commit: cells[2] || '', tests: cells[3] || '', numbers: cells[4] || '', missing: cells[5] || '' });
17
+ }
18
+ return out;
19
+ }
20
+
21
+ /** results: per-task { decision, measured: {key: val}, metricLabels: {key: [aliases]} } */
22
+ export function checkClaim(root, claim, res, { requirePushed = false, remote = 'origin', branch = 'main' } = {}) {
23
+ const issues = [];
24
+ const claimedDone = DONE.test(claim.status.replace(/[^a-z]/gi, ''));
25
+ if (claimedDone && res.decision !== 'PASS') issues.push(`claimed "${claim.status}" but the independent gate says ${res.decision}: ${res.failing.join('; ') || 'see report'}`);
26
+ if (HEDGE.test(claim.raw)) issues.push(`hedged or deferred wording ("${claim.raw.match(HEDGE)[0]}"): not an acceptable status; never defer, report blocked with what you need`);
27
+ const sha = (claim.commit.match(/\b[0-9a-f]{7,40}\b/i) || [])[0];
28
+ if (claimedDone && !sha) issues.push('claimed done without a commit hash');
29
+ if (sha) {
30
+ const full = git(['rev-parse', '--verify', '--quiet', `${sha}^{commit}`], root);
31
+ if (!full.ok) issues.push(`commit ${sha} does not exist`);
32
+ else {
33
+ if (!git(['merge-base', '--is-ancestor', full.out, 'HEAD'], root).ok) issues.push(`commit ${sha} is not in HEAD's history`);
34
+ if (requirePushed) {
35
+ const o = onRemote(root, full.out, remote, branch);
36
+ if (!o.ok) issues.push(`cannot check that commit ${sha} is on ${remote}/${branch}: ${o.err}`);
37
+ else if (!o.contains) issues.push(`commit ${sha} is not on ${remote}/${branch}${o.unknown ? ` (its tip ${o.tip.slice(0, 8)} could not be fetched)` : ''}`);
38
+ }
39
+ }
40
+ }
41
+ const names = claim.tests.split(/[,;]+/).map((s) => s.trim().replace(/^`|`$/g, '')).filter((s) => s && !/^(-|n\/a|none)$/i.test(s));
42
+ if (claimedDone && !names.length) issues.push('claimed done without naming the test(s) that prove it');
43
+ for (const n of names) {
44
+ const base = n.replace(/\s*\(.*\)$/, '').split(/\s+/)[0];
45
+ if (!git(['grep', '-q', '-F', base], root).ok && !git(['ls-files', '--', `*${base}*`], root).out) issues.push(`named test "${base}" not found in the repo`);
46
+ }
47
+ const keys = Object.keys(res.measured || {});
48
+ if (claimedDone && keys.length && !/\d/.test(claim.numbers)) issues.push(`task has metric targets (${keys.join(', ')}) but the claim gives no measured numbers`);
49
+ for (const k of keys) {
50
+ const aliases = [k, ...(res.metricLabels?.[k] || [])];
51
+ for (const a of aliases) {
52
+ const m = claim.numbers.match(new RegExp(a.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\D{0,12}?(\\d+(?:\\.\\d+)?)', 'i'));
53
+ if (!m) continue;
54
+ const said = +m[1], got = res.measured[k];
55
+ if (Math.abs(said - got) > Math.max(0.15 * Math.abs(got), 1)) issues.push(`claimed ${a} ${said} but measured ${got}`);
56
+ break;
57
+ }
58
+ }
59
+ return { claim: claim.raw, id: claim.id, ok: issues.length === 0, issues };
60
+ }
@@ -0,0 +1,163 @@
1
+ // Contract + plan model: proofline.json at the repo root (committed).
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { readJson, writeJson, sha256, canon } from './util.mjs';
5
+
6
+ export const SCHEMA = 'proofline/1';
7
+ export const PHASES = ['intake', 'design', 'plan', 'implement', 'verify', 'integrate', 'release', 'monitor'];
8
+ export const VERIFIERS = ['command', 'test_red_green', 'test_exists', 'metric', 'grep', 'wiring', 'scope', 'docs', 'pushed', 'file_exists'];
9
+ export const STATUS = {
10
+ PASS: 'PASS', FAIL: 'FAIL', BLOCKED: 'BLOCKED',
11
+ };
12
+ // Task status vocabulary (derived, never self-declared)
13
+ export const TASK_STATUS = ['not_started', 'in_progress', 'verified', 'failed', 'blocked'];
14
+
15
+ export const DEFAULT_FORBIDDEN = {
16
+ test_skip_only: true, // .skip( .only( {skip: test.todo it.skip xit(
17
+ timeout_raise: true, // any numeric timeout increased in the diff
18
+ assertion_removal: true, // net loss of assert/expect lines in test files
19
+ test_file_deletion: true, // deleted test files
20
+ no_verify: true, // --no-verify / -n on git commit|push in added lines
21
+ bypass_env: ['SKIP_HOOKS', 'HUSKY=0', 'PROOFLINE_SKIP', 'CI_SKIP'],
22
+ protected_values: [], // [{file, pattern (one capture group, a number), direction: "no_decrease"|"no_increase"}]
23
+ };
24
+
25
+ export const contractPath = (root) => path.join(root, 'proofline.json');
26
+
27
+ export function load(root) {
28
+ const c = readJson(contractPath(root));
29
+ if (!c) throw new Error(`no proofline.json in ${root} (run: proofline init)`);
30
+ return c;
31
+ }
32
+ export const save = (root, c) => writeJson(contractPath(root), c);
33
+ export const contractHash = (c) => sha256(canon(c));
34
+
35
+ export function skeleton(project = path.basename(process.cwd())) {
36
+ return {
37
+ schema: SCHEMA,
38
+ project,
39
+ owner_rules: [
40
+ "Done means ALL evidence: implemented as specified (task id), a test that FAILED before and passes now, suites green, measured number vs target, docs regenerated, committed AND pushed to the contract's branch.",
41
+ 'Never defer: no "later", "follow-up", "out of scope". If blocked: what blocks, what you tried, what you need; then continue with the next task.',
42
+ 'Root cause, then fix: file:line + mechanism. No weakening tests, raising timeouts, skips, relaxed assertions.',
43
+ 'No silent scope cuts: never lower targets, caps or test sizes without owner approval.',
44
+ 'Never bypass guards: no --no-verify, no bypass env vars, no skip flags.',
45
+ "Deliver continuously: one task per commit, pushed to the contract's branch as it goes green.",
46
+ 'Honest reporting: TASK-ID | status | commit | tests | measured vs target | missing.',
47
+ ],
48
+ defaults: { base: 'origin/main', branch: 'main', flaky_reruns: 1, max_corrective_cycles: 3, regression_tolerance_pct: 20 },
49
+ forbidden: { ...DEFAULT_FORBIDDEN },
50
+ baseline: { metrics: {} },
51
+ phases: PHASES.map((id) => ({ id })),
52
+ tasks: [],
53
+ };
54
+ }
55
+
56
+ /** Turn a task description into a contract task. Criteria that cannot be machine-verified are flagged so freeze refuses. */
57
+ export function makeTask({ id, title, phase = 'implement', owner, depends_on = [], requirements = [], description = '', scope = ['**'], criteria = [], stop_rules = [] }) {
58
+ if (!id || !title) throw new Error('task needs id and title');
59
+ return {
60
+ id, title, phase, ...(owner !== undefined ? { owner } : {}), depends_on, requirements, description,
61
+ // a spec may give the allow list, or the contract's own shape { allow: [...] }; either way one level of allow
62
+ scope: { allow: Array.isArray(scope) ? scope : Array.isArray(scope?.allow) ? scope.allow : [String(scope)] },
63
+ criteria: criteria.map((c, i) => ({ id: c.id || `${id}-AC${String(i + 1).padStart(2, '0')}`, mandatory: c.mandatory !== false, ...c })),
64
+ stop_rules: stop_rules.length ? stop_rules : ['If any metric misses its threshold: STOP, report the measured number, a per-stage trace and file:line. Do not start the next task.'],
65
+ };
66
+ }
67
+
68
+ const isName = (x) => typeof x === 'string' && x.trim() !== '';
69
+
70
+ /** A task belongs to an owner's lane when it is theirs or has no owner. No owner given: every task. */
71
+ export const inLane = (t, owner) => !owner || t.owner === undefined || t.owner === owner;
72
+
73
+ /** The remote and branch the work is pushed to, and the default base: defaults.base, else <remote>/<branch>. */
74
+ export const remoteOf = (c) => c.defaults?.remote || 'origin';
75
+ export const branchOf = (c) => c.defaults?.branch || 'main';
76
+ export const defaultBase = (c) => c.defaults?.base || `${remoteOf(c)}/${branchOf(c)}`;
77
+
78
+ /** An --owner name must be in defaults.owners when the contract has that list. */
79
+ export function checkOwner(c, owner) {
80
+ if (owner === undefined) return;
81
+ if (!isName(owner)) throw new Error('--owner needs a name');
82
+ const owners = c.defaults?.owners;
83
+ if (Array.isArray(owners) && !owners.includes(owner)) throw new Error(`owner ${owner} is not in defaults.owners (${owners.join(', ')})`);
84
+ }
85
+
86
+ /** Validation: returns list of problems (empty == valid). */
87
+ export function validate(c) {
88
+ const errs = [];
89
+ if (c.schema !== SCHEMA) errs.push(`schema must be ${SCHEMA}`);
90
+ if (c.defaults?.remote !== undefined && !isName(c.defaults.remote)) errs.push('defaults.remote must be a remote name (a string)');
91
+ const maxLoad = c.defaults?.max_load_per_cpu;
92
+ if (maxLoad !== undefined && !(typeof maxLoad === 'number' && maxLoad > 0)) errs.push('defaults.max_load_per_cpu must be a positive number');
93
+ const reuse = c.defaults?.reuse_results, maxAge = c.defaults?.reuse_max_age_h;
94
+ if (reuse !== undefined && typeof reuse !== 'boolean') errs.push('defaults.reuse_results must be true or false');
95
+ if (maxAge !== undefined && !(typeof maxAge === 'number' && maxAge > 0)) errs.push('defaults.reuse_max_age_h must be a positive number of hours');
96
+ const owners = c.defaults?.owners;
97
+ if (owners !== undefined && !(Array.isArray(owners) && owners.every(isName))) errs.push('defaults.owners must be a list of names');
98
+ const ids = new Set();
99
+ for (const t of c.tasks || []) {
100
+ if (ids.has(t.id)) errs.push(`duplicate task id ${t.id}`);
101
+ ids.add(t.id);
102
+ if (!PHASES.includes(t.phase)) errs.push(`${t.id}: unknown phase ${t.phase}`);
103
+ if (t.owner !== undefined && !isName(t.owner)) errs.push(`${t.id}: owner must be a name`);
104
+ else if (t.owner !== undefined && Array.isArray(owners) && !owners.includes(t.owner)) errs.push(`${t.id}: owner ${t.owner} is not in defaults.owners (${owners.join(', ')})`);
105
+ if (!(Array.isArray(t.scope?.allow) && t.scope.allow.every((g) => typeof g === 'string' && g))) errs.push(`${t.id}: scope.allow must be a list of path patterns`);
106
+ if (!t.criteria?.length) errs.push(`${t.id}: no acceptance criteria`);
107
+ for (const a of t.criteria || []) {
108
+ const v = a.verifier;
109
+ if (!v || !VERIFIERS.includes(v.type)) { errs.push(`${t.id}/${a.id}: needs a deterministic verifier (one of ${VERIFIERS.join(', ')})`); continue; }
110
+ const need = { command: ['cmd'], test_red_green: ['test_file', 'cmd'], test_exists: ['test_file'], metric: ['cmd', 'key', 'op', 'value'], grep: ['file', 'pattern'], wiring: ['entry', 'symbol'], docs: ['cmd', 'paths'], file_exists: ['path'] }[v.type] || [];
111
+ for (const k of need) if (v[k] === undefined) errs.push(`${t.id}/${a.id}: ${v.type} verifier missing "${k}"`);
112
+ if (v.type === 'metric' && !['<', '<=', '>', '>=', '=='].includes(v.op)) errs.push(`${t.id}/${a.id}: bad op ${v.op}`);
113
+ }
114
+ }
115
+ for (const t of c.tasks || []) for (const d of t.depends_on || []) if (!ids.has(d)) errs.push(`${t.id}: depends on unknown ${d}`);
116
+ if (topo(c.tasks || []) === null) errs.push('dependency cycle in task graph');
117
+ return errs;
118
+ }
119
+
120
+ export function topo(tasks) {
121
+ const by = new Map(tasks.map((t) => [t.id, t])); const out = []; const st = new Map();
122
+ const visit = (id) => {
123
+ if (st.get(id) === 2) return true; if (st.get(id) === 1) return false;
124
+ st.set(id, 1);
125
+ for (const d of by.get(id)?.depends_on || []) if (by.has(d) && !visit(d)) return false;
126
+ st.set(id, 2); out.push(by.get(id)); return true;
127
+ };
128
+ // stable: phase order, then declaration order
129
+ const ordered = [...tasks].sort((a, b) => PHASES.indexOf(a.phase) - PHASES.indexOf(b.phase));
130
+ for (const t of ordered) if (!visit(t.id)) return null;
131
+ return out;
132
+ }
133
+
134
+ const looser = (op, before, after) => (op.startsWith('<') ? after > before : op.startsWith('>') ? after < before : after !== before);
135
+
136
+ /** Compare a base contract to the current one: anything weakened needs the owner. */
137
+ export function weakenings(base, cur) {
138
+ if (!base) return [];
139
+ const out = [];
140
+ const bt = new Map((base.tasks || []).map((t) => [t.id, t]));
141
+ for (const [id, b] of bt) {
142
+ const t = (cur.tasks || []).find((x) => x.id === id);
143
+ if (!t) { out.push(`task ${id} removed from contract`); continue; }
144
+ for (const a of b.criteria || []) {
145
+ const n = t.criteria.find((x) => x.id === a.id);
146
+ if (!n) { out.push(`${id}/${a.id} criterion removed`); continue; }
147
+ if (a.mandatory && !n.mandatory) out.push(`${id}/${a.id} made non-mandatory`);
148
+ if (a.verifier?.type !== n.verifier?.type) out.push(`${id}/${a.id} verifier type changed ${a.verifier?.type}→${n.verifier?.type}`);
149
+ if (a.verifier?.type === 'metric' && (a.verifier.op !== n.verifier.op || looser(a.verifier.op, a.verifier.value, n.verifier.value)))
150
+ out.push(`${id}/${a.id} threshold loosened ${a.verifier.key} ${a.verifier.op} ${a.verifier.value} → ${n.verifier.op} ${n.verifier.value}`);
151
+ if (a.verifier?.cmd && n.verifier?.cmd && a.verifier.cmd !== n.verifier.cmd) out.push(`${id}/${a.id} command changed: "${a.verifier.cmd}" → "${n.verifier.cmd}"`);
152
+ }
153
+ }
154
+ const bl = base.defaults?.max_load_per_cpu, cl = cur.defaults?.max_load_per_cpu;
155
+ if (typeof bl === 'number' && cl === undefined) out.push(`defaults.max_load_per_cpu removed (was ${bl})`);
156
+ else if (typeof bl === 'number' && !(cl <= bl)) out.push(`defaults.max_load_per_cpu raised ${bl} → ${cl}`);
157
+ const bf = base.forbidden || {}; const cf = cur.forbidden || {};
158
+ for (const k of Object.keys(bf)) if (bf[k] && (cf[k] === false || cf[k] === undefined)) out.push(`forbidden rule ${k} disabled`);
159
+ for (const e of bf.bypass_env || []) if (!(cf.bypass_env || []).includes(e)) out.push(`bypass env ${e} removed from forbidden list`);
160
+ return out;
161
+ }
162
+
163
+ export function loadFile(f) { return JSON.parse(fs.readFileSync(f, 'utf8')); }