@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/engine.mjs
ADDED
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
// verify → steer → gate, plus project-cycle planning (status, next, brief).
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { git, writeJson, sha256, approvalMarker } from './util.mjs';
|
|
5
|
+
import * as C from './contract.mjs';
|
|
6
|
+
import { verifiers, resolveBase, changedFiles } from './checks.mjs';
|
|
7
|
+
import { scanForbidden } from './forbidden.mjs';
|
|
8
|
+
import { parseClaims, checkClaim } from './claims.mjs';
|
|
9
|
+
import { record, readLog, verifyChain } from './evidence.mjs';
|
|
10
|
+
import * as RC from './cache.mjs';
|
|
11
|
+
import { resolveIsolation, COVERAGE_LIMITS } from './isolate.mjs';
|
|
12
|
+
|
|
13
|
+
export const KNOWN_LIMITS = [
|
|
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.',
|
|
15
|
+
'git hooks can be skipped client-side with --no-verify; Proofline cannot prevent that locally. The CI workflow re-runs the gate on every push, so a skipped hook still fails visibly.',
|
|
16
|
+
'Forbidden-pattern and wiring checks are static heuristics (regex + static import graph). Dynamic imports with computed paths and dependency injection are not followed.',
|
|
17
|
+
'Red-green replays the new test against the base revision in a git worktree. Tests needing services or build steps need a "setup" command.',
|
|
18
|
+
'Metric checks trust the bench script committed in the repo; a changed bench command is flagged as a contract change, but the bench itself is code under review.',
|
|
19
|
+
'Contracts are JSON (zero dependencies). YAML is not parsed.',
|
|
20
|
+
'With defaults.reuse_results on, a PASS of a command or metric check is reused on the same HEAD with a clean tree for up to reuse_max_age_h hours. Its key holds the verifier, HEAD, the node version and the env vars the command names, not gitignored files, other env vars or tools outside the repo; a check that depends on those needs --fresh.',
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
/** Where a task's work is pushed: its pushed criterion's remote/branch, else defaults.remote/branch, else origin/main. */
|
|
24
|
+
export function pushTarget(c, t, v = t.criteria.find((a) => a.verifier?.type === 'pushed')?.verifier) {
|
|
25
|
+
return { remote: v?.remote || C.remoteOf(c), branch: v?.branch || C.branchOf(c) };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function taskById(c, id) { const t = c.tasks.find((x) => x.id === id); if (!t) throw new Error(`unknown task ${id}`); return t; }
|
|
29
|
+
|
|
30
|
+
export function headInfo(root) {
|
|
31
|
+
const head = git(['rev-parse', 'HEAD'], root).out || null;
|
|
32
|
+
const dirty = git(['diff', 'HEAD'], root).out + git(['ls-files', '--others', '--exclude-standard'], root).out;
|
|
33
|
+
return { head, tree: sha256(dirty) };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** `Proofline-Base: <rev>` in the trailer block (last paragraph) of the HEAD commit message, or null. */
|
|
37
|
+
export function baseTrailer(root) {
|
|
38
|
+
const msg = git(['log', '-1', '--format=%B', 'HEAD'], root);
|
|
39
|
+
if (!msg.ok) return null;
|
|
40
|
+
const last = msg.out.trim().split(/\n[ \t]*\n/).pop() || '';
|
|
41
|
+
const m = [...last.matchAll(/^Proofline-Base:[ \t]*(\S+)[ \t]*$/gim)].pop();
|
|
42
|
+
return m ? m[1] : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Where the base revision comes from, first match wins: --base, the task's own `base` in the contract,
|
|
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>).
|
|
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
|
+
|
|
59
|
+
export function baseFor(root, c, t, explicit) {
|
|
60
|
+
if (explicit) return { ref: explicit, source: '--base' };
|
|
61
|
+
if (t.base) return { ref: t.base, source: 'contract' };
|
|
62
|
+
const started = latestStart(root, t.id);
|
|
63
|
+
if (started) return { ref: started, source: 'pinned at start' };
|
|
64
|
+
const tr = baseTrailer(root);
|
|
65
|
+
if (tr) return { ref: tr, source: 'commit trailer' };
|
|
66
|
+
return { ref: C.defaultBase(c), source: 'default' };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const forbiddenOn = (f) => ['test_skip_only', 'timeout_raise', 'assertion_removal', 'test_file_deletion', 'no_verify'].some((k) => f[k] !== false) || (f.bypass_env || []).length > 0 || (f.protected_values || []).length > 0;
|
|
70
|
+
|
|
71
|
+
/** The base for a task, resolved, with the reasons it cannot be used (each one BLOCKS the gate). */
|
|
72
|
+
export function taskBase(root, c, t, explicit) {
|
|
73
|
+
const { ref, source } = baseFor(root, c, t, explicit);
|
|
74
|
+
const base = resolveBase(root, ref);
|
|
75
|
+
const problems = [];
|
|
76
|
+
if (!base) problems.push(`base revision "${ref}" (${source}) not found`);
|
|
77
|
+
const head = git(['rev-parse', '--verify', '--quiet', 'HEAD'], root).out;
|
|
78
|
+
if (base && head && source === 'commit trailer' && (base === head || !git(['merge-base', '--is-ancestor', base, head], root).ok))
|
|
79
|
+
problems.push(`base: the Proofline-Base trailer on HEAD names ${ref}, which is not an ancestor of HEAD ${head.slice(0, 8)}`);
|
|
80
|
+
// With no pinned or passed base, a default base at (or ahead of) HEAD gives an empty diff: every diff check would pass on nothing.
|
|
81
|
+
const diffBased = t.criteria.some((a) => ['scope', 'test_red_green'].includes(a.verifier?.type)) || forbiddenOn({ ...C.DEFAULT_FORBIDDEN, ...(c.forbidden || {}) });
|
|
82
|
+
if (base && head && source === 'default' && diffBased && git(['merge-base', '--is-ancestor', head, base], root).ok)
|
|
83
|
+
problems.push(`base: no base is pinned for ${t.id}, and the default base ${ref} is ${base === head ? 'HEAD' : 'ahead of HEAD'} (${base.slice(0, 8)}), so the diff does not hold this work and the forbidden scan, scope, red-green and weakening checks would see nothing`);
|
|
84
|
+
return { ref, source, base, problems };
|
|
85
|
+
}
|
|
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
|
+
|
|
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.';
|
|
108
|
+
|
|
109
|
+
/** The "## Base" section of a brief: which base verify and gate will use, and whether they can. */
|
|
110
|
+
export function baseBrief(root, c, t, explicit) {
|
|
111
|
+
const b = taskBase(root, c, t, explicit);
|
|
112
|
+
const L = [`## Base`, `verify and gate compare the working tree with ${b.ref} = ${b.base?.slice(0, 8) || 'not found'} (${b.source}).`];
|
|
113
|
+
if (b.problems.length) L.push(`As things stand the gate is BLOCKED: ${b.problems.join('; ')}.`, `Next step: ${BASE_NEXT}`);
|
|
114
|
+
return L.join('\n');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function verifyTask(root, taskId, opts = {}) {
|
|
118
|
+
const { claims, only, skipTypes = [], base: explicitBase, machine, fresh = false } = opts;
|
|
119
|
+
const c = C.load(root);
|
|
120
|
+
const t = taskById(c, taskId);
|
|
121
|
+
const iso = resolveIsolation(c, opts);
|
|
122
|
+
const errs = C.validate(c);
|
|
123
|
+
// Base is pinned when the task starts, so pushing the work does not move the goalposts.
|
|
124
|
+
const { ref: baseRef, source: baseSource, base, problems: baseProblems } = taskBase(root, c, t, explicitBase);
|
|
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 };
|
|
126
|
+
const results = [];
|
|
127
|
+
const reuse = RC.settings(c);
|
|
128
|
+
for (const a of t.criteria) {
|
|
129
|
+
if (only && !only.includes(a.id)) continue;
|
|
130
|
+
if (skipTypes.includes(a.verifier.type)) continue;
|
|
131
|
+
let r;
|
|
132
|
+
const sandboxed = ['command', 'test_red_green', 'metric'].includes(a.verifier.type);
|
|
133
|
+
if (iso.block && sandboxed) {
|
|
134
|
+
r = { status: 'error', detail: iso.block };
|
|
135
|
+
} else {
|
|
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
|
+
}
|
|
147
|
+
}
|
|
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 });
|
|
150
|
+
}
|
|
151
|
+
const rules = { ...C.DEFAULT_FORBIDDEN, ...(c.forbidden || {}) };
|
|
152
|
+
const forbidden = scanForbidden(root, base, rules);
|
|
153
|
+
let baseContract = null;
|
|
154
|
+
if (base) { const s = git(['show', `${base}:proofline.json`], root); if (s.ok) try { baseContract = JSON.parse(s.out); } catch {} }
|
|
155
|
+
const weak = C.weakenings(baseContract, c);
|
|
156
|
+
// The forbidden scan skips proofline.json (it declares the deny-list); a contract change is reported here instead.
|
|
157
|
+
let contractChange = !base ? 'unknown' : !baseContract ? 'added' : C.contractHash(baseContract) === C.contractHash(c) ? 'unchanged' : 'changed';
|
|
158
|
+
const fields = baseContract ? C.contractFieldChanges(baseContract, c) : [];
|
|
159
|
+
let marker = null;
|
|
160
|
+
const hashChanged = contractChange === 'changed';
|
|
161
|
+
const approveChange = () => {
|
|
162
|
+
if (!hashChanged) return false;
|
|
163
|
+
let raw = null;
|
|
164
|
+
try { raw = fs.readFileSync(path.join(root, 'proofline.json')); } catch { raw = null; }
|
|
165
|
+
const mark = approvalMarker(root, raw);
|
|
166
|
+
if (!mark.approved) return false;
|
|
167
|
+
marker = mark.marker;
|
|
168
|
+
contractChange = 'approved';
|
|
169
|
+
return true;
|
|
170
|
+
};
|
|
171
|
+
const chain = verifyChain(root);
|
|
172
|
+
|
|
173
|
+
const blocked = [], failing = [];
|
|
174
|
+
if (errs.length) blocked.push(...errs.map((e) => `contract invalid: ${e}`));
|
|
175
|
+
blocked.push(...baseProblems);
|
|
176
|
+
if (!chain.ok) blocked.push(`evidence log tampered at entry ${chain.at}: ${chain.reason}`);
|
|
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
|
+
}
|
|
184
|
+
for (const r of results) {
|
|
185
|
+
if (r.status === 'error') blocked.push(`${r.id}: could not run: ${r.detail}`);
|
|
186
|
+
else if (r.status === 'blocked') blocked.push(`${r.id}: ${r.detail}`);
|
|
187
|
+
else if (r.mandatory && r.status !== 'pass') failing.push(`${r.id} ${r.status.toUpperCase()}: ${r.detail}`);
|
|
188
|
+
}
|
|
189
|
+
for (const f of forbidden) failing.push(`forbidden ${f.rule} in ${f.file}: ${f.line}`);
|
|
190
|
+
let decision = blocked.length ? 'BLOCKED' : failing.length ? 'FAIL' : 'PASS';
|
|
191
|
+
|
|
192
|
+
const metricLabels = {};
|
|
193
|
+
for (const a of t.criteria) if (a.verifier.type === 'metric') metricLabels[a.verifier.name || a.verifier.key] = a.verifier.labels || [];
|
|
194
|
+
const claimResults = [];
|
|
195
|
+
if (claims) {
|
|
196
|
+
const res = { decision, failing: [...blocked, ...failing], measured: ctx.measured, metricLabels };
|
|
197
|
+
const needPush = t.criteria.some((a) => a.verifier.type === 'pushed');
|
|
198
|
+
for (const cl of parseClaims(claims, [t.id])) claimResults.push(checkClaim(root, cl, res, { requirePushed: needPush, ...pushTarget(c, t) }));
|
|
199
|
+
for (const cr of claimResults) if (!cr.ok) failing.push(...cr.issues.map((i) => `claim mismatch: ${i}`));
|
|
200
|
+
if (claimResults.some((x) => !x.ok) && decision === 'PASS') decision = 'FAIL';
|
|
201
|
+
}
|
|
202
|
+
const hi = headInfo(root);
|
|
203
|
+
// Is this HEAD already on the push target? The pushed criterion says so when it ran; otherwise (skipped, or the
|
|
204
|
+
// task has none) a clean tree whose HEAD equals the local <remote>/<branch> ref counts. No network call here.
|
|
205
|
+
const pr = results.find((r) => r.type === 'pushed');
|
|
206
|
+
const tgt = pushTarget(c, t);
|
|
207
|
+
const pushed = pr ? pr.status === 'pass'
|
|
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;
|
|
209
|
+
const reused = results.filter((r) => r.reused).map((r) => ({ id: r.id, type: r.type, ts: r.reused.ts, entry: r.reused.entry }));
|
|
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() };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Corrective prompt tied to each failed criterion. */
|
|
214
|
+
export function steer(v, c) {
|
|
215
|
+
const t = taskById(c, v.task);
|
|
216
|
+
if (v.decision === 'PASS') {
|
|
217
|
+
const p = pushTarget(c, t), h = v.head?.slice(0, 8);
|
|
218
|
+
const nums = Object.entries(v.measured).map(([k, x]) => `${k}=${x}`).join(', ') || '-';
|
|
219
|
+
const gateCmd = (hash) => `proofline gate ${v.task}${v.base_source === '--base' ? ` --base ${v.base}` : ''} --claims "${v.task} | done | ${hash} | <tests> | ${nums} | -"`;
|
|
220
|
+
if (!v.pushed) return `${v.task}: PASS on ${h}, not pushed yet. Commit (one task per commit) and push to ${p.remote} ${p.branch} with hooks on; the pre-push hook gates it again. Then report it:\n${gateCmd('<hash>')}`;
|
|
221
|
+
if (v.claims.length && v.claims.every((x) => x.ok)) return `${v.task}: PASS, verified on the pushed HEAD ${h} (${p.remote}/${p.branch}), and the claims match the evidence. The task is done. Next task: \`proofline next\`.`;
|
|
222
|
+
return `${v.task}: PASS, verified on the pushed HEAD ${h} (${p.remote}/${p.branch}). Nothing is left to commit. Report it, naming the tests that prove it:\n${gateCmd(h)}`;
|
|
223
|
+
}
|
|
224
|
+
const L = [`PROOFLINE CORRECTIVE INSTRUCTION for ${v.task} (${t.title}): gate ${v.decision}`, `Revision ${v.head?.slice(0, 8)} vs base ${v.base?.slice(0, 8) || v.base_ref} (${v.base_source}). Do NOT report this task as done. Fix in this order:`, ''];
|
|
225
|
+
let n = 1;
|
|
226
|
+
for (const b of v.blocked) L.push(`${n++}. BLOCKED: ${b}\n Next step: ${b.startsWith('base: ')
|
|
227
|
+
? BASE_NEXT
|
|
228
|
+
: /^[^:]+: machine busy:/.test(b) ? 'stop the other load (suites, benches, builds) and gate again on an idle machine. Do not change max_load_per_cpu.'
|
|
229
|
+
: 'resolve the blocker (restore the contract, commit the missing rig/tool, or ask the owner). Do not work around it.'}`);
|
|
230
|
+
for (const r of v.results.filter((x) => x.mandatory && !['pass', 'error', 'blocked'].includes(x.status))) {
|
|
231
|
+
const a = t.criteria.find((x) => x.id === r.id);
|
|
232
|
+
const how = {
|
|
233
|
+
command: () => `Run \`${a.verifier.cmd}\`. Find the root cause of each failure (file:line + mechanism) and fix the cause. Re-run until exit ${a.verifier.expect_exit ?? 0}.`,
|
|
234
|
+
test_red_green: () => `The test ${a.verifier.test_file} must FAIL on the base and PASS now. If it passes on base it does not test the change: assert the outcome (what), not the mechanism (how).`,
|
|
235
|
+
test_exists: () => `Write ${a.verifier.test_file}${a.verifier.test_name ? ` containing "${a.verifier.test_name}"` : ''}.`,
|
|
236
|
+
metric: () => `Run \`${a.verifier.cmd}\` and get a per-stage trace of where the time goes. Target ${a.verifier.key} ${a.verifier.op} ${a.verifier.value}. Do not change the threshold or the bench.`,
|
|
237
|
+
grep: () => `Make ${a.verifier.file} ${a.verifier.must === 'absent' ? 'no longer contain' : 'contain'} /${a.verifier.pattern}/${a.verifier.why ? ` (${a.verifier.why})` : ''}.`,
|
|
238
|
+
wiring: () => `Call ${a.verifier.symbol} from the default path starting at ${a.verifier.entry}. Code that is built but not reachable does not count.`,
|
|
239
|
+
scope: () => `Revert changes outside scope or ask the owner to amend the contract.`,
|
|
240
|
+
docs: () => `Run \`${a.verifier.cmd}\` and commit the regenerated ${a.verifier.paths.join(', ')}.`,
|
|
241
|
+
pushed: () => { const p = pushTarget(c, t, a.verifier); return `Commit everything and \`git push ${p.remote} ${p.branch}\` (hooks on; never --no-verify).`; },
|
|
242
|
+
file_exists: () => `Create ${a.verifier.path}.`,
|
|
243
|
+
}[a.verifier.type]();
|
|
244
|
+
L.push(`${n++}. ${r.id} [${r.type}] ${r.status.toUpperCase()}: ${a.statement || ''}\n Evidence: ${r.detail.split('\n')[0]}\n Next step: ${how}`);
|
|
245
|
+
}
|
|
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.`);
|
|
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.`);
|
|
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.');
|
|
249
|
+
return L.join('\n');
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const reusedFrom = (x) => `reused from ${x.ts} (evidence entry ${x.entry})`;
|
|
253
|
+
|
|
254
|
+
/** Each failed or flaky command criterion with the test names its evidence recorded, one line per run (both when it re-ran). */
|
|
255
|
+
function failingTestLines(v) {
|
|
256
|
+
const L = [];
|
|
257
|
+
for (const r of v.results.filter((x) => x.type === 'command' && ['fail', 'flaky'].includes(x.status) && x.evidence)) {
|
|
258
|
+
L.push(`- ${r.id} [command] ${r.status.toUpperCase()}`);
|
|
259
|
+
const e = r.evidence;
|
|
260
|
+
for (const [label, x] of e.first ? [['first run', e.first], ['re-run', e.rerun]] : [['run', e]]) {
|
|
261
|
+
if (!x?.failing) continue;
|
|
262
|
+
const n = x.failing_total ?? x.failing.length;
|
|
263
|
+
L.push(` - ${label} (exit ${x.exit}): ${n ? `${n} failed${x.failing_total ? ` (first ${x.failing.length} recorded)` : ''}: ${x.failing.join(', ')}` : 'none named'}`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return L.length ? L : ['- none'];
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function reportMd(v, c, prompt) {
|
|
270
|
+
const row = (r) => `| ${r.id} | ${r.type} | ${r.mandatory ? 'yes' : 'no'} | ${r.status.toUpperCase()} | ${r.detail.split('\n')[0].replace(/\|/g, '\\|')} |`;
|
|
271
|
+
return [`# Proofline gate: ${v.task} is **${v.decision}**`, '', `- Task: ${v.title} (phase ${v.phase})`, `- Revision: ${v.head} (tree ${v.tree.slice(0, 12)})`, `- Base: ${v.base_ref} = ${v.base} (${v.base_source})`, `- Contract hash: ${v.contract_hash}`, `- Time: ${v.ts}`, '',
|
|
272
|
+
'## Acceptance criteria', '', '| ID | Verifier | Mandatory | Result | Evidence |', '|---|---|---|---|---|', ...v.results.map(row), '',
|
|
273
|
+
'## Failing tests', '', ...failingTestLines(v), '',
|
|
274
|
+
'## Reused results', '', ...(v.reused.length ? v.reused.map((x) => `- ${x.id} [${x.type}]: ${reusedFrom(x)}; not run again in this gate`) : [`- none${{ off: ' (defaults.reuse_results is off)', fresh: ' (--fresh)' }[v.reuse] || ''}`]), '',
|
|
275
|
+
'## Measured', '', ...(Object.keys(v.measured).length ? Object.entries(v.measured).map(([k, x]) => `- ${k}: ${x}`) : ['- none']), '',
|
|
276
|
+
'## Forbidden actions', '', ...(v.forbidden.length ? v.forbidden.map((f) => `- ${f.rule} in ${f.file}: \`${f.line}\` (${f.why})`) : ['- none detected']),
|
|
277
|
+
`- proofline.json is not scanned (it declares the deny-list); vs base it is ${v.contract_change}${v.contract_change === 'approved' ? `, approved, marker ${v.marker}: ${v.weakenings.join('; ')}` : v.contract_change === 'added' || v.contract_change === 'changed' ? `, judged by the weakening check: ${v.weakenings.length} weakening(s)` : ''}`, '',
|
|
278
|
+
'## Blockers', '', ...(v.blocked.length ? v.blocked.map((b) => `- ${b}`) : ['- none']), '',
|
|
279
|
+
'## Claims vs evidence', '', ...(v.claims.length ? v.claims.map((x) => `- ${x.ok ? 'OK' : 'REJECTED'}: \`${x.claim}\`${x.ok ? '' : '\n - ' + x.issues.join('\n - ')}`) : ['- no claim submitted']), '',
|
|
280
|
+
`## Scope (${v.changed.length} changed files)`, '', ...v.changed.slice(0, 50).map((f) => `- ${f}`), '',
|
|
281
|
+
'## Corrective instruction', '', '```', prompt, '```', '',
|
|
282
|
+
'## Status vocabulary', '', '- PASS: every mandatory criterion passed on this exact revision, with fresh evidence or a PASS reused from this revision on a clean tree (listed under Reused results); no forbidden action; claims match.', '- FAIL: a mandatory criterion failed or was flaky, a forbidden action was found, or a claim contradicts the evidence.', '- BLOCKED: a check could not run, the contract was weakened, or the evidence log was tampered with. Needs the owner.', '',
|
|
283
|
+
'## Known limits', '', ...KNOWN_LIMITS.map((k) => `- ${k}`), ''].join('\n');
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export function gate(root, taskId, opts = {}) {
|
|
287
|
+
const c = C.load(root);
|
|
288
|
+
if (opts.claims) record(root, 'claim', { task: taskId, text: opts.claims }, { actor: 'agent' });
|
|
289
|
+
const v = verifyTask(root, taskId, opts);
|
|
290
|
+
const prompt = steer(v, c);
|
|
291
|
+
const dir = path.join(root, '.proofline', 'reports');
|
|
292
|
+
const stamp = v.ts.replace(/[:.]/g, '-');
|
|
293
|
+
const json = { ...v, corrective_prompt: prompt, known_limits: KNOWN_LIMITS };
|
|
294
|
+
writeJson(path.join(dir, `${taskId}-${stamp}.json`), json);
|
|
295
|
+
fs.writeFileSync(path.join(dir, `${taskId}-${stamp}.md`), reportMd(v, c, prompt));
|
|
296
|
+
writeJson(path.join(dir, `${taskId}-latest.json`), json);
|
|
297
|
+
fs.writeFileSync(path.join(dir, `${taskId}-latest.md`), reportMd(v, c, prompt));
|
|
298
|
+
const e = record(root, 'gate', { task: taskId, decision: v.decision, head: v.head, tree: v.tree, base: v.base, base_source: v.base_source, contract_hash: v.contract_hash, failing: v.failing, blocked: v.blocked, measured: v.measured, ...(v.reused.length ? { reused: v.reused.map((x) => `${x.id}: ${reusedFrom(x)}`) } : {}) });
|
|
299
|
+
return { ...json, evidence_seq: e.seq, report: path.join(dir, `${taskId}-latest.md`) };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ---------- project cycle ----------
|
|
303
|
+
export function taskStatus(root, c = C.load(root)) {
|
|
304
|
+
const log = readLog(root);
|
|
305
|
+
const st = {};
|
|
306
|
+
for (const t of c.tasks) st[t.id] = { status: 'not_started' };
|
|
307
|
+
for (const e of log) {
|
|
308
|
+
if (e.type === 'task_started' && st[e.data.task]?.status === 'not_started') st[e.data.task] = { status: 'in_progress' };
|
|
309
|
+
if (e.type === 'gate' && st[e.data.task]) st[e.data.task] = { status: e.data.decision === 'PASS' ? 'verified' : e.data.decision === 'BLOCKED' ? 'blocked' : 'failed', head: e.data.head, seq: e.seq };
|
|
310
|
+
}
|
|
311
|
+
return st;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export function phaseStatus(root, c = C.load(root)) {
|
|
315
|
+
const st = taskStatus(root, c);
|
|
316
|
+
return C.PHASES.map((p) => {
|
|
317
|
+
const ts = c.tasks.filter((t) => t.phase === p);
|
|
318
|
+
return { phase: p, tasks: ts.length, verified: ts.filter((t) => st[t.id].status === 'verified').length, open: ts.length > 0 && ts.some((t) => st[t.id].status !== 'verified') };
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* The next task. With `owner`, only that owner's tasks and unowned ones are handed out, and the phase gate
|
|
324
|
+
* applies to that lane; depends_on still waits on any owner's task.
|
|
325
|
+
*/
|
|
326
|
+
export function next(root, { owner, skip = [] } = {}) {
|
|
327
|
+
const c = C.load(root);
|
|
328
|
+
C.checkOwner(c, owner);
|
|
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);
|
|
333
|
+
const lane = c.tasks.filter((t) => C.inLane(t, owner));
|
|
334
|
+
const order = (C.topo(c.tasks) || c.tasks).filter((t) => C.inLane(t, owner));
|
|
335
|
+
const firstOpenPhase = C.PHASES.find((p) => lane.some((t) => t.phase === p && open(t)));
|
|
336
|
+
if (!firstOpenPhase && 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})`);
|
|
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.'}` };
|
|
339
|
+
}
|
|
340
|
+
if (!firstOpenPhase) return { done: true, message: 'All tasks verified. Run `proofline gate --all` before release.' };
|
|
341
|
+
for (const t of order) {
|
|
342
|
+
if (!open(t)) continue;
|
|
343
|
+
if (t.phase !== firstOpenPhase) continue; // phase gate: earlier phases must be fully verified
|
|
344
|
+
const deps = (t.depends_on || []).filter((d) => st[d]?.status !== 'verified' && !skipSet.has(d));
|
|
345
|
+
if (deps.length) continue;
|
|
346
|
+
return { done: false, task: t.id, phase: t.phase, status: st[t.id].status, brief: brief(c, t, st) };
|
|
347
|
+
}
|
|
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'})`);
|
|
349
|
+
return { done: false, task: null, message: `No task${owner ? ` for ${owner}` : ''} is unblocked in phase ${firstOpenPhase}: ${waiting.join('; ')}` };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export function brief(c, t, st = {}) {
|
|
353
|
+
const v = (a) => a.verifier;
|
|
354
|
+
const crit = t.criteria.map((a, i) => {
|
|
355
|
+
const x = v(a); const how = {
|
|
356
|
+
command: () => `run \`${x.cmd}\` → exit ${x.expect_exit ?? 0}${x.max_skipped !== undefined ? `, ≤ ${x.max_skipped} skipped` : ''}${x.min_tests ? `, ≥ ${x.min_tests} tests` : ''}, no hung/cancelled tests`,
|
|
357
|
+
test_red_green: () => `write \`${x.test_file}\`${x.test_name ? ` ("${x.test_name}")` : ''} FIRST; Proofline replays it on the base revision where it must FAIL, then on your tree where it must PASS (\`${x.cmd}\`)`,
|
|
358
|
+
test_exists: () => `\`${x.test_file}\` exists${x.test_name ? ` with "${x.test_name}"` : ''}`,
|
|
359
|
+
metric: () => `\`${x.cmd}\` → \`${x.key}\` ${x.op} ${x.value}${c.baseline?.metrics?.[x.name || x.key] !== undefined ? ` (baseline ${c.baseline.metrics[x.name || x.key]})` : ''}`,
|
|
360
|
+
grep: () => `${x.file} ${x.must === 'absent' ? 'must not contain' : 'must contain'} /${x.pattern}/${x.why ? `: ${x.why}` : ''}`,
|
|
361
|
+
wiring: () => `\`${x.symbol}\` must be called on the default path from ${x.entry}`,
|
|
362
|
+
scope: () => `only change: ${(x.allow || t.scope.allow).join(', ')}`,
|
|
363
|
+
docs: () => `\`${x.cmd}\` leaves no diff in ${x.paths.join(', ')}`,
|
|
364
|
+
pushed: () => { const p = pushTarget(c, t, x); return `committed, tree clean, HEAD == ${p.remote}/${p.branch} or a commit under it`; },
|
|
365
|
+
file_exists: () => `${x.path} exists`,
|
|
366
|
+
}[x.type]();
|
|
367
|
+
return `${i + 1}. **${a.id}**${a.mandatory === false ? ' (advisory)' : ''}: ${a.statement || ''}\n Verifier (${x.type}): ${how}`;
|
|
368
|
+
}).join('\n');
|
|
369
|
+
const f = { ...C.DEFAULT_FORBIDDEN, ...(c.forbidden || {}) };
|
|
370
|
+
return [`# TASK ${t.id}: ${t.title}`, ...(t.owner !== undefined ? [`Owner: ${t.owner}.`] : []), `Phase: ${t.phase}. Depends on: ${(t.depends_on || []).join(', ') || 'none'} (all verified). Requirements: ${(t.requirements || []).join(', ') || '-'}.`, '',
|
|
371
|
+
t.description || '', '', `## Scope (allowed paths)\n${t.scope.allow.map((s) => `- ${s}`).join('\n')}`, '',
|
|
372
|
+
`## Acceptance criteria (each is checked independently; your report is not evidence)\n${crit}`, '',
|
|
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.`, '',
|
|
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.`, '',
|
|
375
|
+
'## Owner rules', ...(c.owner_rules || []).map((r) => `- ${r}`), '',
|
|
376
|
+
'## Stop rules', ...(Array.isArray(t.stop_rules) ? t.stop_rules : []).map((s) => `- ${s}`), '',
|
|
377
|
+
'## Report line (exact format)', `${t.id} | done/partial/not done/blocked | <commit> | <test names> | <metric=value vs target> | <what is missing>`].join('\n');
|
|
378
|
+
}
|
package/src/evidence.mjs
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Append-only, hash-chained evidence log: .proofline/evidence.jsonl
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { sha256, canon } from './util.mjs';
|
|
5
|
+
|
|
6
|
+
export const GENESIS = '0'.repeat(64);
|
|
7
|
+
export const logPath = (root) => path.join(root, '.proofline', 'evidence.jsonl');
|
|
8
|
+
|
|
9
|
+
export function readLog(root) {
|
|
10
|
+
const f = logPath(root);
|
|
11
|
+
if (!fs.existsSync(f)) return [];
|
|
12
|
+
return fs.readFileSync(f, 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function entryHash(e) {
|
|
16
|
+
const { hash, ...body } = e;
|
|
17
|
+
return sha256(canon(body));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Append an event. Returns the stored entry. Writes with O_APPEND + fsync. */
|
|
21
|
+
export function record(root, type, data, { actor = 'proofline' } = {}) {
|
|
22
|
+
const log = readLog(root);
|
|
23
|
+
const prev = log.length ? log.at(-1).hash : GENESIS;
|
|
24
|
+
const e = { seq: log.length, ts: new Date().toISOString(), type, actor, data, prev };
|
|
25
|
+
e.hash = entryHash(e);
|
|
26
|
+
const f = logPath(root);
|
|
27
|
+
fs.mkdirSync(path.dirname(f), { recursive: true });
|
|
28
|
+
const fd = fs.openSync(f, 'a');
|
|
29
|
+
try { fs.writeSync(fd, JSON.stringify(e) + '\n'); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
|
|
30
|
+
return e;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Verify the chain. Any edit, deletion or reorder breaks it. */
|
|
34
|
+
export function verifyChain(root) {
|
|
35
|
+
const log = readLog(root);
|
|
36
|
+
let prev = GENESIS;
|
|
37
|
+
for (let i = 0; i < log.length; i++) {
|
|
38
|
+
const e = log[i];
|
|
39
|
+
if (e.seq !== i) return { ok: false, at: i, reason: 'sequence gap or reorder' };
|
|
40
|
+
if (e.prev !== prev) return { ok: false, at: i, reason: 'prev hash mismatch (entry removed or edited)' };
|
|
41
|
+
if (entryHash(e) !== e.hash) return { ok: false, at: i, reason: 'entry hash mismatch (entry edited)' };
|
|
42
|
+
prev = e.hash;
|
|
43
|
+
}
|
|
44
|
+
return { ok: true, entries: log.length, head: prev };
|
|
45
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Forbidden-action scan over the diff (base → working tree). Deterministic, pattern based.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { git, matchAny } from './util.mjs';
|
|
5
|
+
import { TEST_FILE_RE, diffHunks } from './checks.mjs';
|
|
6
|
+
|
|
7
|
+
const SKIP_RE = /\.(skip|only|todo)\s*\(|\{\s*(skip|only|todo)\s*:\s*(true|['"`])|\b(xit|xdescribe|fit|fdescribe)\s*\(|\btest\.todo\b|@pytest\.mark\.skip|\bt\.skip\s*\(/;
|
|
8
|
+
const TIMEOUT_RE = /timeout[^\d\n]{0,24}(\d[\d_]*)/i;
|
|
9
|
+
const ASSERT_RE = /\bassert\b|\bexpect\s*\(|\bt\.(is|ok|deepEqual|equal|throws|true|false)\b/;
|
|
10
|
+
const NOVERIFY_RE = /(--no-verify\b|\bgit\s+(commit|push)\b[^\n]*\s-n\b)/;
|
|
11
|
+
// The contract declares the deny-list, so its own text always matches it. A contract change is
|
|
12
|
+
// judged by weakenings() against the base contract instead; local state is never part of the work.
|
|
13
|
+
export const CONTRACT_PATHS = ['proofline.json', '.proofline/**'];
|
|
14
|
+
|
|
15
|
+
export function scanForbidden(root, base, rules = {}) {
|
|
16
|
+
const findings = [];
|
|
17
|
+
if (!base) return findings;
|
|
18
|
+
const ign = [...CONTRACT_PATHS, ...(rules.ignore_paths || [])];
|
|
19
|
+
const files = diffHunks(root, base).filter((f) => !matchAny(f.file, ign));
|
|
20
|
+
for (const f of files) {
|
|
21
|
+
const isTest = TEST_FILE_RE.test(f.file);
|
|
22
|
+
let addA = 0, delA = 0;
|
|
23
|
+
for (const h of f.hunks) {
|
|
24
|
+
for (const l of h.add) {
|
|
25
|
+
if (rules.test_skip_only !== false && isTest && SKIP_RE.test(l)) findings.push({ rule: 'test_skip_only', file: f.file, line: l.trim(), why: 'skipping/focusing tests hides failures' });
|
|
26
|
+
if (rules.no_verify !== false && NOVERIFY_RE.test(l)) findings.push({ rule: 'no_verify', file: f.file, line: l.trim(), why: 'bypasses hooks/guards' });
|
|
27
|
+
for (const e of rules.bypass_env || []) if (l.includes(e)) findings.push({ rule: 'bypass_env', file: f.file, line: l.trim(), why: `guard-bypass variable ${e}` });
|
|
28
|
+
if (isTest && ASSERT_RE.test(l)) addA++;
|
|
29
|
+
}
|
|
30
|
+
for (const l of h.del) if (isTest && ASSERT_RE.test(l)) delA++;
|
|
31
|
+
if (rules.timeout_raise !== false) {
|
|
32
|
+
const num = (ls) => ls.map((l) => l.match(TIMEOUT_RE)).filter(Boolean).map((m) => +m[1].replace(/_/g, ''));
|
|
33
|
+
const before = num(h.del), after = num(h.add);
|
|
34
|
+
if (before.length && after.length && Math.max(...after) > Math.max(...before)) findings.push({ rule: 'timeout_raise', file: f.file, line: `${Math.max(...before)} → ${Math.max(...after)}`, why: 'raising a timeout treats the symptom; find why it is slow' });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (rules.assertion_removal !== false && isTest && delA > addA) findings.push({ rule: 'assertion_removal', file: f.file, line: `-${delA} +${addA} assertion lines`, why: 'net loss of assertions weakens the test' });
|
|
38
|
+
}
|
|
39
|
+
if (rules.test_file_deletion !== false) {
|
|
40
|
+
for (const l of git(['diff', '--name-status', base, '--'], root).out.split('\n').filter(Boolean)) {
|
|
41
|
+
const [st, a] = l.split('\t');
|
|
42
|
+
if (st === 'D' && TEST_FILE_RE.test(a) && !matchAny(a, ign)) findings.push({ rule: 'test_file_deletion', file: a, line: 'deleted', why: 'deleting a test removes coverage (e.g. a crash guard)' });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
for (const pv of rules.protected_values || []) {
|
|
46
|
+
const re = new RegExp(pv.pattern, 'm');
|
|
47
|
+
const old = git(['show', `${base}:${pv.file}`], root);
|
|
48
|
+
let now = ''; try { now = fs.readFileSync(path.join(root, pv.file), 'utf8'); } catch {}
|
|
49
|
+
const a = old.ok ? old.out.match(re) : null, b = now.match(re);
|
|
50
|
+
if (a && !b) findings.push({ rule: 'protected_value', file: pv.file, line: `/${pv.pattern}/ removed`, why: pv.why || 'protected value removed' });
|
|
51
|
+
else if (a && b) {
|
|
52
|
+
const x = +a[1], y = +b[1];
|
|
53
|
+
if ((pv.direction || 'no_decrease') === 'no_decrease' ? y < x : y > x) findings.push({ rule: 'protected_value', file: pv.file, line: `${x} → ${y}`, why: pv.why || 'silent scope cut (cap/size/target changed)' });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return findings;
|
|
57
|
+
}
|