@optitransfer/proofline 0.2.0-next.0 → 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/CHANGELOG.md +21 -1
- package/README.md +95 -2
- package/bin/proofline.mjs +206 -11
- package/docs/receipt.md +25 -0
- package/package.json +4 -1
- package/src/checks.mjs +12 -7
- package/src/contract.mjs +242 -1
- package/src/engine.mjs +77 -28
- package/src/guided.mjs +240 -0
- package/src/hook-stop.mjs +105 -0
- package/src/index.mjs +243 -15
- package/src/infer.mjs +173 -0
- package/src/inference.json +20 -0
- package/src/isolate.mjs +123 -0
- package/src/mcp.mjs +20 -5
- package/src/receipt.mjs +17 -0
- package/src/signature.mjs +110 -0
- package/src/vault.mjs +109 -0
- package/src/verify-receipt.mjs +109 -0
package/src/contract.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Contract + plan model: proofline.json at the repo root (committed).
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import { readJson, writeJson, sha256, canon } from './util.mjs';
|
|
4
|
+
import { readJson, writeJson, sha256, canon, git, matchAny } from './util.mjs';
|
|
5
5
|
|
|
6
6
|
export const SCHEMA = 'proofline/1';
|
|
7
7
|
export const PHASES = ['intake', 'design', 'plan', 'implement', 'verify', 'integrate', 'release', 'monitor'];
|
|
@@ -12,6 +12,26 @@ export const STATUS = {
|
|
|
12
12
|
// Task status vocabulary (derived, never self-declared)
|
|
13
13
|
export const TASK_STATUS = ['not_started', 'in_progress', 'verified', 'failed', 'blocked'];
|
|
14
14
|
|
|
15
|
+
/** Stop rules a scaffolded task carries. brief and steer still run when a loaded task has none. */
|
|
16
|
+
export const DEFAULT_STOP_RULES = [
|
|
17
|
+
"If a criterion cannot pass without weakening a rule, widening a bypass list, or signing with a key that is not the owner's: STOP and report. Never mark a boundary criterion passed by hand.",
|
|
18
|
+
'If the environment a criterion needs is missing (repository admin, the owner key, a container runtime, OIDC): report BLOCKED with the exact missing item.',
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
const KINDS = ['feature', 'docs', 'release'];
|
|
22
|
+
|
|
23
|
+
/** A path the scaffold can place inside scope. Prefer `prefer` when it already matches. */
|
|
24
|
+
function slotted(allow, prefer) {
|
|
25
|
+
if (matchAny(prefer, allow)) return prefer;
|
|
26
|
+
for (const g of allow) {
|
|
27
|
+
if (g.endsWith('/**')) {
|
|
28
|
+
const p = `${g.slice(0, -3)}/change.test.mjs`;
|
|
29
|
+
if (matchAny(p, allow)) return p;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return prefer;
|
|
33
|
+
}
|
|
34
|
+
|
|
15
35
|
export const DEFAULT_FORBIDDEN = {
|
|
16
36
|
test_skip_only: true, // .skip( .only( {skip: test.todo it.skip xit(
|
|
17
37
|
timeout_raise: true, // any numeric timeout increased in the diff
|
|
@@ -65,6 +85,39 @@ export function makeTask({ id, title, phase = 'implement', owner, depends_on = [
|
|
|
65
85
|
};
|
|
66
86
|
}
|
|
67
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Print-ready task: standard criteria and the default stop rules, for an agent to fill in.
|
|
90
|
+
* kind feature: a red-then-green test and the suite. docs: a grep. release: one command.
|
|
91
|
+
* Scope and pushed are always present. Does not write the contract.
|
|
92
|
+
*/
|
|
93
|
+
export function scaffoldTask({ title, scope, kind = 'feature', id = 'TASK' } = {}) {
|
|
94
|
+
if (!title || !String(title).trim()) throw new Error('scaffold needs a title');
|
|
95
|
+
if (!KINDS.includes(kind)) throw new Error('--kind must be feature, docs or release');
|
|
96
|
+
const allow = String(scope ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
97
|
+
if (!allow.length) throw new Error('scaffold needs a scope');
|
|
98
|
+
const criteria = [];
|
|
99
|
+
const add = (statement, verifier) => criteria.push({ statement, verifier });
|
|
100
|
+
if (kind === 'docs') {
|
|
101
|
+
const file = allow.find((g) => !/[*?]/.test(g)) || slotted(allow, 'README.md').replace(/change\.test\.mjs$/, 'change.md');
|
|
102
|
+
add('the docs name the change', { type: 'grep', file, pattern: 'CHANGE', must: 'present' });
|
|
103
|
+
} else if (kind === 'release') {
|
|
104
|
+
add('the release check exits 0', { type: 'command', cmd: 'npm test', expect_exit: 0 });
|
|
105
|
+
} else {
|
|
106
|
+
add('the new behavior fails on the base and passes now', { type: 'test_red_green', test_file: slotted(allow, 'test/change.test.mjs'), cmd: 'node --test {test}' });
|
|
107
|
+
add('suite green', { type: 'command', cmd: 'npm test', expect_exit: 0 });
|
|
108
|
+
}
|
|
109
|
+
add('Changes stay in scope', { type: 'scope' });
|
|
110
|
+
add('Committed and pushed to main', { type: 'pushed' });
|
|
111
|
+
return makeTask({ id, title, phase: kind === 'release' ? 'release' : 'implement', scope: allow, criteria, stop_rules: DEFAULT_STOP_RULES });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Double-quoted `sh -c` / `bash -c` whose quotes contain `$`. The verifier runs bash -c itself, so an outer shell would expand `$` first. */
|
|
115
|
+
function expandingShell(cmd) {
|
|
116
|
+
if (typeof cmd !== 'string') return false;
|
|
117
|
+
const m = cmd.match(/(?:^|[;&|]\s*)(?:sh|bash)\s+-c\s+"([^"]*)"/);
|
|
118
|
+
return !!(m && m[1].includes('$'));
|
|
119
|
+
}
|
|
120
|
+
|
|
68
121
|
const isName = (x) => typeof x === 'string' && x.trim() !== '';
|
|
69
122
|
|
|
70
123
|
/** A task belongs to an owner's lane when it is theirs or has no owner. No owner given: every task. */
|
|
@@ -117,6 +170,49 @@ export function validate(c) {
|
|
|
117
170
|
return errs;
|
|
118
171
|
}
|
|
119
172
|
|
|
173
|
+
/** The shapes contract validate refuses, on top of validate(). The hook keeps validate() so a fixture without these fields still gates. */
|
|
174
|
+
function lintErrors(c) {
|
|
175
|
+
const errors = [];
|
|
176
|
+
for (const t of c.tasks || []) {
|
|
177
|
+
if (!Array.isArray(t.stop_rules) || !t.stop_rules.length || t.stop_rules.some((s) => typeof s !== 'string' || !s.trim())) errors.push(`${t.id}: no stop_rules`);
|
|
178
|
+
if (!(t.criteria || []).some((a) => a?.verifier?.type === 'scope')) errors.push(`${t.id}: no scope criterion`);
|
|
179
|
+
const allow = Array.isArray(t.scope?.allow) ? t.scope.allow : [];
|
|
180
|
+
for (const a of t.criteria || []) {
|
|
181
|
+
const v = a.verifier;
|
|
182
|
+
if (!v) continue;
|
|
183
|
+
for (const key of ['file', 'path', 'test_file', 'entry']) {
|
|
184
|
+
const rel = v[key];
|
|
185
|
+
if (typeof rel !== 'string' || !rel || !allow.length) continue;
|
|
186
|
+
if (!matchAny(rel, allow)) errors.push(`${t.id}/${a.id}: ${key} ${rel} is outside scope`);
|
|
187
|
+
}
|
|
188
|
+
if (expandingShell(v.cmd)) errors.push(`${t.id}/${a.id}: command is a double-quoted sh -c or bash -c that contains $`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return errors;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Structural errors from validate, the scaffold lint, and a warning when a grep already matches on the base revision. */
|
|
195
|
+
export function lint(c, { root, base } = {}) {
|
|
196
|
+
const errors = [...validate(c), ...lintErrors(c)];
|
|
197
|
+
const warnings = [];
|
|
198
|
+
if (!root || !base) return { errors, warnings };
|
|
199
|
+
const rev = git(['rev-parse', '--verify', `${base}^{commit}`], root);
|
|
200
|
+
if (!rev.ok) return { errors, warnings };
|
|
201
|
+
const sha = rev.out;
|
|
202
|
+
for (const t of c.tasks || []) {
|
|
203
|
+
for (const a of t.criteria || []) {
|
|
204
|
+
const v = a.verifier;
|
|
205
|
+
if (v?.type !== 'grep' || typeof v.file !== 'string' || typeof v.pattern !== 'string') continue;
|
|
206
|
+
const shown = git(['show', `${sha}:${v.file}`], root);
|
|
207
|
+
if (!shown.ok) continue;
|
|
208
|
+
let re;
|
|
209
|
+
try { re = new RegExp(v.pattern, v.flags || 'm'); } catch { continue; }
|
|
210
|
+
if (re.test(shown.out)) warnings.push(`${t.id}/${a.id}: grep /${v.pattern}/ already matches ${v.file} on ${sha}`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return { errors, warnings };
|
|
214
|
+
}
|
|
215
|
+
|
|
120
216
|
export function topo(tasks) {
|
|
121
217
|
const by = new Map(tasks.map((t) => [t.id, t])); const out = []; const st = new Map();
|
|
122
218
|
const visit = (id) => {
|
|
@@ -160,4 +256,149 @@ export function weakenings(base, cur) {
|
|
|
160
256
|
return out;
|
|
161
257
|
}
|
|
162
258
|
|
|
259
|
+
/** Fields whose canonical bytes differ. This is the refusal. weakenings() stays the explanation. */
|
|
260
|
+
export function contractFieldChanges(base, cur) {
|
|
261
|
+
if (!base || !cur) return [];
|
|
262
|
+
if (contractHash(base) === contractHash(cur)) return [];
|
|
263
|
+
const out = [];
|
|
264
|
+
const walk = (a, b, path) => {
|
|
265
|
+
if (Object.is(a, b)) return;
|
|
266
|
+
const aArr = Array.isArray(a);
|
|
267
|
+
const bArr = Array.isArray(b);
|
|
268
|
+
if (aArr || bArr) {
|
|
269
|
+
const left = aArr ? a : [];
|
|
270
|
+
const right = bArr ? b : [];
|
|
271
|
+
const n = Math.max(left.length, right.length);
|
|
272
|
+
for (let i = 0; i < n; i++) {
|
|
273
|
+
const id = (right[i] && right[i].id) || (left[i] && left[i].id) || String(i);
|
|
274
|
+
walk(left[i], right[i], path ? `${path}/${id}` : String(id));
|
|
275
|
+
}
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
if (a && b && typeof a === "object" && typeof b === "object") {
|
|
279
|
+
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
|
|
280
|
+
for (const k of keys) walk(a[k], b[k], path ? `${path}/${k}` : k);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
const shown = (v) => (v === undefined ? "absent" : JSON.stringify(v));
|
|
284
|
+
out.push(`${path}: ${shown(a)} -> ${shown(b)}`);
|
|
285
|
+
};
|
|
286
|
+
walk(base, cur, "");
|
|
287
|
+
return out;
|
|
288
|
+
}
|
|
289
|
+
|
|
163
290
|
export function loadFile(f) { return JSON.parse(fs.readFileSync(f, 'utf8')); }
|
|
291
|
+
|
|
292
|
+
/** End index of the JSON array named `key`, so an append can keep every byte before it. */
|
|
293
|
+
function arrayEnd(text, key) {
|
|
294
|
+
const marker = `"${key}"`;
|
|
295
|
+
const at = text.indexOf(marker);
|
|
296
|
+
if (at < 0) return null;
|
|
297
|
+
const bracket = text.indexOf('[', at);
|
|
298
|
+
if (bracket < 0) return null;
|
|
299
|
+
let depth = 0;
|
|
300
|
+
let inStr = false;
|
|
301
|
+
let esc = false;
|
|
302
|
+
for (let i = bracket; i < text.length; i++) {
|
|
303
|
+
const c = text[i];
|
|
304
|
+
if (inStr) {
|
|
305
|
+
if (esc) esc = false;
|
|
306
|
+
else if (c === '\\') esc = true;
|
|
307
|
+
else if (c === '"') inStr = false;
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
if (c === '"') { inStr = true; continue; }
|
|
311
|
+
if (c === '[') depth++;
|
|
312
|
+
else if (c === ']') {
|
|
313
|
+
depth--;
|
|
314
|
+
if (depth === 0) return i;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function insertValues(text, key, values) {
|
|
321
|
+
if (!values.length) return text;
|
|
322
|
+
const end = arrayEnd(text, key);
|
|
323
|
+
if (end == null) throw new Error(`no ${key} array`);
|
|
324
|
+
const body = text.slice(text.lastIndexOf('[', end) + 1, end);
|
|
325
|
+
const lines = values.map((v) => ' ' + JSON.stringify(v)).join(',\n');
|
|
326
|
+
const chunk = body.trim() ? `,\n${lines}` : `\n${lines}\n `;
|
|
327
|
+
return text.slice(0, end) + chunk + text.slice(end);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function ownerTable(spec) {
|
|
331
|
+
const map = new Map();
|
|
332
|
+
if (!spec) return map;
|
|
333
|
+
for (const pair of String(spec).split(',')) {
|
|
334
|
+
const [from, to] = pair.split('=').map((s) => s.trim());
|
|
335
|
+
if (from && to) map.set(from, to);
|
|
336
|
+
}
|
|
337
|
+
return map;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** The contract text with tasks appended. A duplicate id throws and the caller writes nothing. Existing bytes stay. */
|
|
341
|
+
export function appendTaskText(text, tasks) {
|
|
342
|
+
const current = JSON.parse(text);
|
|
343
|
+
const have = new Set((current.tasks || []).map((t) => t.id));
|
|
344
|
+
for (const t of tasks) {
|
|
345
|
+
if (!t.id || have.has(t.id)) throw new Error(`duplicate task id ${t.id || '(missing)'}`);
|
|
346
|
+
have.add(t.id);
|
|
347
|
+
}
|
|
348
|
+
return insertValues(text, 'tasks', tasks);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Append a fragment's tasks and new owner rules. A duplicate id is refused and the file is left untouched.
|
|
353
|
+
* Existing bytes stay. The return is the sha256 of the file after the append.
|
|
354
|
+
*/
|
|
355
|
+
export function mergeContract(root, fragmentPath, { owners } = {}) {
|
|
356
|
+
const fragment = loadFile(fragmentPath);
|
|
357
|
+
const tasks = (Array.isArray(fragment) ? fragment : fragment.tasks || []).map((t) => ({ ...t }));
|
|
358
|
+
const rules = Array.isArray(fragment) ? [] : (fragment.owner_rules || []).filter((r) => typeof r === 'string');
|
|
359
|
+
const map = ownerTable(owners);
|
|
360
|
+
for (const t of tasks) if (typeof t.owner === 'string' && map.has(t.owner)) t.owner = map.get(t.owner);
|
|
361
|
+
const file = contractPath(root);
|
|
362
|
+
const before = fs.readFileSync(file, 'utf8');
|
|
363
|
+
const current = JSON.parse(before);
|
|
364
|
+
const haveRules = new Set(current.owner_rules || []);
|
|
365
|
+
const freshRules = rules.filter((r) => !haveRules.has(r));
|
|
366
|
+
let next = appendTaskText(before, tasks);
|
|
367
|
+
if (freshRules.length) {
|
|
368
|
+
if (arrayEnd(next, 'owner_rules') == null) throw new Error('no owner_rules array');
|
|
369
|
+
next = insertValues(next, 'owner_rules', freshRules);
|
|
370
|
+
}
|
|
371
|
+
if (next !== before) fs.writeFileSync(file, next);
|
|
372
|
+
const bytes = fs.readFileSync(file);
|
|
373
|
+
return { sha256: sha256(bytes), added: tasks.map((t) => t.id), owner_rules: freshRules };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** One line per requirement id: the tasks that name it, or MISSING. An id a task names that the file does not list is unknown. */
|
|
377
|
+
export function coverageReport(root, requirementsPath, { contracts } = {}) {
|
|
378
|
+
const text = fs.readFileSync(requirementsPath, 'utf8');
|
|
379
|
+
const ids = [];
|
|
380
|
+
for (const line of text.split(/\r?\n/)) {
|
|
381
|
+
const m = line.match(/^- ([^:]+):/);
|
|
382
|
+
if (m) ids.push(m[1].trim());
|
|
383
|
+
}
|
|
384
|
+
const known = new Set(ids);
|
|
385
|
+
const covered = new Map(ids.map((id) => [id, []]));
|
|
386
|
+
const unknown = [];
|
|
387
|
+
const files = contracts && contracts.length ? contracts : [contractPath(root)];
|
|
388
|
+
for (const f of files) {
|
|
389
|
+
const c = loadFile(f);
|
|
390
|
+
for (const t of c.tasks || []) {
|
|
391
|
+
for (const req of t.requirements || []) {
|
|
392
|
+
if (known.has(req)) covered.get(req).push(t.id);
|
|
393
|
+
else unknown.push(`${req} unknown ${t.id}`);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
const lines = ids.map((id) => {
|
|
398
|
+
const tasks = covered.get(id);
|
|
399
|
+
return tasks.length ? `${id} ${tasks.join(',')}` : `${id} MISSING`;
|
|
400
|
+
});
|
|
401
|
+
lines.push(...unknown);
|
|
402
|
+
const ok = !lines.some((l) => l.endsWith(' MISSING') || l.includes(' unknown '));
|
|
403
|
+
return { ok, lines };
|
|
404
|
+
}
|
package/src/engine.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import { scanForbidden } from './forbidden.mjs';
|
|
|
8
8
|
import { parseClaims, checkClaim } from './claims.mjs';
|
|
9
9
|
import { record, readLog, verifyChain } from './evidence.mjs';
|
|
10
10
|
import * as RC from './cache.mjs';
|
|
11
|
+
import { resolveIsolation, COVERAGE_LIMITS } from './isolate.mjs';
|
|
11
12
|
|
|
12
13
|
export const KNOWN_LIMITS = [
|
|
13
14
|
'Proofline runs on the same machine as the agent: an agent with shell access could edit .proofline/ or proofline.json. The hash chain makes edits detectable, contract weakening vs the base revision is flagged, and CI re-verifies from a clean checkout; protect proofline.json with CODEOWNERS/branch protection for a hard boundary.',
|
|
@@ -43,14 +44,23 @@ export function baseTrailer(root) {
|
|
|
43
44
|
|
|
44
45
|
/**
|
|
45
46
|
* Where the base revision comes from, first match wins: --base, the task's own `base` in the contract,
|
|
46
|
-
* the base pinned when the task started (`proofline next
|
|
47
|
-
* (defaults.base, else <defaults.remote>/<defaults.branch>).
|
|
47
|
+
* the most recent base pinned when the task started (`proofline next`, and a later `next --base`),
|
|
48
|
+
* a Proofline-Base trailer on HEAD, the default (defaults.base, else <defaults.remote>/<defaults.branch>).
|
|
48
49
|
*/
|
|
50
|
+
export function latestStart(root, taskId) {
|
|
51
|
+
const log = readLog(root);
|
|
52
|
+
for (let i = log.length - 1; i >= 0; i--) {
|
|
53
|
+
const e = log[i];
|
|
54
|
+
if (e.type === 'task_started' && e.data?.task === taskId && e.data.base) return e.data.base;
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
49
59
|
export function baseFor(root, c, t, explicit) {
|
|
50
60
|
if (explicit) return { ref: explicit, source: '--base' };
|
|
51
61
|
if (t.base) return { ref: t.base, source: 'contract' };
|
|
52
|
-
const started =
|
|
53
|
-
if (started
|
|
62
|
+
const started = latestStart(root, t.id);
|
|
63
|
+
if (started) return { ref: started, source: 'pinned at start' };
|
|
54
64
|
const tr = baseTrailer(root);
|
|
55
65
|
if (tr) return { ref: tr, source: 'commit trailer' };
|
|
56
66
|
return { ref: C.defaultBase(c), source: 'default' };
|
|
@@ -74,6 +84,26 @@ export function taskBase(root, c, t, explicit) {
|
|
|
74
84
|
return { ref, source, base, problems };
|
|
75
85
|
}
|
|
76
86
|
|
|
87
|
+
/** One line when the working contract differs from `explicit` (or the default base) and no marker approves it. */
|
|
88
|
+
export function unapprovedContractChange(root, explicit) {
|
|
89
|
+
const c = C.load(root);
|
|
90
|
+
const ref = explicit || C.defaultBase(c);
|
|
91
|
+
const base = resolveBase(root, ref);
|
|
92
|
+
if (!base) return null;
|
|
93
|
+
const s = git(['show', `${base}:proofline.json`], root);
|
|
94
|
+
if (!s.ok) return null;
|
|
95
|
+
let baseContract;
|
|
96
|
+
try { baseContract = JSON.parse(s.out); } catch { return null; }
|
|
97
|
+
if (C.contractHash(baseContract) === C.contractHash(c)) return null;
|
|
98
|
+
let raw;
|
|
99
|
+
try { raw = fs.readFileSync(path.join(root, 'proofline.json')); } catch { return null; }
|
|
100
|
+
if (approvalMarker(root, raw).approved) return null;
|
|
101
|
+
const weak = C.weakenings(baseContract, c);
|
|
102
|
+
return weak.length
|
|
103
|
+
? `contract weakened vs base (needs owner approval): ${weak.join('; ')}`
|
|
104
|
+
: 'contract bytes changed vs base (needs owner approval)';
|
|
105
|
+
}
|
|
106
|
+
|
|
77
107
|
const BASE_NEXT = 'gate against the revision the task started from: pass --base <rev> (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.';
|
|
78
108
|
|
|
79
109
|
/** The "## Base" section of a brief: which base verify and gate will use, and whether they can. */
|
|
@@ -84,31 +114,39 @@ export function baseBrief(root, c, t, explicit) {
|
|
|
84
114
|
return L.join('\n');
|
|
85
115
|
}
|
|
86
116
|
|
|
87
|
-
export function verifyTask(root, taskId,
|
|
117
|
+
export function verifyTask(root, taskId, opts = {}) {
|
|
118
|
+
const { claims, only, skipTypes = [], base: explicitBase, machine, fresh = false } = opts;
|
|
88
119
|
const c = C.load(root);
|
|
89
120
|
const t = taskById(c, taskId);
|
|
121
|
+
const iso = resolveIsolation(c, opts);
|
|
90
122
|
const errs = C.validate(c);
|
|
91
123
|
// Base is pinned when the task starts, so pushing the work does not move the goalposts.
|
|
92
124
|
const { ref: baseRef, source: baseSource, base, problems: baseProblems } = taskBase(root, c, t, explicitBase);
|
|
93
|
-
const ctx = { root, contract: c, task: t, base, changed: base ? changedFiles(root, base) : new Map(), measured: {}, machine, baseline: c.baseline?.metrics, tolerancePct: c.defaults?.regression_tolerance_pct, flakyReruns: c.defaults?.flaky_reruns ?? 1 };
|
|
125
|
+
const ctx = { root, contract: c, task: t, base, changed: base ? changedFiles(root, base) : new Map(), measured: {}, machine, baseline: c.baseline?.metrics, tolerancePct: c.defaults?.regression_tolerance_pct, flakyReruns: c.defaults?.flaky_reruns ?? 1, sandbox: iso.sandbox };
|
|
94
126
|
const results = [];
|
|
95
127
|
const reuse = RC.settings(c);
|
|
96
128
|
for (const a of t.criteria) {
|
|
97
129
|
if (only && !only.includes(a.id)) continue;
|
|
98
130
|
if (skipTypes.includes(a.verifier.type)) continue;
|
|
99
131
|
let r;
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
if (hit) {
|
|
104
|
-
r = RC.reusedResult(hit);
|
|
105
|
-
if (a.verifier.type === 'metric') ctx.measured[a.verifier.name || a.verifier.key] = hit.data.evidence.measured;
|
|
132
|
+
const sandboxed = ['command', 'test_red_green', 'metric'].includes(a.verifier.type);
|
|
133
|
+
if (iso.block && sandboxed) {
|
|
134
|
+
r = { status: 'error', detail: iso.block };
|
|
106
135
|
} else {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
136
|
+
// Reuse (opt-in): a command or metric PASS recorded on this HEAD with a clean tree is not run again; --fresh runs it.
|
|
137
|
+
const k = reuse.on && RC.REUSABLE.includes(a.verifier.type) ? RC.keyFor(root, a.verifier, ctx) : null;
|
|
138
|
+
const hit = k && !fresh ? RC.lookup(root, k.key, reuse) : null;
|
|
139
|
+
if (hit) {
|
|
140
|
+
r = RC.reusedResult(hit);
|
|
141
|
+
if (a.verifier.type === 'metric') ctx.measured[a.verifier.name || a.verifier.key] = hit.data.evidence.measured;
|
|
142
|
+
} else {
|
|
143
|
+
try { r = verifiers[a.verifier.type](a.verifier, ctx); } catch (e) { r = { status: 'error', detail: `verifier crashed: ${e.message}` }; }
|
|
144
|
+
// Only a PASS is stored, and only if the tree is still the one the check started on and the log is intact.
|
|
145
|
+
if (k && r.status === 'pass' && RC.keyFor(root, a.verifier, ctx)?.key === k.key && verifyChain(root).ok) RC.store(root, k, t.id, a, r);
|
|
146
|
+
}
|
|
110
147
|
}
|
|
111
|
-
|
|
148
|
+
const heuristic = a.verifier.type === 'grep' || a.verifier.type === 'wiring';
|
|
149
|
+
results.push({ id: a.id, statement: a.statement, mandatory: a.mandatory !== false, type: a.verifier.type, ...(heuristic ? { heuristic: true } : {}), ...r });
|
|
112
150
|
}
|
|
113
151
|
const rules = { ...C.DEFAULT_FORBIDDEN, ...(c.forbidden || {}) };
|
|
114
152
|
const forbidden = scanForbidden(root, base, rules);
|
|
@@ -117,9 +155,11 @@ export function verifyTask(root, taskId, { claims, only, skipTypes = [], base: e
|
|
|
117
155
|
const weak = C.weakenings(baseContract, c);
|
|
118
156
|
// The forbidden scan skips proofline.json (it declares the deny-list); a contract change is reported here instead.
|
|
119
157
|
let contractChange = !base ? 'unknown' : !baseContract ? 'added' : C.contractHash(baseContract) === C.contractHash(c) ? 'unchanged' : 'changed';
|
|
158
|
+
const fields = baseContract ? C.contractFieldChanges(baseContract, c) : [];
|
|
120
159
|
let marker = null;
|
|
121
|
-
const
|
|
122
|
-
|
|
160
|
+
const hashChanged = contractChange === 'changed';
|
|
161
|
+
const approveChange = () => {
|
|
162
|
+
if (!hashChanged) return false;
|
|
123
163
|
let raw = null;
|
|
124
164
|
try { raw = fs.readFileSync(path.join(root, 'proofline.json')); } catch { raw = null; }
|
|
125
165
|
const mark = approvalMarker(root, raw);
|
|
@@ -134,7 +174,13 @@ export function verifyTask(root, taskId, { claims, only, skipTypes = [], base: e
|
|
|
134
174
|
if (errs.length) blocked.push(...errs.map((e) => `contract invalid: ${e}`));
|
|
135
175
|
blocked.push(...baseProblems);
|
|
136
176
|
if (!chain.ok) blocked.push(`evidence log tampered at entry ${chain.at}: ${chain.reason}`);
|
|
137
|
-
if (
|
|
177
|
+
if (hashChanged && !approveChange()) {
|
|
178
|
+
// One blocked line. weakenings() is the explanation, so a real weakening stays length 1.
|
|
179
|
+
if (weak.length) blocked.push(`contract weakened vs base (needs owner approval): ${weak.join('; ')}`);
|
|
180
|
+
else blocked.push('contract bytes changed vs base (needs owner approval)');
|
|
181
|
+
} else if (!hashChanged) {
|
|
182
|
+
for (const w of weak) blocked.push(`contract weakened vs base (needs owner approval): ${w}`);
|
|
183
|
+
}
|
|
138
184
|
for (const r of results) {
|
|
139
185
|
if (r.status === 'error') blocked.push(`${r.id}: could not run: ${r.detail}`);
|
|
140
186
|
else if (r.status === 'blocked') blocked.push(`${r.id}: ${r.detail}`);
|
|
@@ -161,7 +207,7 @@ export function verifyTask(root, taskId, { claims, only, skipTypes = [], base: e
|
|
|
161
207
|
const pushed = pr ? pr.status === 'pass'
|
|
162
208
|
: !!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;
|
|
163
209
|
const reused = results.filter((r) => r.reused).map((r) => ({ id: r.id, type: r.type, ts: r.reused.ts, entry: r.reused.entry }));
|
|
164
|
-
return { 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() };
|
|
210
|
+
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() };
|
|
165
211
|
}
|
|
166
212
|
|
|
167
213
|
/** Corrective prompt tied to each failed criterion. */
|
|
@@ -199,7 +245,7 @@ export function steer(v, c) {
|
|
|
199
245
|
}
|
|
200
246
|
for (const f of v.forbidden) L.push(`${n++}. FORBIDDEN ${f.rule} in ${f.file}: ${f.line}\n Why: ${f.why}. Next step: revert it and fix the underlying cause.`);
|
|
201
247
|
for (const cr of v.claims.filter((x) => !x.ok)) L.push(`${n++}. CLAIM REJECTED: "${cr.claim}"\n ${cr.issues.join('\n ')}\n Next step: report only what the evidence shows.`);
|
|
202
|
-
L.push('', 'STOP RULES:', ...t.stop_rules.map((s) => `- ${s}`), '- After fixing, call verify again. Max corrective cycles: ' + (c.defaults?.max_corrective_cycles ?? 3) + '; after that report BLOCKED with root cause, what you tried, what you need.');
|
|
248
|
+
L.push('', 'STOP RULES:', ...(Array.isArray(t.stop_rules) ? t.stop_rules : []).map((s) => `- ${s}`), '- After fixing, call verify again. Max corrective cycles: ' + (c.defaults?.max_corrective_cycles ?? 3) + '; after that report BLOCKED with root cause, what you tried, what you need.');
|
|
203
249
|
return L.join('\n');
|
|
204
250
|
}
|
|
205
251
|
|
|
@@ -277,26 +323,29 @@ export function phaseStatus(root, c = C.load(root)) {
|
|
|
277
323
|
* The next task. With `owner`, only that owner's tasks and unowned ones are handed out, and the phase gate
|
|
278
324
|
* applies to that lane; depends_on still waits on any owner's task.
|
|
279
325
|
*/
|
|
280
|
-
export function next(root, { owner } = {}) {
|
|
326
|
+
export function next(root, { owner, skip = [] } = {}) {
|
|
281
327
|
const c = C.load(root);
|
|
282
328
|
C.checkOwner(c, owner);
|
|
283
329
|
const st = taskStatus(root, c);
|
|
330
|
+
const skipSet = new Set(skip);
|
|
331
|
+
// A skipped task has landed. It does not stay active, and it does not keep its phase or its dependents waiting.
|
|
332
|
+
const open = (t) => st[t.id].status !== 'verified' && !skipSet.has(t.id);
|
|
284
333
|
const lane = c.tasks.filter((t) => C.inLane(t, owner));
|
|
285
334
|
const order = (C.topo(c.tasks) || c.tasks).filter((t) => C.inLane(t, owner));
|
|
286
|
-
const firstOpenPhase = C.PHASES.find((p) => lane.some((t) => t.phase === p &&
|
|
335
|
+
const firstOpenPhase = C.PHASES.find((p) => lane.some((t) => t.phase === p && open(t)));
|
|
287
336
|
if (!firstOpenPhase && owner) {
|
|
288
|
-
const others = c.tasks.filter((t) => !C.inLane(t, owner) && st[t.id].status !== 'verified').map((t) => `${t.id} (${t.owner})`);
|
|
337
|
+
const others = c.tasks.filter((t) => !C.inLane(t, owner) && st[t.id].status !== 'verified' && !skipSet.has(t.id)).map((t) => `${t.id} (${t.owner})`);
|
|
289
338
|
return { done: true, owner, message: `Every task for ${owner} and every unowned task is verified.${others.length ? ` Open for other owners: ${others.join(', ')}.` : ' Run `proofline gate --all` before release.'}` };
|
|
290
339
|
}
|
|
291
340
|
if (!firstOpenPhase) return { done: true, message: 'All tasks verified. Run `proofline gate --all` before release.' };
|
|
292
341
|
for (const t of order) {
|
|
293
|
-
if (
|
|
342
|
+
if (!open(t)) continue;
|
|
294
343
|
if (t.phase !== firstOpenPhase) continue; // phase gate: earlier phases must be fully verified
|
|
295
|
-
const deps = (t.depends_on || []).filter((d) => st[d]?.status !== 'verified');
|
|
344
|
+
const deps = (t.depends_on || []).filter((d) => st[d]?.status !== 'verified' && !skipSet.has(d));
|
|
296
345
|
if (deps.length) continue;
|
|
297
346
|
return { done: false, task: t.id, phase: t.phase, status: st[t.id].status, brief: brief(c, t, st) };
|
|
298
347
|
}
|
|
299
|
-
const waiting = order.filter((t) =>
|
|
348
|
+
const waiting = order.filter((t) => open(t)).map((t) => `${t.id} (waits on ${(t.depends_on || []).filter((d) => st[d]?.status !== 'verified' && !skipSet.has(d)).join(', ') || 'phase gate'})`);
|
|
300
349
|
return { done: false, task: null, message: `No task${owner ? ` for ${owner}` : ''} is unblocked in phase ${firstOpenPhase}: ${waiting.join('; ')}` };
|
|
301
350
|
}
|
|
302
351
|
|
|
@@ -324,6 +373,6 @@ export function brief(c, t, st = {}) {
|
|
|
324
373
|
'## Mandatory loop', '1. Write the failing test(s) first and run them: paste the failing output.', '2. Implement the cause-level fix on the default path (wired in, default config).', `3. \`proofline verify ${t.id}\` → fix until PASS. Read the corrective instruction it prints.`, `4. Commit (one task per commit), push to ${pushTarget(c, t).remote} ${pushTarget(c, t).branch} with hooks on.`, `5. \`proofline gate ${t.id} --claims "<your report line>"\` and paste the report.`, '',
|
|
325
374
|
'## Forbidden (detected automatically)', `- skip/only/todo in tests; raising timeouts; net removal of assertions; deleting tests; --no-verify; env bypasses (${(f.bypass_env || []).join(', ')}); lowering protected values (${(f.protected_values || []).map((p) => p.file).join(', ') || 'none configured'}); editing thresholds/commands/criteria in proofline.json.`, '',
|
|
326
375
|
'## Owner rules', ...(c.owner_rules || []).map((r) => `- ${r}`), '',
|
|
327
|
-
'## Stop rules', ...t.stop_rules.map((s) => `- ${s}`), '',
|
|
376
|
+
'## Stop rules', ...(Array.isArray(t.stop_rules) ? t.stop_rules : []).map((s) => `- ${s}`), '',
|
|
328
377
|
'## Report line (exact format)', `${t.id} | done/partial/not done/blocked | <commit> | <test names> | <metric=value vs target> | <what is missing>`].join('\n');
|
|
329
378
|
}
|