pincer-workflow 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -6
- package/bin/pincer.js +17 -1
- package/package.json +3 -3
- package/template/.agents/skills/pincer-code/SKILL.md +85 -14
- package/template/.agents/skills/pincer-evaluate/SKILL.md +42 -13
- package/template/.agents/skills/pincer-narrow/SKILL.md +43 -12
- package/template/.agents/skills/pincer-plan/SKILL.md +12 -4
- package/template/.agents/skills/pincer-release/SKILL.md +18 -0
- package/template/.agents/skills/pincer-status/SKILL.md +12 -2
- package/template/.claude/commands/pincer-code.md +85 -14
- package/template/.claude/commands/pincer-evaluate.md +42 -13
- package/template/.claude/commands/pincer-narrow.md +43 -12
- package/template/.claude/commands/pincer-plan.md +12 -4
- package/template/.claude/commands/pincer-release.md +18 -0
- package/template/.claude/commands/pincer-status.md +12 -2
- package/template/.claude/hooks/hook-policy.cjs +13 -6
- package/template/.claude/references/prd-template.md +11 -4
- package/template/.codex/README.md +1 -1
- package/template/.github/prompts/pincer-code.prompt.md +85 -14
- package/template/.github/prompts/pincer-evaluate.prompt.md +42 -13
- package/template/.github/prompts/pincer-narrow.prompt.md +43 -12
- package/template/.github/prompts/pincer-plan.prompt.md +12 -4
- package/template/.github/prompts/pincer-release.prompt.md +18 -0
- package/template/.github/prompts/pincer-status.prompt.md +12 -2
- package/template/AGENTS.md +17 -1
- package/template/docs/dry-run-checklist.md +30 -3
- package/template/docs/release-checklist.md +3 -1
- package/template/docs/runtime-contracts.md +1342 -96
- package/template/scripts/pincer-evidence.cjs +9 -7
- package/template/scripts/pincer-runtime/adopt.cjs +132 -0
- package/template/scripts/pincer-runtime/agreement.cjs +240 -0
- package/template/scripts/pincer-runtime/authorization.cjs +167 -0
- package/template/scripts/pincer-runtime/changes.cjs +517 -0
- package/template/scripts/pincer-runtime/checks.cjs +48 -0
- package/template/scripts/pincer-runtime/coverage.cjs +361 -0
- package/template/scripts/pincer-runtime/dispositions.cjs +95 -0
- package/template/scripts/pincer-runtime/evidence.cjs +303 -18
- package/template/scripts/pincer-runtime/gates.cjs +73 -0
- package/template/scripts/pincer-runtime/identity.cjs +21 -4
- package/template/scripts/pincer-runtime/impact.cjs +177 -0
- package/template/scripts/pincer-runtime/io.cjs +41 -0
- package/template/scripts/pincer-runtime/lifecycle.cjs +34 -12
- package/template/scripts/pincer-runtime/locator.cjs +158 -0
- package/template/scripts/pincer-runtime/migrate.cjs +140 -63
- package/template/scripts/pincer-runtime/parse.cjs +20 -1
- package/template/scripts/pincer-runtime/phases.cjs +245 -0
- package/template/scripts/pincer-runtime/readiness.cjs +9 -1
- package/template/scripts/pincer-runtime/requirements.cjs +255 -0
- package/template/scripts/pincer-runtime/resume.cjs +205 -0
- package/template/scripts/pincer-runtime/routing.cjs +54 -0
- package/template/scripts/pincer-runtime/runner.cjs +24 -6
- package/template/scripts/pincer-runtime/state.cjs +29 -7
- package/template/scripts/pincer-runtime/status.cjs +178 -22
- package/template/scripts/pincer-runtime/transaction.cjs +200 -0
- package/template/scripts/pincer-runtime/transitions.cjs +134 -0
- package/template/scripts/pincer-runtime.cjs +387 -76
- package/template/scripts/pincer-status.sh +1 -1
- package/template/scripts/pincer-ticket.sh +1 -1
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// PINCER runtime — structural impact (docs/runtime-contracts.md, "Strict coverage"
|
|
3
|
+
// → "Coverage and impact commands"). Compares the current authored inputs of a
|
|
4
|
+
// strict change with a retained agreement (`--from G-NN|A-NN`, else the latest
|
|
5
|
+
// authorization's agreement, else the latest agreement with an inventory) and
|
|
6
|
+
// reports added, removed, changed and unchanged requirements and scenarios, changed
|
|
7
|
+
// links, scope entries, declarations and tickets, the affected scenarios, tickets
|
|
8
|
+
// and checks with the reason each is included, dependency dependents separately,
|
|
9
|
+
// and unscoped PRD changes. History it cannot read is `unavailable`, never "no
|
|
10
|
+
// impact". Read-only and structural: it never judges semantics, approves, launches
|
|
11
|
+
// or rewrites anything, and it does not touch evidence freshness.
|
|
12
|
+
const changes = require('./changes.cjs');
|
|
13
|
+
const agreement = require('./agreement.cjs');
|
|
14
|
+
const coverage = require('./coverage.cjs');
|
|
15
|
+
const requirements = require('./requirements.cjs');
|
|
16
|
+
const parse = require('./parse.cjs');
|
|
17
|
+
const { nowIso } = require('./fsutil.cjs');
|
|
18
|
+
|
|
19
|
+
const SCHEMA = 1;
|
|
20
|
+
const FRESHNESS = 'a narrow impact is not permission to reuse evidence whose source identity changed';
|
|
21
|
+
const sortIds = requirements.sortIds;
|
|
22
|
+
const byNumber = (a, b) => Number(a.slice(2)) - Number(b.slice(2));
|
|
23
|
+
const setDiff = (before, after) => ({ added: sortIds(after.filter(x => !before.includes(x))), removed: sortIds(before.filter(x => !after.includes(x))) });
|
|
24
|
+
|
|
25
|
+
// Resolve the baseline entry. Returns { entry, authorization } or { reason }.
|
|
26
|
+
function baselineEntry(record, from) {
|
|
27
|
+
const entryFor = gid => record.agreements.find(g => g.id === gid) || null;
|
|
28
|
+
if (from) {
|
|
29
|
+
if (/^A-[0-9]{2,6}$/.test(from)) {
|
|
30
|
+
const a = record.authorizations.find(x => x.id === from);
|
|
31
|
+
if (!a) return { reason: `no authorization ${from} on change ${record.change} (recorded: ${record.authorizations.map(x => x.id).join(', ') || 'none'})` };
|
|
32
|
+
return { entry: entryFor(a.agreement), authorization: a.id };
|
|
33
|
+
}
|
|
34
|
+
if (/^G-[0-9]{2,6}$/.test(from)) {
|
|
35
|
+
const g = entryFor(from);
|
|
36
|
+
if (!g) return { reason: `no agreement ${from} on change ${record.change} (recorded: ${record.agreements.map(x => x.id).join(', ') || 'none'})` };
|
|
37
|
+
const auth = record.authorizations.filter(a => a.agreement === g.id).map(a => a.id).pop() || null;
|
|
38
|
+
return { entry: g, authorization: auth };
|
|
39
|
+
}
|
|
40
|
+
return { reason: `--from must name an agreement G-NN or an authorization A-NN (got ${from})` };
|
|
41
|
+
}
|
|
42
|
+
const latestAuth = record.authorizations.length ? record.authorizations[record.authorizations.length - 1] : null;
|
|
43
|
+
if (latestAuth) return { entry: entryFor(latestAuth.agreement), authorization: latestAuth.id };
|
|
44
|
+
for (let i = record.agreements.length - 1; i >= 0; i--) if (record.agreements[i].inventory) return { entry: record.agreements[i], authorization: null };
|
|
45
|
+
return { reason: 'no retained agreement carries an inventory snapshot' };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// compute(root, record, { from }) → the impact report, or { code, problem } for invalid input.
|
|
49
|
+
function compute(root, record, { from = null } = {}) {
|
|
50
|
+
const base = { schema: SCHEMA, runtime: changes.RUNTIME_STRICT, generated: nowIso(), root, change: record.change, baseline: null, current: null, verdict: 'unavailable', reason: null, requirements: null, scenarios: null, links: null, scope: null, checks: null, tickets: null, affected: null, unscoped: null, freshness: { note: FRESHNESS } };
|
|
51
|
+
if (!changes.isStrict(record)) return { ...base, reason: `strict coverage not adopted by change ${record.change}; impact needs a retained inventory (node scripts/pincer-runtime.cjs coverage adopt --preview --change ${record.change})` };
|
|
52
|
+
const now = agreement.compute(root, record);
|
|
53
|
+
if (now.code) return { code: now.code, problem: now.problem };
|
|
54
|
+
base.current = { digest: now.digest, inventory: now.inventory.digest, coverage: now.coverage.digest };
|
|
55
|
+
const resolved = baselineEntry(record, from);
|
|
56
|
+
if (resolved.reason) return { ...base, reason: resolved.reason };
|
|
57
|
+
const entry = resolved.entry;
|
|
58
|
+
if (!entry.inventory) return { ...base, baseline: { agreement: entry.id, authorization: resolved.authorization, recorded: entry.recorded, digest: entry.digest }, reason: `agreement ${entry.id} predates strict coverage (no inventory snapshot); the earliest comparable agreement is ${record.coverage.agreement}` };
|
|
59
|
+
const snap = agreement.readSnapshot(root, record, entry);
|
|
60
|
+
if (snap.code) return { ...base, baseline: { agreement: entry.id, authorization: resolved.authorization, recorded: entry.recorded, digest: entry.digest }, reason: `${snap.code}: ${snap.problem}` };
|
|
61
|
+
const s = snap.snapshot;
|
|
62
|
+
base.baseline = { agreement: entry.id, authorization: resolved.authorization, recorded: entry.recorded, digest: entry.digest };
|
|
63
|
+
const baseMap = coverage.validateText(s.coverage.text, { change: record.change, prd: s.prd.path });
|
|
64
|
+
if (baseMap.problem) return { ...base, reason: `the snapshot coverage map of ${entry.id} cannot be read: ${baseMap.problem}` };
|
|
65
|
+
const curMap = now.graphInputs.map.map;
|
|
66
|
+
const inv = requirements.difference(s.inventory, now.inventory);
|
|
67
|
+
const scenarios = { added: inv.scenarios.added, removed: inv.scenarios.removed.map(id => ({ id, tombstone: Boolean(curMap.scope[id] && curMap.scope[id].disposition === 'removed') })), changed: inv.scenarios.changed, unchanged: inv.scenarios.unchanged };
|
|
68
|
+
const reqs = inv.requirements;
|
|
69
|
+
// Links: scenarios present in both maps whose tickets or checks changed.
|
|
70
|
+
// Links: rows added or removed for a scenario, and scenarios present in both maps whose tickets or checks changed.
|
|
71
|
+
const links = { added: sortIds(Object.keys(curMap.scenarios).filter(id => !baseMap.map.scenarios[id])), removed: sortIds(Object.keys(baseMap.map.scenarios).filter(id => !curMap.scenarios[id])), changed: [] };
|
|
72
|
+
for (const id of sortIds(Object.keys(curMap.scenarios))) {
|
|
73
|
+
const b = baseMap.map.scenarios[id];
|
|
74
|
+
if (!b) continue;
|
|
75
|
+
const t = setDiff(b.tickets, curMap.scenarios[id].tickets), c = setDiff(b.checks, curMap.scenarios[id].checks);
|
|
76
|
+
if (t.added.length || t.removed.length || c.added.length || c.removed.length) links.changed.push({ id, tickets: t, checks: c });
|
|
77
|
+
}
|
|
78
|
+
const scope = { added: [], removed: [], changed: [] };
|
|
79
|
+
for (const id of sortIds(Object.keys(curMap.scope))) { const b = baseMap.map.scope[id]; if (!b) scope.added.push({ id, disposition: curMap.scope[id].disposition }); else if (b.disposition !== curMap.scope[id].disposition || b.decision !== curMap.scope[id].decision || b.prior !== curMap.scope[id].prior) scope.changed.push({ id, from: b.disposition, to: curMap.scope[id].disposition }); }
|
|
80
|
+
scope.removed = sortIds(Object.keys(baseMap.map.scope).filter(id => !curMap.scope[id]));
|
|
81
|
+
const checks = { added: [], removed: [], changed: [] };
|
|
82
|
+
for (const id of Object.keys(curMap.checks).sort()) {
|
|
83
|
+
const b = baseMap.map.checks[id];
|
|
84
|
+
if (!b) { checks.added.push(id); continue; }
|
|
85
|
+
const parts = ['kind', 'command', 'timeout', 'cwd', 'obligation', 'required'].filter(k => b[k] !== curMap.checks[id][k]);
|
|
86
|
+
if (parts.length) checks.changed.push({ id, parts });
|
|
87
|
+
}
|
|
88
|
+
checks.removed = Object.keys(baseMap.map.checks).filter(id => !curMap.checks[id]).sort();
|
|
89
|
+
const tickets = { added: [], removed: [], changed: [] };
|
|
90
|
+
const ticketDiff = agreement.difference(s, now);
|
|
91
|
+
tickets.added = ticketDiff.tickets_added; tickets.removed = ticketDiff.tickets_removed;
|
|
92
|
+
const changedTickets = new Map(ticketDiff.tickets_changed.map(t => [t.id, [...t.parts]]));
|
|
93
|
+
for (const id of Object.keys(curMap.tickets).sort(byNumber)) {
|
|
94
|
+
const b = baseMap.map.tickets[id];
|
|
95
|
+
if (!b) { if (!tickets.added.includes(id)) changedTickets.set(id, [...(changedTickets.get(id) || []), 'classification']); continue; }
|
|
96
|
+
if (b.role !== curMap.tickets[id].role || b.rationale !== curMap.tickets[id].rationale) changedTickets.set(id, [...(changedTickets.get(id) || []), 'classification']);
|
|
97
|
+
}
|
|
98
|
+
tickets.changed = [...changedTickets.entries()].sort(([a], [b]) => byNumber(a, b)).map(([id, parts]) => ({ id, parts }));
|
|
99
|
+
// Affected: scenarios by text/owner change, link change, changed declaration or changed ticket, addition, or scope change.
|
|
100
|
+
const affectedScenarios = new Map();
|
|
101
|
+
const because = (id, why) => { if (!affectedScenarios.has(id)) affectedScenarios.set(id, []); if (!affectedScenarios.get(id).includes(why)) affectedScenarios.get(id).push(why); };
|
|
102
|
+
for (const c of scenarios.changed) because(c.id, `scenario ${c.parts.join(' and ')} changed`);
|
|
103
|
+
for (const id of scenarios.added) because(id, 'scenario added');
|
|
104
|
+
for (const l of links.changed) because(l.id, 'links changed');
|
|
105
|
+
for (const id of links.added) if (!scenarios.added.includes(id)) because(id, 'links added (a row the baseline lacked)');
|
|
106
|
+
for (const id of links.removed) if (curMap.scenarios[id] || inv.scenarios.unchanged.includes(id) || inv.scenarios.changed.some(c => c.id === id)) because(id, 'links removed (the row is gone)');
|
|
107
|
+
for (const e of [...scope.added, ...scope.changed]) because(e.id, `scope disposition ${e.to ? `changed to ${e.to}` : e.disposition}`);
|
|
108
|
+
for (const id of scope.removed) if (curMap.scenarios[id]) because(id, 'scope disposition removed (back in scope)');
|
|
109
|
+
for (const [id, row] of Object.entries(curMap.scenarios)) {
|
|
110
|
+
for (const c of checks.changed) if (row.checks.includes(c.id)) because(id, `declaration of ${c.id} changed (${c.parts.join(', ')})`);
|
|
111
|
+
for (const t of tickets.changed) if (row.tickets.includes(t.id)) because(id, `ticket ${t.id} changed (${t.parts.join(', ')})`);
|
|
112
|
+
}
|
|
113
|
+
const affectedTickets = new Map(), affectedChecks = new Map();
|
|
114
|
+
const add = (map, id, why) => { if (!map.has(id)) map.set(id, []); if (!map.get(id).includes(why)) map.get(id).push(why); };
|
|
115
|
+
for (const id of sortIds([...affectedScenarios.keys()])) {
|
|
116
|
+
const row = curMap.scenarios[id];
|
|
117
|
+
if (!row) continue;
|
|
118
|
+
for (const t of row.tickets) add(affectedTickets, t, `linked from ${id}`);
|
|
119
|
+
for (const c of row.checks) add(affectedChecks, c, `linked from ${id}`);
|
|
120
|
+
}
|
|
121
|
+
for (const t of tickets.changed) add(affectedTickets, t.id, `ticket ${t.parts.join(', ')} changed`);
|
|
122
|
+
for (const c of checks.changed) add(affectedChecks, c.id, `declaration changed (${c.parts.join(', ')})`);
|
|
123
|
+
// Dependents: tickets of the change that depend (transitively) on an affected ticket and are not affected themselves.
|
|
124
|
+
const dependents = [];
|
|
125
|
+
const fields = Object.fromEntries(Object.entries(now.graphInputs.tickets || {}).map(([id, t]) => [id, parse.dependencies(t.fields)]));
|
|
126
|
+
const allTickets = Object.keys(now.tickets);
|
|
127
|
+
const deps = id => (fields[id] || (now.tickets[id] ? parse.dependencies(parse.validateTicket(now.tickets[id].file, now.tickets[id].text).fields) : []));
|
|
128
|
+
const affectedSet = new Set(affectedTickets.keys());
|
|
129
|
+
let grew = true; const dependentVia = new Map();
|
|
130
|
+
while (grew) {
|
|
131
|
+
grew = false;
|
|
132
|
+
for (const id of allTickets) {
|
|
133
|
+
if (affectedSet.has(id) || dependentVia.has(id)) continue;
|
|
134
|
+
const via = deps(id).find(d => affectedSet.has(d) || dependentVia.has(d));
|
|
135
|
+
if (via) { dependentVia.set(id, via); grew = true; }
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
for (const [id, via] of [...dependentVia.entries()].sort(([a], [b]) => byNumber(a, b))) dependents.push({ id, via, because: 'depends_on' });
|
|
139
|
+
const definitionsUnchanged = inv.same;
|
|
140
|
+
const unscoped = { prd: Boolean(ticketDiff.prd_changed && definitionsUnchanged), detail: ticketDiff.prd_changed && definitionsUnchanged ? 'unscoped PRD change requiring review: the PRD revision changed while every requirement and scenario definition is unchanged (a constraint, table or note outside the definitions)' : ticketDiff.prd_changed ? 'the PRD revision changed together with its definitions; review the prose outside the definitions too' : null };
|
|
141
|
+
const same = entry.digest === now.digest;
|
|
142
|
+
return {
|
|
143
|
+
...base, verdict: same ? 'unchanged' : 'changed', reason: same ? null : unscoped.prd && !links.changed.length && !checks.changed.length && !tickets.changed.length && !scope.added.length && !scope.changed.length && !scope.removed.length ? unscoped.detail : null,
|
|
144
|
+
requirements: reqs, scenarios, links, scope, checks, tickets,
|
|
145
|
+
affected: { scenarios: sortIds([...affectedScenarios.keys()]).map(id => ({ id, because: affectedScenarios.get(id) })), tickets: [...affectedTickets.keys()].sort(byNumber).map(id => ({ id, because: affectedTickets.get(id) })), checks: [...affectedChecks.keys()].sort().map(id => ({ id, because: affectedChecks.get(id) })), dependents },
|
|
146
|
+
unscoped,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function render(r) {
|
|
151
|
+
const lines = [];
|
|
152
|
+
lines.push(`PINCER impact · ${r.generated} · ${r.root}`);
|
|
153
|
+
lines.push(`Change ${r.change}${r.current ? ` · current agreement ${r.current.digest.slice(0, 12)} (inventory ${r.current.inventory.slice(0, 12)}, coverage ${r.current.coverage.slice(0, 12)})` : ''}`);
|
|
154
|
+
lines.push(`Baseline ${r.baseline ? `${r.baseline.agreement}${r.baseline.authorization ? ` (authorized by ${r.baseline.authorization})` : ' (not authorized)'} recorded ${r.baseline.recorded} · ${r.baseline.digest.slice(0, 12)}` : 'none'}`);
|
|
155
|
+
lines.push(`Verdict ${r.verdict}${r.reason ? ` — ${r.reason}` : ''}`);
|
|
156
|
+
if (r.verdict === 'unavailable') { lines.push(`Freshness ${r.freshness.note}`); return `${lines.join('\n')}\n`; }
|
|
157
|
+
const list = (label, items, fmt = x => x) => { if (items.length) lines.push(`${label.padEnd(10)} ${items.map(fmt).join(', ')}`); };
|
|
158
|
+
list('Requirements added', r.requirements.added); list('Requirements removed', r.requirements.removed);
|
|
159
|
+
list('Requirements changed', r.requirements.changed, c => `${c.id} (${c.parts.join(', ')})`);
|
|
160
|
+
list('Scenarios added', r.scenarios.added); list('Scenarios removed', r.scenarios.removed, s => `${s.id}${s.tombstone ? ' (tombstone)' : ' (no tombstone)'}`);
|
|
161
|
+
list('Scenarios changed', r.scenarios.changed, c => `${c.id} (${c.parts.join(', ')})`);
|
|
162
|
+
list('Links added', r.links.added); list('Links removed', r.links.removed);
|
|
163
|
+
list('Links changed', r.links.changed, l => `${l.id} (tickets +${l.tickets.added.join(' ') || '—'} -${l.tickets.removed.join(' ') || '—'}; checks +${l.checks.added.join(' ') || '—'} -${l.checks.removed.join(' ') || '—'})`);
|
|
164
|
+
list('Scope added', r.scope.added, s => `${s.id} (${s.disposition})`); list('Scope removed', r.scope.removed); list('Scope changed', r.scope.changed, s => `${s.id} (${s.from} → ${s.to})`);
|
|
165
|
+
list('Checks added', r.checks.added); list('Checks removed', r.checks.removed); list('Checks changed', r.checks.changed, c => `${c.id} (${c.parts.join(', ')})`);
|
|
166
|
+
list('Tickets added', r.tickets.added); list('Tickets removed', r.tickets.removed); list('Tickets changed', r.tickets.changed, t => `${t.id} (${t.parts.join(', ')})`);
|
|
167
|
+
lines.push(`Unchanged ${r.requirements.unchanged.length} requirement(s), ${r.scenarios.unchanged.length} scenario(s)${r.scenarios.unchanged.length ? `: ${r.scenarios.unchanged.join(', ')}` : ''}`);
|
|
168
|
+
lines.push(`Affected scenarios ${r.affected.scenarios.map(s => `${s.id} [${s.because.join('; ')}]`).join(', ') || 'none'}`);
|
|
169
|
+
lines.push(` tickets ${r.affected.tickets.map(t => `${t.id} [${t.because.join('; ')}]`).join(', ') || 'none'}`);
|
|
170
|
+
lines.push(` checks ${r.affected.checks.map(c => `${c.id} [${c.because.join('; ')}]`).join(', ') || 'none'}`);
|
|
171
|
+
lines.push(`Dependents ${r.affected.dependents.map(d => `${d.id} (via ${d.via}, ${d.because})`).join(', ') || 'none'}`);
|
|
172
|
+
lines.push(`Unscoped ${r.unscoped.prd ? 'yes' : 'no'}${r.unscoped.detail ? ` — ${r.unscoped.detail}` : ''}`);
|
|
173
|
+
lines.push(`Freshness ${r.freshness.note}`);
|
|
174
|
+
return `${lines.join('\n')}\n`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
module.exports = { SCHEMA, FRESHNESS, baselineEntry, compute, render };
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Complete output on every exit path.
|
|
3
|
+
//
|
|
4
|
+
// `process.stdout` is asynchronous when it is a pipe: a write larger than the
|
|
5
|
+
// pipe buffer is queued on the event loop, and `process.exit()` throws away
|
|
6
|
+
// whatever has not drained. The CLI uses `process.exit()` as its return
|
|
7
|
+
// statement, so any command whose report outgrows one pipe buffer prints a
|
|
8
|
+
// prefix of itself and still exits 0 — `snapshot --json` is 237 KB on this
|
|
9
|
+
// repository and arrives as 64 KB of invalid JSON through a pipe, on every
|
|
10
|
+
// supported Node version. Writing the file descriptor synchronously removes the
|
|
11
|
+
// queue: when `out` returns, the bytes are in the pipe.
|
|
12
|
+
//
|
|
13
|
+
// A slow reader on a non-blocking pipe answers EAGAIN; wait for it rather than
|
|
14
|
+
// spin. A reader that has gone away answers EPIPE, and a closed descriptor
|
|
15
|
+
// answers EBADF: neither is an error here — `pincer status | head -3` and
|
|
16
|
+
// `pincer status 1>&-` are both things a person can type, and both must stay
|
|
17
|
+
// silent and keep the command's own exit code.
|
|
18
|
+
const fs = require('node:fs');
|
|
19
|
+
|
|
20
|
+
const IDLE = new Int32Array(new SharedArrayBuffer(4));
|
|
21
|
+
|
|
22
|
+
function write(fd, text) {
|
|
23
|
+
if (!text) return;
|
|
24
|
+
const buffer = Buffer.from(text, 'utf8');
|
|
25
|
+
let offset = 0;
|
|
26
|
+
while (offset < buffer.length) {
|
|
27
|
+
try {
|
|
28
|
+
offset += fs.writeSync(fd, buffer, offset, buffer.length - offset);
|
|
29
|
+
} catch (error) {
|
|
30
|
+
if (error.code === 'EAGAIN' || error.code === 'EINTR') { Atomics.wait(IDLE, 0, 0, 1); continue; }
|
|
31
|
+
if (error.code === 'EPIPE' || error.code === 'EBADF') return;
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
module.exports = {
|
|
38
|
+
out: text => write(1, text),
|
|
39
|
+
err: text => write(2, text),
|
|
40
|
+
write,
|
|
41
|
+
};
|
|
@@ -16,6 +16,7 @@ const runner = require('./runner.cjs');
|
|
|
16
16
|
const { sanitizeText, inlineSecretLine } = require('./sanitize.cjs');
|
|
17
17
|
const { nowIso } = require('./fsutil.cjs');
|
|
18
18
|
const statusModule = require('./status.cjs');
|
|
19
|
+
const gates = require('./gates.cjs');
|
|
19
20
|
|
|
20
21
|
const EXIT = { OK: 0, FAILED: 1, INVALID: 4 };
|
|
21
22
|
class Refusal extends Error {
|
|
@@ -77,10 +78,24 @@ function usablePrd(root, t) {
|
|
|
77
78
|
if (!statusModule.usablePrd(assoc.prdResult)) die(`${assoc.prd}: PRD is draft; complete the authorized breakdown before starting (expected ticketed or built)`, { prefix: 'pincer' });
|
|
78
79
|
return assoc;
|
|
79
80
|
}
|
|
80
|
-
|
|
81
|
+
// The mode of the ticket's PRD. In changes mode the command passes the shared
|
|
82
|
+
// guard (docs/runtime-contracts.md, "Command gates") before anything is written
|
|
83
|
+
// or launched; the guard's binding carries the change, current agreement and
|
|
84
|
+
// legacy receipts for readiness, attempts and closure.
|
|
85
|
+
const gateExit = code => (code === 'STATE_BUSY' ? 3 : ['INPUT_INVALID', 'INVENTORY_INVALID', 'COVERAGE_INVALID', 'MALFORMED', 'UNSUPPORTED_SCHEMA', 'HISTORY_INVALID', 'STATE_INCOMPLETE'].includes(code) ? EXIT.INVALID : EXIT.FAILED);
|
|
86
|
+
function modeFor(root, prd, { command, ticket } = {}) {
|
|
81
87
|
const bind = identity.loadBinding(root, { prd });
|
|
82
88
|
if (bind.binding && !bind.code) return { mode: 'migrated', binding: bind.binding };
|
|
83
89
|
if (bind.code === 'CHANGE_REQUIRED') return { mode: 'legacy' };
|
|
90
|
+
if (bind.code === 'CHANGES_MODE') {
|
|
91
|
+
try {
|
|
92
|
+
const g = gates.guard(root, { command, ticket: ticket ? { file: ticket.file, fields: ticket.fields } : null, prd });
|
|
93
|
+
return { mode: 'changes', binding: g.binding, guard: g };
|
|
94
|
+
} catch (error) {
|
|
95
|
+
if (error && error.refusal) die(`${error.code}: ${error.message}`, { prefix: 'pincer', exit: gateExit(error.code), code: error.code });
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
84
99
|
die(`${bind.code}: ${bind.problem}`, { prefix: 'pincer', exit: EXIT.INVALID, code: bind.code });
|
|
85
100
|
return null;
|
|
86
101
|
}
|
|
@@ -101,7 +116,7 @@ function migratedReadiness(root, t, binding, inputs) {
|
|
|
101
116
|
if (before) changedPaths = source.diffManifests(before, inputs.manifest);
|
|
102
117
|
}
|
|
103
118
|
const legacyReceipt = (binding.legacy_receipts && binding.legacy_receipts[t.id]) || (t.fields.verified || t.fields.last_check ? { verified: t.fields.verified, last_check: t.fields.last_check } : null);
|
|
104
|
-
const r = readiness.migratedTicketReadiness({ text: t.text, fields: t.fields, timeout: t.timeout, attempt, legacyReceipt, current: inputs.current, sourceProblems: inputs.manifest.problems, changedPaths, contextKey: key, pointedId: inputs.index ? inputs.index.current[key] || null : null });
|
|
119
|
+
const r = readiness.migratedTicketReadiness({ text: t.text, fields: t.fields, timeout: t.timeout, attempt, legacyReceipt, current: inputs.current, sourceProblems: inputs.manifest.problems, changedPaths, contextKey: key, pointedId: inputs.index ? inputs.index.current[key] || null : null, mode: binding.mode || 'migrated', strict: Boolean(binding.strict) });
|
|
105
120
|
r.attempt = attempt;
|
|
106
121
|
return r;
|
|
107
122
|
}
|
|
@@ -125,14 +140,14 @@ function bind(root, input, ref) {
|
|
|
125
140
|
function start(root, input, { quiet = false } = {}) {
|
|
126
141
|
const t = resolve(root, input);
|
|
127
142
|
const assoc = usablePrd(root, t);
|
|
128
|
-
const { mode, binding } = modeFor(root, assoc.prd);
|
|
143
|
+
const { mode, binding } = modeFor(root, assoc.prd, { command: 'start', ticket: t });
|
|
129
144
|
const st = t.fields.status;
|
|
130
145
|
if (st === 'in_progress') {
|
|
131
146
|
if (!t.fields.prd) writeTicket(root, t.file, fmSet(t.text, 'prd', assoc.prd));
|
|
132
147
|
return { out: quiet ? '' : `${t.id} already in progress (started ${t.fields.started || ''})\n`, mode, binding };
|
|
133
148
|
}
|
|
134
149
|
if (st === 'done') die(`${t.id} is already done`);
|
|
135
|
-
const inputs = mode
|
|
150
|
+
const inputs = mode !== 'legacy' ? currentInputs(root, binding) : null;
|
|
136
151
|
for (const dep of parse.dependencies(t.fields)) {
|
|
137
152
|
const df = parse.ticketFile(root, dep);
|
|
138
153
|
if (df.problem) die(df.problem);
|
|
@@ -168,8 +183,12 @@ function executeLegacy(root, block) {
|
|
|
168
183
|
async function verify(root, input, { write = process.stdout, error = process.stderr } = {}) {
|
|
169
184
|
const t0 = resolve(root, input);
|
|
170
185
|
const assoc = usablePrd(root, t0);
|
|
171
|
-
const { mode, binding } = modeFor(root, assoc.prd);
|
|
172
|
-
if (t0.fields.status === 'open') {
|
|
186
|
+
const { mode, binding } = modeFor(root, assoc.prd, { command: 'verify', ticket: t0 });
|
|
187
|
+
if (t0.fields.status === 'open') {
|
|
188
|
+
// A verify on an open ticket starts it; in changes mode that needs an active change.
|
|
189
|
+
if (mode === 'changes') modeFor(root, assoc.prd, { command: 'start', ticket: t0 });
|
|
190
|
+
const s = start(root, t0.id); write.write(s.out);
|
|
191
|
+
}
|
|
173
192
|
const t = load(root, t0.file, t0.id);
|
|
174
193
|
if (mode === 'legacy') return verifyLegacy(root, t, write, error);
|
|
175
194
|
return verifyMigrated(root, t, binding, write, error);
|
|
@@ -215,11 +234,14 @@ async function verifyMigrated(root, t, binding, write, error) {
|
|
|
215
234
|
const commands = parse.verificationCommands(t.text);
|
|
216
235
|
const secretLine = inlineSecretLine(commands);
|
|
217
236
|
if (secretLine) die(`${t.file}: Verification block line ${secretLine} assigns a secret-like literal; reference it from the environment instead (the block is recorded as display text)`, { exit: EXIT.INVALID, code: 'INPUT_INVALID' });
|
|
218
|
-
write.write(`── ${id} verification ──\n`);
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
237
|
+
const announce = () => { write.write(`── ${id} verification ──\n`); for (const c of commands) write.write(` $ ${sanitizeText(c).text}\n`); };
|
|
238
|
+
const contextFor = b => ({ kind: 'ticket', change: b.change, prd: b.prd, prd_revision: b.prd_revision, base: b.base, ticket: id, ticket_digest: parse.ticketDigest(t.text), ...(b.agreement ? { agreement: b.agreement } : {}), ...(b.strict ? { inventory: b.inventory, coverage: b.coverage } : {}) });
|
|
239
|
+
// In changes mode the guard runs again under the runner's lock (docs/runtime-contracts.md,
|
|
240
|
+
// "Command gates"): a transition, revision or authorization committed since the
|
|
241
|
+
// pre-launch evaluation refuses the attempt or is the agreement it records.
|
|
242
|
+
const revalidate = binding.mode === 'changes' ? () => contextFor(gates.guard(root, { command: 'verify', ticket: { file: t.file, fields: t.fields } }).binding) : null;
|
|
243
|
+
const result = await runner.runAttempt({ root, context: contextFor(binding), commands, timeoutSeconds: t.timeout, command: `verify ${id}`, revalidate, announce });
|
|
244
|
+
if (result.code) die(`${result.code}: ${result.problem}`, { prefix: 'pincer', exit: result.refused ? gateExit(result.code) : result.code === 'STATE_BUSY' ? 3 : EXIT.INVALID, code: result.code });
|
|
223
245
|
const a = result.attempt;
|
|
224
246
|
const logs = `${state.RUNTIME_DIR}/attempts/${a.id}/`;
|
|
225
247
|
if (a.outcome === 'passed') {
|
|
@@ -238,7 +260,7 @@ async function done(root, input, io = {}) {
|
|
|
238
260
|
const write = io.write || process.stdout, error = io.error || process.stderr;
|
|
239
261
|
const t = resolve(root, input);
|
|
240
262
|
const assoc = usablePrd(root, t);
|
|
241
|
-
const { mode, binding } = modeFor(root, assoc.prd);
|
|
263
|
+
const { mode, binding } = modeFor(root, assoc.prd, { command: 'done', ticket: t });
|
|
242
264
|
const id = t.id, st = t.fields.status;
|
|
243
265
|
if (st !== 'in_progress' && st !== 'done') die(`${id} is '${st}' — run 'scripts/pincer-ticket.sh verify ${id}' first`);
|
|
244
266
|
if (mode === 'legacy') return doneLegacy(root, t, write, error);
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// PINCER runtime — evaluation locators (docs/runtime-contracts.md, "Evaluation
|
|
3
|
+
// locator"). In changes mode the evaluations of a change are located by
|
|
4
|
+
// `.prd/evidence/changes/<change-id>.json`, tracked in git, appended only by
|
|
5
|
+
// `evidence export` through a transaction. It lives under the fixed source
|
|
6
|
+
// exclusion, is listed in no manifest (no digest refers to itself), and is one
|
|
7
|
+
// of the paths allowed to follow the candidate. Root NOTES.md stays a human
|
|
8
|
+
// summary of whichever change was evaluated last; the locator is the identity.
|
|
9
|
+
const fs = require('node:fs');
|
|
10
|
+
const path = require('node:path');
|
|
11
|
+
const parse = require('./parse.cjs');
|
|
12
|
+
const evidence = require('./evidence.cjs');
|
|
13
|
+
const transaction = require('./transaction.cjs');
|
|
14
|
+
const { readJson, tryGit } = require('./fsutil.cjs');
|
|
15
|
+
|
|
16
|
+
const SCHEMA = 1;
|
|
17
|
+
const DIR = '.prd/evidence/changes';
|
|
18
|
+
const KEYS = ['schema', 'change', 'evaluations'];
|
|
19
|
+
const ENTRY_KEYS = ['candidate', 'base', 'prd', 'prd_revision', 'agreement', 'manifest', 'recorded'];
|
|
20
|
+
const SHA256 = /^[0-9a-f]{64}$/;
|
|
21
|
+
const isObject = v => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
22
|
+
const file = id => `${DIR}/${id}.json`;
|
|
23
|
+
|
|
24
|
+
function validate(doc, id) {
|
|
25
|
+
const bad = p => ({ code: 'MALFORMED', problem: `${file(id)}: ${p}` });
|
|
26
|
+
if (!isObject(doc)) return bad('locator must be a JSON object');
|
|
27
|
+
if (doc.schema !== SCHEMA) return { code: 'UNSUPPORTED_SCHEMA', problem: `${file(id)}: unsupported evaluation locator schema ${JSON.stringify(doc.schema)}` };
|
|
28
|
+
for (const k of Object.keys(doc)) if (!KEYS.includes(k)) return bad(`unknown key "${k}"`);
|
|
29
|
+
for (const k of KEYS) if (!(k in doc)) return bad(`missing key "${k}"`);
|
|
30
|
+
if (doc.change !== id) return bad(`locator names change "${doc.change}", not ${id}`);
|
|
31
|
+
if (!Array.isArray(doc.evaluations)) return bad('evaluations must be an array');
|
|
32
|
+
for (const [i, e] of doc.evaluations.entries()) {
|
|
33
|
+
if (!isObject(e)) return bad(`evaluations[${i}] must be an object`);
|
|
34
|
+
for (const k of Object.keys(e)) if (!ENTRY_KEYS.includes(k)) return bad(`evaluations[${i}].${k} is not allowed`);
|
|
35
|
+
for (const k of ENTRY_KEYS) if (!(k in e)) return bad(`evaluations[${i}].${k} is missing`);
|
|
36
|
+
if (!parse.HEX40.test(e.candidate) || !parse.HEX40.test(e.base)) return bad(`evaluations[${i}]: candidate and base must be full 40-hex commit IDs`);
|
|
37
|
+
if (!parse.PRD_REF.test(e.prd)) return bad(`evaluations[${i}]: prd must be .prd/prd-vN.md`);
|
|
38
|
+
if (!SHA256.test(e.prd_revision) || !SHA256.test(e.agreement)) return bad(`evaluations[${i}]: prd_revision and agreement must be 64-hex digests`);
|
|
39
|
+
if (e.manifest !== `.prd/evidence/prd-v${e.prd.match(parse.PRD_REF)[1]}/${e.candidate}/manifest.json`) return bad(`evaluations[${i}]: manifest must be .prd/evidence/prd-v<N>/${e.candidate}/manifest.json for ${e.prd}`);
|
|
40
|
+
if (!parse.TIMESTAMP.test(e.recorded)) return bad(`evaluations[${i}]: recorded must be an ISO UTC timestamp`);
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
// { locator } (possibly empty), or { code, problem }. A missing file is an empty locator.
|
|
45
|
+
function read(root, id) {
|
|
46
|
+
const r = readJson(path.join(root, file(id)));
|
|
47
|
+
if (r.error === 'missing') return { locator: { schema: SCHEMA, change: id, evaluations: [] }, missing: true };
|
|
48
|
+
if (r.error) return { code: 'MALFORMED', problem: `${file(id)}: ${r.error}` };
|
|
49
|
+
const invalid = validate(r.data, id);
|
|
50
|
+
if (invalid) return invalid;
|
|
51
|
+
return { locator: r.data };
|
|
52
|
+
}
|
|
53
|
+
const latest = locator => (locator.evaluations.length ? locator.evaluations[locator.evaluations.length - 1] : null);
|
|
54
|
+
|
|
55
|
+
// Append an evaluation reference (after the manifest was written and validated).
|
|
56
|
+
function append(root, id, entry) {
|
|
57
|
+
try {
|
|
58
|
+
const out = transaction.run(root, { command: `evidence export ${id}` }, ctx => {
|
|
59
|
+
const r = read(root, id);
|
|
60
|
+
if (r.code) ctx.refuse(r.code, r.problem);
|
|
61
|
+
const doc = r.locator;
|
|
62
|
+
const same = doc.evaluations.find(e => e.candidate === entry.candidate && e.manifest === entry.manifest && e.agreement === entry.agreement && e.prd_revision === entry.prd_revision && e.base === entry.base);
|
|
63
|
+
if (same) return { action: 'unchanged', entry: same };
|
|
64
|
+
doc.evaluations.push(entry);
|
|
65
|
+
const invalid = validate(doc, id);
|
|
66
|
+
if (invalid) ctx.refuse(invalid.code, invalid.problem);
|
|
67
|
+
ctx.write(file(id), doc);
|
|
68
|
+
return { action: 'recorded', entry };
|
|
69
|
+
});
|
|
70
|
+
return out.result;
|
|
71
|
+
} catch (error) {
|
|
72
|
+
if (error.refusal) return { code: error.code, problem: error.message };
|
|
73
|
+
if (error.code === 'STATE_BUSY') return { code: 'STATE_BUSY', problem: error.message };
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// The paths that may differ from `candidate` (docs/runtime-contracts.md,
|
|
79
|
+
// "Evaluation locator"): NOTES.md, every locator under .prd/evidence/changes/ that
|
|
80
|
+
// parses and validates for its own id, and, for each of their entries naming this
|
|
81
|
+
// candidate whose manifest validates with file digests for its candidate, base and
|
|
82
|
+
// PRD, that manifest and the files it lists. The set is computed from validated
|
|
83
|
+
// content, never from a directory or filename pattern: an unlisted file inside an
|
|
84
|
+
// evidence directory, an altered listed artifact and an unreadable or misnamed
|
|
85
|
+
// locator are candidate changes. Returns a Set of repository-relative paths.
|
|
86
|
+
function followers(root, candidate) {
|
|
87
|
+
const allowed = new Set(['NOTES.md']);
|
|
88
|
+
let names = [];
|
|
89
|
+
try { names = fs.readdirSync(path.join(root, DIR)).filter(n => n.endsWith('.json')); } catch { names = []; }
|
|
90
|
+
for (const name of names.sort()) {
|
|
91
|
+
const id = name.slice(0, -'.json'.length);
|
|
92
|
+
const r = read(root, id);
|
|
93
|
+
if (r.code || r.missing) continue;
|
|
94
|
+
allowed.add(file(id));
|
|
95
|
+
for (const e of r.locator.evaluations) {
|
|
96
|
+
if (e.candidate !== candidate) continue;
|
|
97
|
+
const opts = { files: true, candidate: e.candidate, base: e.base, prd: e.prd };
|
|
98
|
+
const problems = evidence.validate(path.resolve(root, e.manifest), opts, root);
|
|
99
|
+
if (problems.length || !Array.isArray(opts.list)) continue;
|
|
100
|
+
for (const listed of opts.list) allowed.add(listed);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return allowed;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// The candidate of a change from its locator, in the shape status reports for
|
|
107
|
+
// NOTES.md: { text, state: 'current' | 'stale' | 'missing', candidate, base,
|
|
108
|
+
// manifest, entry }. Current means: the latest entry's manifest validates for its
|
|
109
|
+
// candidate, base and PRD, the candidate is an ancestor of HEAD, and the diff
|
|
110
|
+
// from the candidate to HEAD plus the dirty tree contain nothing but the
|
|
111
|
+
// candidate's followers (NOTES.md, valid locators, validated listed artifacts).
|
|
112
|
+
function current(root, record) {
|
|
113
|
+
const r = read(root, record.change);
|
|
114
|
+
if (r.code) return { text: `stale: ${r.problem}`, state: 'stale', problem: r };
|
|
115
|
+
const entry = latest(r.locator);
|
|
116
|
+
if (!entry) return { text: `missing (no evaluation recorded in ${file(record.change)})`, state: 'missing' };
|
|
117
|
+
const base = { candidate: entry.candidate, base: entry.base, manifest: entry.manifest, entry, fields: { prd: entry.prd, candidate: entry.candidate, base: entry.base, evidence: entry.manifest } };
|
|
118
|
+
if (entry.prd !== record.prd) return { ...base, text: `stale: the evaluation is for ${entry.prd}, not ${record.prd}`, state: 'stale' };
|
|
119
|
+
const ok = args => !tryGit(root, args).error;
|
|
120
|
+
if (!ok(['rev-parse', '--verify', `${entry.candidate}^{commit}`]) || !ok(['rev-parse', '--verify', `${entry.base}^{commit}`]) ||
|
|
121
|
+
!ok(['merge-base', '--is-ancestor', entry.base, entry.candidate]) || !ok(['merge-base', '--is-ancestor', entry.candidate, 'HEAD'])) {
|
|
122
|
+
return { ...base, text: 'stale: evaluation commits or ancestry unavailable', state: 'stale' };
|
|
123
|
+
}
|
|
124
|
+
const opts = { files: true, candidate: entry.candidate, base: entry.base, prd: entry.prd };
|
|
125
|
+
const problems = evidence.validate(path.resolve(root, entry.manifest), opts, root);
|
|
126
|
+
if (problems.length) return { ...base, text: `stale: evidence invalid: ${problems[0]}`, state: 'stale' };
|
|
127
|
+
let manifestDoc = null;
|
|
128
|
+
try { manifestDoc = JSON.parse(fs.readFileSync(path.resolve(root, entry.manifest), 'utf8')); } catch { manifestDoc = null; }
|
|
129
|
+
if (!manifestDoc || !manifestDoc.change || manifestDoc.change.id !== record.change) return { ...base, text: `stale: ${entry.manifest} records change ${manifestDoc && manifestDoc.change ? manifestDoc.change.id : 'none'}, not ${record.change}`, state: 'stale' };
|
|
130
|
+
for (const f of opts.list) {
|
|
131
|
+
const tracked = tryGit(root, ['ls-files', '--error-unmatch', '--', f]);
|
|
132
|
+
if (tracked.error || !tracked.out.trim()) return { ...base, text: `stale: evidence not tracked: ${f}`, state: 'stale' };
|
|
133
|
+
}
|
|
134
|
+
const set = followers(root, entry.candidate);
|
|
135
|
+
const allowed = p => set.has(p);
|
|
136
|
+
const diff = tryGit(root, ['diff', '--name-only', '--relative', entry.candidate, 'HEAD']);
|
|
137
|
+
if (diff.error) return { ...base, text: 'stale: evaluation commits or ancestry unavailable', state: 'stale' };
|
|
138
|
+
const offending = diff.out.split('\n').filter(l => l && !allowed(l))[0];
|
|
139
|
+
if (offending) return { ...base, text: `stale: candidate changed after evaluation: ${offending}`, state: 'stale' };
|
|
140
|
+
const dirty = tryGit(root, ['status', '--porcelain', '--untracked-files=all']);
|
|
141
|
+
if (dirty.error) return { ...base, text: 'stale: git status failed', state: 'stale' };
|
|
142
|
+
const dirtyPath = dirty.out.split('\n').filter(Boolean).map(l => l.slice(3).replace(/^"(.*)"$/, '$1')).filter(p => !allowed(p))[0];
|
|
143
|
+
if (dirtyPath) return { ...base, text: `stale: working tree has changes outside the candidate's evidence: ${dirtyPath}`, state: 'stale' };
|
|
144
|
+
return { ...base, text: `current (${entry.candidate})`, state: 'current' };
|
|
145
|
+
}
|
|
146
|
+
// The Evidence line for the locator's latest manifest, independent of currency.
|
|
147
|
+
function evidenceLine(root, record) {
|
|
148
|
+
const r = read(root, record.change);
|
|
149
|
+
if (r.code) return { manifest: file(record.change), ok: false, reason: r.problem, schema: null };
|
|
150
|
+
const entry = latest(r.locator);
|
|
151
|
+
if (!entry) return null;
|
|
152
|
+
const problems = evidence.validate(path.resolve(root, entry.manifest), { candidate: entry.candidate, base: entry.base, prd: entry.prd }, root);
|
|
153
|
+
let schema = null;
|
|
154
|
+
try { schema = JSON.parse(fs.readFileSync(path.resolve(root, entry.manifest), 'utf8')).schema; } catch { schema = null; }
|
|
155
|
+
return { manifest: entry.manifest, ok: problems.length === 0, reason: problems[0] || null, schema };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
module.exports = { SCHEMA, DIR, file, validate, read, latest, append, followers, current, evidenceLine };
|