@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/contract.mjs
ADDED
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
// Contract + plan model: proofline.json at the repo root (committed).
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { readJson, writeJson, sha256, canon, git, matchAny } from './util.mjs';
|
|
5
|
+
|
|
6
|
+
export const SCHEMA = 'proofline/1';
|
|
7
|
+
export const PHASES = ['intake', 'design', 'plan', 'implement', 'verify', 'integrate', 'release', 'monitor'];
|
|
8
|
+
export const VERIFIERS = ['command', 'test_red_green', 'test_exists', 'metric', 'grep', 'wiring', 'scope', 'docs', 'pushed', 'file_exists'];
|
|
9
|
+
export const STATUS = {
|
|
10
|
+
PASS: 'PASS', FAIL: 'FAIL', BLOCKED: 'BLOCKED',
|
|
11
|
+
};
|
|
12
|
+
// Task status vocabulary (derived, never self-declared)
|
|
13
|
+
export const TASK_STATUS = ['not_started', 'in_progress', 'verified', 'failed', 'blocked'];
|
|
14
|
+
|
|
15
|
+
/** 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
|
+
|
|
35
|
+
export const DEFAULT_FORBIDDEN = {
|
|
36
|
+
test_skip_only: true, // .skip( .only( {skip: test.todo it.skip xit(
|
|
37
|
+
timeout_raise: true, // any numeric timeout increased in the diff
|
|
38
|
+
assertion_removal: true, // net loss of assert/expect lines in test files
|
|
39
|
+
test_file_deletion: true, // deleted test files
|
|
40
|
+
no_verify: true, // --no-verify / -n on git commit|push in added lines
|
|
41
|
+
bypass_env: ['SKIP_HOOKS', 'HUSKY=0', 'PROOFLINE_SKIP', 'CI_SKIP'],
|
|
42
|
+
protected_values: [], // [{file, pattern (one capture group, a number), direction: "no_decrease"|"no_increase"}]
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const contractPath = (root) => path.join(root, 'proofline.json');
|
|
46
|
+
|
|
47
|
+
export function load(root) {
|
|
48
|
+
const c = readJson(contractPath(root));
|
|
49
|
+
if (!c) throw new Error(`no proofline.json in ${root} (run: proofline init)`);
|
|
50
|
+
return c;
|
|
51
|
+
}
|
|
52
|
+
export const save = (root, c) => writeJson(contractPath(root), c);
|
|
53
|
+
export const contractHash = (c) => sha256(canon(c));
|
|
54
|
+
|
|
55
|
+
export function skeleton(project = path.basename(process.cwd())) {
|
|
56
|
+
return {
|
|
57
|
+
schema: SCHEMA,
|
|
58
|
+
project,
|
|
59
|
+
owner_rules: [
|
|
60
|
+
"Done means ALL evidence: implemented as specified (task id), a test that FAILED before and passes now, suites green, measured number vs target, docs regenerated, committed AND pushed to the contract's branch.",
|
|
61
|
+
'Never defer: no "later", "follow-up", "out of scope". If blocked: what blocks, what you tried, what you need; then continue with the next task.',
|
|
62
|
+
'Root cause, then fix: file:line + mechanism. No weakening tests, raising timeouts, skips, relaxed assertions.',
|
|
63
|
+
'No silent scope cuts: never lower targets, caps or test sizes without owner approval.',
|
|
64
|
+
'Never bypass guards: no --no-verify, no bypass env vars, no skip flags.',
|
|
65
|
+
"Deliver continuously: one task per commit, pushed to the contract's branch as it goes green.",
|
|
66
|
+
'Honest reporting: TASK-ID | status | commit | tests | measured vs target | missing.',
|
|
67
|
+
],
|
|
68
|
+
defaults: { base: 'origin/main', branch: 'main', flaky_reruns: 1, max_corrective_cycles: 3, regression_tolerance_pct: 20 },
|
|
69
|
+
forbidden: { ...DEFAULT_FORBIDDEN },
|
|
70
|
+
baseline: { metrics: {} },
|
|
71
|
+
phases: PHASES.map((id) => ({ id })),
|
|
72
|
+
tasks: [],
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Turn a task description into a contract task. Criteria that cannot be machine-verified are flagged so freeze refuses. */
|
|
77
|
+
export function makeTask({ id, title, phase = 'implement', owner, depends_on = [], requirements = [], description = '', scope = ['**'], criteria = [], stop_rules = [] }) {
|
|
78
|
+
if (!id || !title) throw new Error('task needs id and title');
|
|
79
|
+
return {
|
|
80
|
+
id, title, phase, ...(owner !== undefined ? { owner } : {}), depends_on, requirements, description,
|
|
81
|
+
// a spec may give the allow list, or the contract's own shape { allow: [...] }; either way one level of allow
|
|
82
|
+
scope: { allow: Array.isArray(scope) ? scope : Array.isArray(scope?.allow) ? scope.allow : [String(scope)] },
|
|
83
|
+
criteria: criteria.map((c, i) => ({ id: c.id || `${id}-AC${String(i + 1).padStart(2, '0')}`, mandatory: c.mandatory !== false, ...c })),
|
|
84
|
+
stop_rules: stop_rules.length ? stop_rules : ['If any metric misses its threshold: STOP, report the measured number, a per-stage trace and file:line. Do not start the next task.'],
|
|
85
|
+
};
|
|
86
|
+
}
|
|
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
|
+
|
|
121
|
+
const isName = (x) => typeof x === 'string' && x.trim() !== '';
|
|
122
|
+
|
|
123
|
+
/** A task belongs to an owner's lane when it is theirs or has no owner. No owner given: every task. */
|
|
124
|
+
export const inLane = (t, owner) => !owner || t.owner === undefined || t.owner === owner;
|
|
125
|
+
|
|
126
|
+
/** The remote and branch the work is pushed to, and the default base: defaults.base, else <remote>/<branch>. */
|
|
127
|
+
export const remoteOf = (c) => c.defaults?.remote || 'origin';
|
|
128
|
+
export const branchOf = (c) => c.defaults?.branch || 'main';
|
|
129
|
+
export const defaultBase = (c) => c.defaults?.base || `${remoteOf(c)}/${branchOf(c)}`;
|
|
130
|
+
|
|
131
|
+
/** An --owner name must be in defaults.owners when the contract has that list. */
|
|
132
|
+
export function checkOwner(c, owner) {
|
|
133
|
+
if (owner === undefined) return;
|
|
134
|
+
if (!isName(owner)) throw new Error('--owner needs a name');
|
|
135
|
+
const owners = c.defaults?.owners;
|
|
136
|
+
if (Array.isArray(owners) && !owners.includes(owner)) throw new Error(`owner ${owner} is not in defaults.owners (${owners.join(', ')})`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Validation: returns list of problems (empty == valid). */
|
|
140
|
+
export function validate(c) {
|
|
141
|
+
const errs = [];
|
|
142
|
+
if (c.schema !== SCHEMA) errs.push(`schema must be ${SCHEMA}`);
|
|
143
|
+
if (c.defaults?.remote !== undefined && !isName(c.defaults.remote)) errs.push('defaults.remote must be a remote name (a string)');
|
|
144
|
+
const maxLoad = c.defaults?.max_load_per_cpu;
|
|
145
|
+
if (maxLoad !== undefined && !(typeof maxLoad === 'number' && maxLoad > 0)) errs.push('defaults.max_load_per_cpu must be a positive number');
|
|
146
|
+
const reuse = c.defaults?.reuse_results, maxAge = c.defaults?.reuse_max_age_h;
|
|
147
|
+
if (reuse !== undefined && typeof reuse !== 'boolean') errs.push('defaults.reuse_results must be true or false');
|
|
148
|
+
if (maxAge !== undefined && !(typeof maxAge === 'number' && maxAge > 0)) errs.push('defaults.reuse_max_age_h must be a positive number of hours');
|
|
149
|
+
const owners = c.defaults?.owners;
|
|
150
|
+
if (owners !== undefined && !(Array.isArray(owners) && owners.every(isName))) errs.push('defaults.owners must be a list of names');
|
|
151
|
+
const ids = new Set();
|
|
152
|
+
for (const t of c.tasks || []) {
|
|
153
|
+
if (ids.has(t.id)) errs.push(`duplicate task id ${t.id}`);
|
|
154
|
+
ids.add(t.id);
|
|
155
|
+
if (!PHASES.includes(t.phase)) errs.push(`${t.id}: unknown phase ${t.phase}`);
|
|
156
|
+
if (t.owner !== undefined && !isName(t.owner)) errs.push(`${t.id}: owner must be a name`);
|
|
157
|
+
else if (t.owner !== undefined && Array.isArray(owners) && !owners.includes(t.owner)) errs.push(`${t.id}: owner ${t.owner} is not in defaults.owners (${owners.join(', ')})`);
|
|
158
|
+
if (!(Array.isArray(t.scope?.allow) && t.scope.allow.every((g) => typeof g === 'string' && g))) errs.push(`${t.id}: scope.allow must be a list of path patterns`);
|
|
159
|
+
if (!t.criteria?.length) errs.push(`${t.id}: no acceptance criteria`);
|
|
160
|
+
for (const a of t.criteria || []) {
|
|
161
|
+
const v = a.verifier;
|
|
162
|
+
if (!v || !VERIFIERS.includes(v.type)) { errs.push(`${t.id}/${a.id}: needs a deterministic verifier (one of ${VERIFIERS.join(', ')})`); continue; }
|
|
163
|
+
const need = { command: ['cmd'], test_red_green: ['test_file', 'cmd'], test_exists: ['test_file'], metric: ['cmd', 'key', 'op', 'value'], grep: ['file', 'pattern'], wiring: ['entry', 'symbol'], docs: ['cmd', 'paths'], file_exists: ['path'] }[v.type] || [];
|
|
164
|
+
for (const k of need) if (v[k] === undefined) errs.push(`${t.id}/${a.id}: ${v.type} verifier missing "${k}"`);
|
|
165
|
+
if (v.type === 'metric' && !['<', '<=', '>', '>=', '=='].includes(v.op)) errs.push(`${t.id}/${a.id}: bad op ${v.op}`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
for (const t of c.tasks || []) for (const d of t.depends_on || []) if (!ids.has(d)) errs.push(`${t.id}: depends on unknown ${d}`);
|
|
169
|
+
if (topo(c.tasks || []) === null) errs.push('dependency cycle in task graph');
|
|
170
|
+
return errs;
|
|
171
|
+
}
|
|
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
|
+
|
|
216
|
+
export function topo(tasks) {
|
|
217
|
+
const by = new Map(tasks.map((t) => [t.id, t])); const out = []; const st = new Map();
|
|
218
|
+
const visit = (id) => {
|
|
219
|
+
if (st.get(id) === 2) return true; if (st.get(id) === 1) return false;
|
|
220
|
+
st.set(id, 1);
|
|
221
|
+
for (const d of by.get(id)?.depends_on || []) if (by.has(d) && !visit(d)) return false;
|
|
222
|
+
st.set(id, 2); out.push(by.get(id)); return true;
|
|
223
|
+
};
|
|
224
|
+
// stable: phase order, then declaration order
|
|
225
|
+
const ordered = [...tasks].sort((a, b) => PHASES.indexOf(a.phase) - PHASES.indexOf(b.phase));
|
|
226
|
+
for (const t of ordered) if (!visit(t.id)) return null;
|
|
227
|
+
return out;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const looser = (op, before, after) => (op.startsWith('<') ? after > before : op.startsWith('>') ? after < before : after !== before);
|
|
231
|
+
|
|
232
|
+
/** Compare a base contract to the current one: anything weakened needs the owner. */
|
|
233
|
+
export function weakenings(base, cur) {
|
|
234
|
+
if (!base) return [];
|
|
235
|
+
const out = [];
|
|
236
|
+
const bt = new Map((base.tasks || []).map((t) => [t.id, t]));
|
|
237
|
+
for (const [id, b] of bt) {
|
|
238
|
+
const t = (cur.tasks || []).find((x) => x.id === id);
|
|
239
|
+
if (!t) { out.push(`task ${id} removed from contract`); continue; }
|
|
240
|
+
for (const a of b.criteria || []) {
|
|
241
|
+
const n = t.criteria.find((x) => x.id === a.id);
|
|
242
|
+
if (!n) { out.push(`${id}/${a.id} criterion removed`); continue; }
|
|
243
|
+
if (a.mandatory && !n.mandatory) out.push(`${id}/${a.id} made non-mandatory`);
|
|
244
|
+
if (a.verifier?.type !== n.verifier?.type) out.push(`${id}/${a.id} verifier type changed ${a.verifier?.type}→${n.verifier?.type}`);
|
|
245
|
+
if (a.verifier?.type === 'metric' && (a.verifier.op !== n.verifier.op || looser(a.verifier.op, a.verifier.value, n.verifier.value)))
|
|
246
|
+
out.push(`${id}/${a.id} threshold loosened ${a.verifier.key} ${a.verifier.op} ${a.verifier.value} → ${n.verifier.op} ${n.verifier.value}`);
|
|
247
|
+
if (a.verifier?.cmd && n.verifier?.cmd && a.verifier.cmd !== n.verifier.cmd) out.push(`${id}/${a.id} command changed: "${a.verifier.cmd}" → "${n.verifier.cmd}"`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
const bl = base.defaults?.max_load_per_cpu, cl = cur.defaults?.max_load_per_cpu;
|
|
251
|
+
if (typeof bl === 'number' && cl === undefined) out.push(`defaults.max_load_per_cpu removed (was ${bl})`);
|
|
252
|
+
else if (typeof bl === 'number' && !(cl <= bl)) out.push(`defaults.max_load_per_cpu raised ${bl} → ${cl}`);
|
|
253
|
+
const bf = base.forbidden || {}; const cf = cur.forbidden || {};
|
|
254
|
+
for (const k of Object.keys(bf)) if (bf[k] && (cf[k] === false || cf[k] === undefined)) out.push(`forbidden rule ${k} disabled`);
|
|
255
|
+
for (const e of bf.bypass_env || []) if (!(cf.bypass_env || []).includes(e)) out.push(`bypass env ${e} removed from forbidden list`);
|
|
256
|
+
return out;
|
|
257
|
+
}
|
|
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
|
+
|
|
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
|
+
}
|