@optitransfer/proofline 0.0.0-stage → 0.2.0-next.1
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/AGENTS.md +32 -0
- package/CHANGELOG.md +56 -0
- package/CONTRIBUTING.md +29 -0
- package/LICENSE +154 -0
- package/NOTICE +10 -0
- package/PATENTS-NONASSERT.md +1 -0
- package/README.md +285 -2
- package/bin/proofline.mjs +292 -0
- package/docs/00-index.md +43 -0
- package/docs/01-scope.md +70 -0
- package/docs/02-requirements.md +64 -0
- package/docs/03-domain-model.md +43 -0
- package/docs/04-architecture.md +79 -0
- package/docs/05-lifecycle.md +47 -0
- package/docs/06-verification.md +57 -0
- package/docs/07-evidence.md +69 -0
- package/docs/08-adapters.md +61 -0
- package/docs/09-security.md +64 -0
- package/docs/10-conformance.md +59 -0
- package/docs/11-development-pipeline.md +50 -0
- package/docs/12-roadmap.md +104 -0
- package/docs/13-interdependencies.md +53 -0
- package/docs/14-metrics.md +40 -0
- package/docs/adr/0001-product-boundary.md +28 -0
- package/docs/adr/0002-evidence-and-gates.md +22 -0
- package/docs/adr/0003-standalone-first.md +18 -0
- package/docs/receipt.md +25 -0
- package/package.json +60 -4
- package/src/cache.mjs +81 -0
- package/src/checks.mjs +276 -0
- package/src/claims.mjs +60 -0
- package/src/contract.mjs +404 -0
- package/src/engine.mjs +378 -0
- package/src/evidence.mjs +45 -0
- package/src/forbidden.mjs +57 -0
- package/src/guided.mjs +240 -0
- package/src/hook-stop.mjs +105 -0
- package/src/index.mjs +431 -0
- package/src/infer.mjs +173 -0
- package/src/inference.json +20 -0
- package/src/isolate.mjs +123 -0
- package/src/mcp.mjs +70 -0
- package/src/receipt.mjs +17 -0
- package/src/signature.mjs +110 -0
- package/src/util.mjs +92 -0
- package/src/vault.mjs +109 -0
- package/src/verify-receipt.mjs +109 -0
package/src/index.mjs
ADDED
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
// Public API used by the CLI and the MCP server.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import * as C from './contract.mjs';
|
|
6
|
+
import * as E from './engine.mjs';
|
|
7
|
+
import { record, readLog, verifyChain } from './evidence.mjs';
|
|
8
|
+
import { scanForbidden } from './forbidden.mjs';
|
|
9
|
+
import { spawnSync } from 'node:child_process';
|
|
10
|
+
import { git, readJson, approvalMarker } from './util.mjs';
|
|
11
|
+
import { resolveBase } from './checks.mjs';
|
|
12
|
+
|
|
13
|
+
export { C, E };
|
|
14
|
+
|
|
15
|
+
export function findRoot(start = process.cwd()) {
|
|
16
|
+
const r = git(['rev-parse', '--show-toplevel'], start);
|
|
17
|
+
return r.ok ? r.out : start;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function init(root, { hooks = true, project, approvals } = {}) {
|
|
21
|
+
const made = [];
|
|
22
|
+
if (!fs.existsSync(C.contractPath(root))) { C.save(root, C.skeleton(project || path.basename(root))); made.push('proofline.json'); }
|
|
23
|
+
fs.mkdirSync(path.join(root, '.proofline'), { recursive: true });
|
|
24
|
+
if (approvals !== undefined) {
|
|
25
|
+
if (typeof approvals !== 'string' || !approvals.trim()) throw new Error('--approvals needs a directory');
|
|
26
|
+
const cfg = path.join(root, '.proofline', 'config.json');
|
|
27
|
+
let cur = {}; try { cur = JSON.parse(fs.readFileSync(cfg, 'utf8')); } catch { cur = {}; }
|
|
28
|
+
cur.approvals = path.resolve(approvals);
|
|
29
|
+
fs.writeFileSync(cfg, JSON.stringify(cur, null, 2) + '\n');
|
|
30
|
+
made.push('.proofline/config.json');
|
|
31
|
+
}
|
|
32
|
+
const gi = path.join(root, '.gitignore');
|
|
33
|
+
const cur = fs.existsSync(gi) ? fs.readFileSync(gi, 'utf8') : '';
|
|
34
|
+
if (!/^\.proofline\/?$/m.test(cur)) { fs.appendFileSync(gi, (cur && !cur.endsWith('\n') ? '\n' : '') + '.proofline/\n'); made.push('.gitignore'); }
|
|
35
|
+
if (hooks) made.push(...installHooks(root));
|
|
36
|
+
record(root, 'init', { made });
|
|
37
|
+
return { root, made };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const HOOK_MARK = '# installed by proofline';
|
|
41
|
+
export const PREV_SUFFIX = '.proofline-prev';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The hook script. A hook that was there before init is kept as <hook>.proofline-prev and runs first,
|
|
45
|
+
* with the same arguments and stdin; if it fails, its exit code stands and the gate does not run.
|
|
46
|
+
* No bypass variable is read here, by design.
|
|
47
|
+
*/
|
|
48
|
+
export function hookScript(h, node = process.execPath, bin = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..', 'bin', 'proofline.mjs')) {
|
|
49
|
+
const gate = `"${node}" "${bin}" hook ${h} "$@"`;
|
|
50
|
+
return [
|
|
51
|
+
'#!/bin/sh',
|
|
52
|
+
`${HOOK_MARK}; there is no skip/bypass variable`,
|
|
53
|
+
`# If a ${h} hook was here before init, it was kept as ${h}${PREV_SUFFIX}: it runs first, and its refusal stands.`,
|
|
54
|
+
`prev="$(dirname "$0")/${h}${PREV_SUFFIX}"`,
|
|
55
|
+
'if [ -x "$prev" ]; then',
|
|
56
|
+
' input="$(mktemp)" || exit 1',
|
|
57
|
+
` trap 'rm -f "$input"' EXIT`,
|
|
58
|
+
' cat > "$input"',
|
|
59
|
+
` "$prev" "$@" < "$input" || { rc=$?; echo "proofline: ${h}${PREV_SUFFIX} refused (exit $rc)" >&2; exit $rc; }`,
|
|
60
|
+
` ${gate} < "$input"`,
|
|
61
|
+
' exit $?',
|
|
62
|
+
'fi',
|
|
63
|
+
`exec ${gate}`,
|
|
64
|
+
'',
|
|
65
|
+
].join('\n');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function installHooks(root) {
|
|
69
|
+
const hd = git(['rev-parse', '--git-path', 'hooks'], root);
|
|
70
|
+
if (!hd.ok) return [];
|
|
71
|
+
const dir = path.resolve(root, hd.out);
|
|
72
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
73
|
+
const out = [];
|
|
74
|
+
for (const h of ['pre-commit', 'pre-push']) {
|
|
75
|
+
const f = path.join(dir, h), prev = f + PREV_SUFFIX;
|
|
76
|
+
let cur = null; try { cur = fs.readFileSync(f, 'utf8'); } catch {}
|
|
77
|
+
if (cur !== null && !cur.includes(HOOK_MARK)) {
|
|
78
|
+
// Never lose a hook: if both slots are taken by foreign hooks, stop and let the owner merge them.
|
|
79
|
+
if (fs.existsSync(prev)) throw new Error(`${f} and ${prev} both exist and neither is proofline's; merge them into one, then run init again`);
|
|
80
|
+
fs.renameSync(f, prev);
|
|
81
|
+
out.push(`hooks/${h}${PREV_SUFFIX} (the existing ${h} hook, kept; it runs before the proofline gate)`);
|
|
82
|
+
}
|
|
83
|
+
fs.writeFileSync(f, hookScript(h), { mode: 0o755 });
|
|
84
|
+
fs.chmodSync(f, 0o755);
|
|
85
|
+
out.push(`hooks/${h}`);
|
|
86
|
+
}
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function contractCreate(root, spec) {
|
|
91
|
+
const c = C.load(root);
|
|
92
|
+
if (c.tasks.some((t) => t.id === spec.id)) throw new Error(`task ${spec.id} exists; contracts are amended by the owner, not overwritten`);
|
|
93
|
+
const t = C.makeTask(spec);
|
|
94
|
+
c.tasks.push(t);
|
|
95
|
+
const errs = C.validate(c);
|
|
96
|
+
C.save(root, c);
|
|
97
|
+
record(root, 'contract_task_added', { task: t.id, hash: C.contractHash(c), valid: !errs.length });
|
|
98
|
+
return { task: t, valid: errs.length === 0, problems: errs };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function taskNext(root, { owner, base: explicitBase } = {}) {
|
|
102
|
+
const settled = settleLanded(root);
|
|
103
|
+
let c;
|
|
104
|
+
try { c = C.load(root); } catch { c = { tasks: [] }; }
|
|
105
|
+
const n = E.next(root, { owner, skip: c.tasks.map((t) => t.id).filter((id) => landedWhy(root, id)) });
|
|
106
|
+
if (n.task && (n.status === 'not_started' || explicitBase)) {
|
|
107
|
+
const loaded = C.load(root);
|
|
108
|
+
let base;
|
|
109
|
+
if (explicitBase) {
|
|
110
|
+
base = resolveBase(root, explicitBase);
|
|
111
|
+
if (!base) throw new Error(`base revision "${explicitBase}" not found`);
|
|
112
|
+
} else base = resolveBase(root, C.defaultBase(loaded)) || git(['rev-parse', 'HEAD'], root).out;
|
|
113
|
+
record(root, 'task_started', { task: n.task, base, ...(owner ? { owner } : {}) }); n.status = 'in_progress';
|
|
114
|
+
}
|
|
115
|
+
if (n.task) {
|
|
116
|
+
fs.writeFileSync(path.join(root, '.proofline', 'active'), n.task + '\n');
|
|
117
|
+
n.base = E.latestStart(root, n.task);
|
|
118
|
+
if (n.base) n.brief += `\n\n## Base\nPinned at start: ${n.base}. The pin lives in the local .proofline/ log; in another clone, gate with \`--base ${n.base}\`, or end the task commit message with the trailer \`Proofline-Base: ${n.base}\`.`;
|
|
119
|
+
}
|
|
120
|
+
if (settled) {
|
|
121
|
+
n.landed = settled.message;
|
|
122
|
+
if (n.brief) n.brief = `${settled.message}\n\n${n.brief}`;
|
|
123
|
+
else n.message = n.message ? `${settled.message}\n\n${n.message}` : settled.message;
|
|
124
|
+
}
|
|
125
|
+
return n;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export const activeTask = (root) => { try { return fs.readFileSync(path.join(root, '.proofline', 'active'), 'utf8').trim() || null; } catch { return null; } };
|
|
129
|
+
|
|
130
|
+
const activePath = (root) => path.join(root, '.proofline', 'active');
|
|
131
|
+
|
|
132
|
+
/** Task id named by a commit message: a (TASK-ID) subject suffix, else a Proofline-Task trailer. */
|
|
133
|
+
export function taskFromMessage(body, known) {
|
|
134
|
+
const subject = String(body || '').split('\n')[0];
|
|
135
|
+
const m = subject.match(/\(([A-Za-z][A-Za-z0-9-]*)\)\s*$/);
|
|
136
|
+
if (m && known.has(m[1])) return m[1];
|
|
137
|
+
const last = String(body || '').trim().split(/\n[ \t]*\n/).pop() || '';
|
|
138
|
+
for (const line of last.split('\n')) {
|
|
139
|
+
const t = line.match(/^Proofline-Task:[ \t]*(\S+)\s*$/);
|
|
140
|
+
if (t && known.has(t[1])) return t[1];
|
|
141
|
+
}
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Newest commit on origin/main that names `id`, or null. */
|
|
146
|
+
function namedHeadOnMain(root, id) {
|
|
147
|
+
const log = git(['log', 'origin/main', '-z', '--format=%H%x1f%B'], root);
|
|
148
|
+
if (!log.ok || !log.out) return null;
|
|
149
|
+
const known = new Set([id]);
|
|
150
|
+
for (const rec of log.out.split('\0')) {
|
|
151
|
+
const i = rec.indexOf('\x1f');
|
|
152
|
+
if (i < 0) continue;
|
|
153
|
+
const sha = rec.slice(0, i).trim();
|
|
154
|
+
if (taskFromMessage(rec.slice(i + 1), known)) return sha;
|
|
155
|
+
}
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* A pushed criterion PASS in the latest report.
|
|
161
|
+
* A PASS recorded at an older HEAD is stale once the task has new commits, so it does not count as landed.
|
|
162
|
+
* A report with no head (a recorded PASS and nothing else) still counts.
|
|
163
|
+
*/
|
|
164
|
+
function pushedPass(root, id) {
|
|
165
|
+
let head = null;
|
|
166
|
+
let pass = false;
|
|
167
|
+
try {
|
|
168
|
+
const j = JSON.parse(fs.readFileSync(path.join(root, '.proofline', 'reports', `${id}-latest.json`), 'utf8'));
|
|
169
|
+
pass = (j.results || []).some((r) => r.type === 'pushed' && r.status === 'pass');
|
|
170
|
+
if (typeof j.head === 'string' && /^[0-9a-f]{40}$/.test(j.head)) head = j.head;
|
|
171
|
+
} catch { return false; }
|
|
172
|
+
if (!pass) return false;
|
|
173
|
+
if (!head) {
|
|
174
|
+
const log = readLog(root);
|
|
175
|
+
for (let i = log.length - 1; i >= 0; i--) {
|
|
176
|
+
const e = log[i];
|
|
177
|
+
if (e.type === 'gate' && e.data?.task === id && /^[0-9a-f]{40}$/.test(e.data.head || '')) { head = e.data.head; break; }
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (!head) return true;
|
|
181
|
+
return git(['rev-parse', 'HEAD'], root).out === head;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Why an active task has landed, or null. Does not touch the active file. */
|
|
185
|
+
export function landedWhy(root, id) {
|
|
186
|
+
let c;
|
|
187
|
+
try { c = C.load(root); } catch { return null; }
|
|
188
|
+
if (!c.tasks?.some((t) => t.id === id)) return null;
|
|
189
|
+
const sha = namedHeadOnMain(root, id);
|
|
190
|
+
if (sha) return { kind: 'head', sha };
|
|
191
|
+
if (pushedPass(root, id)) return { kind: 'pushed' };
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function landedMessage(id, why) {
|
|
196
|
+
if (why.kind === 'head') return `${id} landed: its head ${why.sha.slice(0, 8)} is an ancestor of origin/main. Cleared .proofline/active.`;
|
|
197
|
+
return `${id} landed: its pushed criterion has a PASS record. Cleared .proofline/active.`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Clear .proofline/active when that task has landed. Returns the sentence to report, or null. */
|
|
201
|
+
export function settleLanded(root) {
|
|
202
|
+
const id = activeTask(root);
|
|
203
|
+
if (!id) return null;
|
|
204
|
+
const why = landedWhy(root, id);
|
|
205
|
+
if (!why) return null;
|
|
206
|
+
fs.rmSync(activePath(root), { force: true });
|
|
207
|
+
return { task: id, message: landedMessage(id, why) };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Server default-branch head for this push URL, from `git ls-remote` now. Never reads refs/remotes. */
|
|
211
|
+
function serverDefaultHead(root, pushUrl) {
|
|
212
|
+
if (typeof pushUrl !== 'string' || !pushUrl.trim()) return { ok: false };
|
|
213
|
+
const ls = git(['ls-remote', pushUrl, 'HEAD'], root);
|
|
214
|
+
if (!ls.ok) return { ok: false };
|
|
215
|
+
const sha = (ls.out.split(/\s+/)[0] || '');
|
|
216
|
+
if (!sha) return { ok: true, sha: '' };
|
|
217
|
+
if (!/^[0-9a-f]{40}$/i.test(sha)) return { ok: false };
|
|
218
|
+
return { ok: true, sha };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function commitsOnPushLine(root, line, pushUrl) {
|
|
222
|
+
const p = line.trim().split(/\s+/);
|
|
223
|
+
if (p.length < 4) return [];
|
|
224
|
+
const local = p[1];
|
|
225
|
+
const remote = p[3];
|
|
226
|
+
if (!/^[0-9a-f]{40}$/i.test(local) || /^0+$/.test(local)) return [];
|
|
227
|
+
// Existing branch: remote..local. New branch: every commit reachable from local.
|
|
228
|
+
// Already sent means reachable from the server default-branch head. A local
|
|
229
|
+
// remote-tracking ref is not that head. An unreadable server gates the whole range.
|
|
230
|
+
const range = /^0+$/.test(remote) ? [local] : [`${remote}..${local}`];
|
|
231
|
+
const listed = git(['rev-list', ...range], root);
|
|
232
|
+
if (!listed.ok) return [];
|
|
233
|
+
const all = listed.out ? listed.out.split('\n').filter(Boolean) : [];
|
|
234
|
+
const server = serverDefaultHead(root, pushUrl);
|
|
235
|
+
if (!server.ok || !server.sha) return all;
|
|
236
|
+
if (!git(['cat-file', '-e', `${server.sha}^{commit}`], root).ok) return all;
|
|
237
|
+
const filtered = git(['rev-list', ...range, '--not', server.sha], root);
|
|
238
|
+
if (!filtered.ok) return all;
|
|
239
|
+
return filtered.out ? filtered.out.split('\n').filter(Boolean) : [];
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function evidenceRecord(root, type, data, actor = 'agent') {
|
|
243
|
+
// Agent-recorded evidence is stored as a claim-class event; it is never used to pass a gate.
|
|
244
|
+
return record(root, `agent_${type}`, data, { actor });
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Task and phase status; with `owner`, that owner's tasks and unowned ones (phases counted over them). */
|
|
248
|
+
export function status(root, { owner } = {}) {
|
|
249
|
+
const all = C.load(root);
|
|
250
|
+
C.checkOwner(all, owner);
|
|
251
|
+
const c = owner ? { ...all, tasks: all.tasks.filter((t) => C.inLane(t, owner)) } : all;
|
|
252
|
+
return { project: c.project, ...(owner ? { owner } : {}), tasks: E.taskStatus(root, c), phases: E.phaseStatus(root, c), chain: verifyChain(root), active: activeTask(root) };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Brief for one task, with the base verify and gate will use (`base`: as --base); with `owner`, another owner's task is refused. */
|
|
256
|
+
export function taskBrief(root, id, { owner, base } = {}) {
|
|
257
|
+
const c = C.load(root);
|
|
258
|
+
C.checkOwner(c, owner);
|
|
259
|
+
const t = c.tasks.find((x) => x.id === id);
|
|
260
|
+
if (!t) throw new Error(`unknown task ${id}`);
|
|
261
|
+
if (!C.inLane(t, owner)) throw new Error(`task ${id} is owned by ${t.owner}, not ${owner}`);
|
|
262
|
+
return E.brief(c, t) + '\n\n' + E.baseBrief(root, c, t, base);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function report(root, taskId) {
|
|
266
|
+
const f = path.join(root, '.proofline', 'reports', `${taskId}-latest.md`);
|
|
267
|
+
if (!fs.existsSync(f)) return { error: `no gate report for ${taskId}; run gate first` };
|
|
268
|
+
return { markdown: fs.readFileSync(f, 'utf8'), json: readJson(path.join(root, '.proofline', 'reports', `${taskId}-latest.json`)) };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Bytes of the staged proofline.json, or null when it is not in the index. */
|
|
272
|
+
function stagedContract(root) {
|
|
273
|
+
const r = spawnSync('git', ['cat-file', 'blob', ':proofline.json'], { cwd: root, maxBuffer: 64 << 20 });
|
|
274
|
+
return r.status === 0 ? r.stdout : null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Directory from `init --approvals`, stored in .proofline/config.json. Null when unset. */
|
|
278
|
+
export function approvalsDir(root) {
|
|
279
|
+
try {
|
|
280
|
+
const c = JSON.parse(fs.readFileSync(path.join(root, '.proofline', 'config.json'), 'utf8'));
|
|
281
|
+
return typeof c.approvals === 'string' && c.approvals.trim() ? c.approvals : null;
|
|
282
|
+
} catch { return null; }
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** pre-commit: forbidden actions + contract weakening of the staged contract vs HEAD. Fast.
|
|
286
|
+
* A weakening passes only when an approval marker named with the sha256 of those bytes exists.
|
|
287
|
+
* What else is staged does not matter. There is no bypass. */
|
|
288
|
+
export function hookPreCommit(root) {
|
|
289
|
+
// Do not clear here. The commit under way is not HEAD yet, so a PASS recorded at the
|
|
290
|
+
// current head would look landed and the active task would be dropped before its own commit.
|
|
291
|
+
const c = C.load(root);
|
|
292
|
+
const head = git(['rev-parse', '--verify', '--quiet', 'HEAD'], root);
|
|
293
|
+
if (!head.ok) return { ok: true, msg: 'first commit' };
|
|
294
|
+
const f = scanForbidden(root, 'HEAD', { ...C.DEFAULT_FORBIDDEN, ...(c.forbidden || {}) });
|
|
295
|
+
const staged = stagedContract(root);
|
|
296
|
+
let w = [];
|
|
297
|
+
let note = '';
|
|
298
|
+
if (staged) {
|
|
299
|
+
let cur; try { cur = JSON.parse(staged.toString('utf8')); } catch { return { ok: false, msg: 'staged proofline.json is not valid JSON' }; }
|
|
300
|
+
let base = null; const s = git(['show', 'HEAD:proofline.json'], root); if (s.ok) try { base = JSON.parse(s.out); } catch {}
|
|
301
|
+
w = C.weakenings(base, cur);
|
|
302
|
+
const fields = base && cur ? C.contractFieldChanges(base, cur) : [];
|
|
303
|
+
if (w.length || fields.length) {
|
|
304
|
+
// Same check as verify and gate: a regular file named with the sha256 of these bytes.
|
|
305
|
+
const mark = approvalMarker(root, staged);
|
|
306
|
+
if (mark.approved) w = [];
|
|
307
|
+
else {
|
|
308
|
+
note = [`sha256 ${mark.hex}`, mark.marker ? `marker ${mark.marker}` : 'marker (no approvals directory configured)'].join('\n');
|
|
309
|
+
// One explanation line. A non-weakening edit is still the hash refusal.
|
|
310
|
+
w = [w.length ? w.join('; ') : 'canonical bytes differ from HEAD'];
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
} else if (git(['cat-file', '-e', 'HEAD:proofline.json'], root).ok) {
|
|
314
|
+
// Removed from the index. There are no staged bytes, so no marker name can approve the removal.
|
|
315
|
+
let base = null; const s = git(['show', 'HEAD:proofline.json'], root); if (s.ok) try { base = JSON.parse(s.out); } catch {}
|
|
316
|
+
w = C.weakenings(base, { tasks: [] });
|
|
317
|
+
note = 'staged proofline.json is absent, so no approval marker can be bound to its bytes';
|
|
318
|
+
}
|
|
319
|
+
const problems = [...f.map((x) => `forbidden ${x.rule}: ${x.file}: ${x.line} (${x.why})`), ...w.map((x) => `contract weakened: ${x}`), ...(note ? [note] : [])];
|
|
320
|
+
return { ok: problems.length === 0, msg: problems.join('\n') };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const GIT_ENV = ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_PREFIX', 'GIT_COMMON_DIR', 'GIT_OBJECT_DIRECTORY', 'GIT_NAMESPACE', 'GIT_QUARANTINE_PATH'];
|
|
324
|
+
|
|
325
|
+
/** A pre-push hook exports GIT_DIR. Drop it so the gate sees the temporary worktree, then put it back. */
|
|
326
|
+
function withoutGitEnv(fn) {
|
|
327
|
+
const saved = {};
|
|
328
|
+
for (const k of GIT_ENV) {
|
|
329
|
+
if (Object.prototype.hasOwnProperty.call(process.env, k)) { saved[k] = process.env[k]; delete process.env[k]; }
|
|
330
|
+
}
|
|
331
|
+
try { return fn(); }
|
|
332
|
+
finally {
|
|
333
|
+
for (const k of GIT_ENV) {
|
|
334
|
+
if (Object.prototype.hasOwnProperty.call(saved, k)) process.env[k] = saved[k];
|
|
335
|
+
else delete process.env[k];
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Gate `id` at `sha` in a temporary worktree. The base is the most recent start pin in `root`,
|
|
342
|
+
* otherwise the parent of `sha`. The refusal names that base. Stale evidence in `root` is not copied.
|
|
343
|
+
*/
|
|
344
|
+
function gateAtCommit(root, sha, id) {
|
|
345
|
+
const pinned = E.latestStart(root, id);
|
|
346
|
+
const parent = git(['rev-parse', '--verify', '--quiet', `${sha}^`], root);
|
|
347
|
+
const baseRef = pinned || (parent.ok ? parent.out : null);
|
|
348
|
+
const named = baseRef || 'none';
|
|
349
|
+
if (!baseRef) return { ok: false, msg: `${id} at ${sha}: no base\nbase ${named}` };
|
|
350
|
+
return withoutGitEnv(() => {
|
|
351
|
+
const wt = fs.mkdtempSync(path.join(os.tmpdir(), 'proofline-push-'));
|
|
352
|
+
let added = false;
|
|
353
|
+
try {
|
|
354
|
+
const add = git(['worktree', 'add', '--detach', '--force', wt, sha], root);
|
|
355
|
+
if (!add.ok) return { ok: false, msg: `${id} at ${sha}: worktree failed: ${add.err}\nbase ${baseRef}` };
|
|
356
|
+
added = true;
|
|
357
|
+
let g;
|
|
358
|
+
try { g = E.gate(wt, id, { skipTypes: ['pushed'], base: baseRef }); }
|
|
359
|
+
catch (e) { return { ok: false, msg: `${id} at ${sha}: ${e.message}\nbase ${baseRef}` }; }
|
|
360
|
+
const used = g.base || baseRef;
|
|
361
|
+
if (g.decision !== 'PASS') return { ok: false, msg: `${id} at ${sha}: ${g.decision}\nbase ${used}\n${g.corrective_prompt}` };
|
|
362
|
+
return { ok: true, msg: `gated ${id} at ${sha}\nbase ${used}` };
|
|
363
|
+
} finally {
|
|
364
|
+
if (added) git(['worktree', 'remove', '--force', wt], root);
|
|
365
|
+
fs.rmSync(wt, { recursive: true, force: true });
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* pre-push. A push is gated on the commit being pushed: the local sha from stdin, at that sha,
|
|
372
|
+
* not the checked-out HEAD. The task is the (TASK-ID) subject suffix or a Proofline-Task trailer.
|
|
373
|
+
* A commit that names none falls back to the active task. No active task and no named task is a refusal
|
|
374
|
+
* that prints `proofline next`. An active task that has landed is cleared and never gated.
|
|
375
|
+
* With no push lines (a direct call), the previous rule stands: the active task, else every in-progress or failed task.
|
|
376
|
+
*/
|
|
377
|
+
export function hookPrePush(root, { lines, pushUrl } = {}) {
|
|
378
|
+
// stdin lines are `<local ref> <local sha> <remote ref> <remote sha>`. The gated revision is that sha.
|
|
379
|
+
if (lines == null) {
|
|
380
|
+
let raw = '';
|
|
381
|
+
try { if (!process.stdin.isTTY) raw = fs.readFileSync(0, 'utf8'); } catch { raw = ''; }
|
|
382
|
+
lines = raw.split('\n').filter((l) => l.trim());
|
|
383
|
+
}
|
|
384
|
+
const settled = settleLanded(root);
|
|
385
|
+
const c = C.load(root);
|
|
386
|
+
const errs = C.validate(c);
|
|
387
|
+
const note = settled?.message || '';
|
|
388
|
+
const join = (...parts) => parts.flat().filter(Boolean).join('\n\n');
|
|
389
|
+
if (errs.length) return { ok: false, msg: join(note, 'contract invalid:\n' + errs.join('\n')) };
|
|
390
|
+
const haveLines = Array.isArray(lines) && lines.some((l) => l.trim());
|
|
391
|
+
if (haveLines) {
|
|
392
|
+
const known = new Set(c.tasks.map((t) => t.id));
|
|
393
|
+
const commits = [...new Set(lines.flatMap((l) => commitsOnPushLine(root, l, pushUrl)))];
|
|
394
|
+
if (commits.length) {
|
|
395
|
+
const active = activeTask(root);
|
|
396
|
+
const activeLive = active && !landedWhy(root, active) ? active : null;
|
|
397
|
+
const jobs = [];
|
|
398
|
+
for (const sha of commits) {
|
|
399
|
+
const body = git(['log', '-1', '--format=%B', sha], root).out || '';
|
|
400
|
+
const id = taskFromMessage(body, known);
|
|
401
|
+
if (id) jobs.push({ sha, id });
|
|
402
|
+
else if (activeLive) jobs.push({ sha, id: activeLive });
|
|
403
|
+
}
|
|
404
|
+
if (!jobs.length) {
|
|
405
|
+
const started = readLog(root).some((e) => e.type === 'task_started');
|
|
406
|
+
if (!started) return { ok: true, msg: join(note, 'No task has a start record. Next step: proofline next') };
|
|
407
|
+
return { ok: false, msg: join(note, 'no active task and no commit names a task.\nNext step: proofline next') };
|
|
408
|
+
}
|
|
409
|
+
const bad = [];
|
|
410
|
+
const good = [];
|
|
411
|
+
for (const job of jobs) {
|
|
412
|
+
if (!c.tasks.some((t) => t.id === job.id)) continue;
|
|
413
|
+
const g = gateAtCommit(root, job.sha, job.id);
|
|
414
|
+
if (!g.ok) bad.push(g.msg);
|
|
415
|
+
else if (g.msg) good.push(g.msg);
|
|
416
|
+
}
|
|
417
|
+
return { ok: bad.length === 0, msg: join(note, bad.length ? bad.join('\n\n') : good.join('\n\n')) };
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
const st = E.taskStatus(root, c);
|
|
421
|
+
const active = activeTask(root);
|
|
422
|
+
let ids = active ? [active] : Object.keys(st).filter((k) => st[k].status === 'in_progress' || st[k].status === 'failed');
|
|
423
|
+
ids = ids.filter((id) => !landedWhy(root, id));
|
|
424
|
+
const out = [];
|
|
425
|
+
for (const id of ids) {
|
|
426
|
+
if (!c.tasks.some((t) => t.id === id)) continue;
|
|
427
|
+
const g = E.gate(root, id, { skipTypes: ['pushed'] });
|
|
428
|
+
if (g.decision !== 'PASS') out.push(`${id}: ${g.decision}\n${g.corrective_prompt}`);
|
|
429
|
+
}
|
|
430
|
+
return { ok: out.length === 0, msg: join(note, out.join('\n\n')) };
|
|
431
|
+
}
|
package/src/infer.mjs
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// Zero-config suite inference. Commands come from the base revision, never from the change under test.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
import { git, run } from './util.mjs';
|
|
7
|
+
|
|
8
|
+
const RULES = JSON.parse(fs.readFileSync(new URL('./inference.json', import.meta.url), 'utf8'));
|
|
9
|
+
const CHANGED = RULES.changed_tests.map((s) => new RegExp(s));
|
|
10
|
+
|
|
11
|
+
export function inferenceRules() { return RULES; }
|
|
12
|
+
|
|
13
|
+
export function isChangedTest(file) {
|
|
14
|
+
return CHANGED.some((re) => re.test(file));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function rel(dir, name) { return dir ? `${dir}/${name}` : name; }
|
|
18
|
+
|
|
19
|
+
function hasAt(root, base, file) {
|
|
20
|
+
return git(['cat-file', '-e', `${base}:${file}`], root).ok;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function show(root, base, file) {
|
|
24
|
+
const s = git(['show', `${base}:${file}`], root);
|
|
25
|
+
return s.ok ? s.out : null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function readWorktree(root, file) {
|
|
29
|
+
try { return fs.readFileSync(path.join(root, file), 'utf8'); } catch { return null; }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function nodeCommand(pkg) {
|
|
33
|
+
const script = pkg?.scripts?.test;
|
|
34
|
+
if (typeof script === 'string' && script.trim()) return script.trim();
|
|
35
|
+
const dev = pkg?.devDependencies || {};
|
|
36
|
+
if (dev.vitest) return RULES.node.fallback_commands.vitest;
|
|
37
|
+
if (dev.jest) return RULES.node.fallback_commands.jest;
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function toolchainOk(bin) {
|
|
42
|
+
if (!bin) return true;
|
|
43
|
+
const r = spawnSync('bash', ['-lc', `command -v ${bin}`], { encoding: 'utf8' });
|
|
44
|
+
return r.status === 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function parseJson(text) {
|
|
48
|
+
if (!text) return null;
|
|
49
|
+
try { return JSON.parse(text); } catch { return null; }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Suites declared by the manifests at `base`. `dir` is a repo-relative directory. */
|
|
53
|
+
export function suitesFromBase(root, base, dir = '') {
|
|
54
|
+
const out = [];
|
|
55
|
+
const pkgPath = rel(dir, 'package.json');
|
|
56
|
+
if (hasAt(root, base, pkgPath)) {
|
|
57
|
+
const command = nodeCommand(parseJson(show(root, base, pkgPath)));
|
|
58
|
+
if (command) out.push({ id: 'node', manifest: pkgPath, command, toolchain: null });
|
|
59
|
+
}
|
|
60
|
+
const cargo = rel(dir, 'Cargo.toml');
|
|
61
|
+
if (hasAt(root, base, cargo)) out.push({ id: 'rust', manifest: cargo, command: RULES.rust.command, toolchain: RULES.rust.toolchain });
|
|
62
|
+
const pyIni = rel(dir, 'pytest.ini');
|
|
63
|
+
const pyProj = rel(dir, 'pyproject.toml');
|
|
64
|
+
const pyText = hasAt(root, base, pyProj) ? show(root, base, pyProj) : null;
|
|
65
|
+
if (hasAt(root, base, pyIni) || (pyText && /\[tool\.pytest/.test(pyText))) {
|
|
66
|
+
out.push({ id: 'python', manifest: hasAt(root, base, pyIni) ? pyIni : pyProj, command: RULES.python.command, toolchain: RULES.python.toolchain });
|
|
67
|
+
}
|
|
68
|
+
const gomod = rel(dir, 'go.mod');
|
|
69
|
+
if (hasAt(root, base, gomod)) out.push({ id: 'go', manifest: gomod, command: RULES.go.command, toolchain: RULES.go.toolchain });
|
|
70
|
+
const gradle = rel(dir, 'build.gradle');
|
|
71
|
+
if (hasAt(root, base, gradle)) out.push({ id: 'gradle', manifest: gradle, command: null, toolchain: null });
|
|
72
|
+
const tree = git(['ls-tree', '--name-only', base, dir || '.'], root);
|
|
73
|
+
if (tree.ok && tree.out.split('\n').some((n) => n.endsWith('.sln'))) {
|
|
74
|
+
const file = tree.out.split('\n').find((n) => n.endsWith('.sln'));
|
|
75
|
+
out.push({ id: 'dotnet', manifest: file, command: null, toolchain: null });
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function changedTests(root, base) {
|
|
81
|
+
const s = git(['diff', '--name-only', base], root);
|
|
82
|
+
const names = s.ok ? s.out.split('\n').filter(Boolean) : [];
|
|
83
|
+
return names.filter(isChangedTest);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function scriptOf(text) {
|
|
87
|
+
const v = parseJson(text)?.scripts?.test;
|
|
88
|
+
return typeof v === 'string' ? v : null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A test-command change against `base`. The command that runs stays the base command. */
|
|
92
|
+
export function testCommandFindings(root, base) {
|
|
93
|
+
const findings = [];
|
|
94
|
+
const push = (file, line) => findings.push({
|
|
95
|
+
rule: 'test_command_changed', file, line,
|
|
96
|
+
why: 'the test command is taken from the base revision',
|
|
97
|
+
});
|
|
98
|
+
const baseScript = scriptOf(show(root, base, 'package.json'));
|
|
99
|
+
const headScript = scriptOf(readWorktree(root, 'package.json'));
|
|
100
|
+
if (baseScript !== headScript && (baseScript !== null || headScript !== null)) {
|
|
101
|
+
push('package.json', `${baseScript ?? ''} -> ${headScript ?? ''}`);
|
|
102
|
+
}
|
|
103
|
+
for (const file of ['Cargo.toml', 'pytest.ini']) {
|
|
104
|
+
const old = show(root, base, file);
|
|
105
|
+
const now = readWorktree(root, file);
|
|
106
|
+
if (old === null && now === null) continue;
|
|
107
|
+
if ((old ?? '').trim() !== (now ?? '').trim()) push(file, 'manifest changed');
|
|
108
|
+
}
|
|
109
|
+
const diff = git(['diff', '--name-only', base, '--', '.github/workflows'], root);
|
|
110
|
+
for (const file of (diff.ok ? diff.out.split('\n').filter(Boolean) : [])) push(file, 'workflow changed');
|
|
111
|
+
return findings;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function runCmd(cwd, command) {
|
|
115
|
+
const r = run(command, { cwd, timeoutS: 180 });
|
|
116
|
+
return { exitCode: r.exit, ran: true };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function runAtBase(root, base, command) {
|
|
120
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pl-infer-'));
|
|
121
|
+
const add = git(['worktree', 'add', '--detach', '--quiet', dir, base], root);
|
|
122
|
+
if (!add.ok) { fs.rmSync(dir, { recursive: true, force: true }); return null; }
|
|
123
|
+
try { return runCmd(dir, command); }
|
|
124
|
+
finally {
|
|
125
|
+
git(['worktree', 'remove', '--force', dir], root);
|
|
126
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Infer a receipt with no contract. Nothing inferable returns L0 and exit 2, and does not throw.
|
|
132
|
+
* A missing toolchain is `blocked`, not `pass`.
|
|
133
|
+
*/
|
|
134
|
+
export function receiptZeroConfig(root, { base } = {}) {
|
|
135
|
+
const l0 = (reason, extra = {}) => ({ level: 'L0', exitCode: 2, reason, suites: [], changed: [], findings: [], ...extra });
|
|
136
|
+
if (!base) return l0('no base revision');
|
|
137
|
+
let suites, changed, findings;
|
|
138
|
+
try {
|
|
139
|
+
suites = suitesFromBase(root, base);
|
|
140
|
+
changed = changedTests(root, base);
|
|
141
|
+
findings = testCommandFindings(root, base);
|
|
142
|
+
} catch (e) {
|
|
143
|
+
return l0(String(e?.message || e));
|
|
144
|
+
}
|
|
145
|
+
if (!suites.length) return { ...l0('nothing inferable'), findings };
|
|
146
|
+
const ran = [];
|
|
147
|
+
for (const s of suites) {
|
|
148
|
+
if (!s.command || (s.toolchain && !toolchainOk(s.toolchain))) {
|
|
149
|
+
ran.push({ ...s, status: 'blocked', ran: false, exitCode: null });
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const head = runCmd(root, s.command);
|
|
153
|
+
const baseRun = runAtBase(root, base, s.command);
|
|
154
|
+
ran.push({
|
|
155
|
+
...s,
|
|
156
|
+
status: head.exitCode === 0 ? 'pass' : 'fail',
|
|
157
|
+
ran: true,
|
|
158
|
+
exitCode: head.exitCode,
|
|
159
|
+
baseExitCode: baseRun ? baseRun.exitCode : null,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
if (!ran.some((s) => s.ran)) {
|
|
163
|
+
return { level: 'L0', exitCode: 0, reason: 'nothing ran', suites: ran, changed, findings };
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
level: 'L1',
|
|
167
|
+
exitCode: ran.some((s) => s.status === 'fail') ? 1 : 0,
|
|
168
|
+
reason: '',
|
|
169
|
+
suites: ran,
|
|
170
|
+
changed,
|
|
171
|
+
findings,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"changed_tests": [
|
|
3
|
+
"(^|/)(test|tests)/",
|
|
4
|
+
"_test\\.[^/]+$",
|
|
5
|
+
"\\.test\\.[^/]+$",
|
|
6
|
+
"\\.spec\\.[^/]+$",
|
|
7
|
+
"(^|/)test_[^/]+\\.py$"
|
|
8
|
+
],
|
|
9
|
+
"node": {
|
|
10
|
+
"manifest": "package.json",
|
|
11
|
+
"script": "scripts.test",
|
|
12
|
+
"fallback_dev_dependencies": ["vitest", "jest"],
|
|
13
|
+
"fallback_commands": { "vitest": "vitest run", "jest": "jest" }
|
|
14
|
+
},
|
|
15
|
+
"rust": { "manifest": "Cargo.toml", "command": "cargo test", "toolchain": "cargo" },
|
|
16
|
+
"python": { "manifests": ["pytest.ini", "pyproject.toml"], "command": "pytest", "toolchain": "pytest" },
|
|
17
|
+
"go": { "manifest": "go.mod", "command": "go test ./...", "toolchain": "go" },
|
|
18
|
+
"gradle": { "manifest": "build.gradle" },
|
|
19
|
+
"dotnet": { "glob": "*.sln" }
|
|
20
|
+
}
|