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,361 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// PINCER runtime — the coverage map and the validated coverage graph
|
|
3
|
+
// (docs/runtime-contracts.md, "Strict coverage" → "Coverage map"). The map
|
|
4
|
+
// `.prd/coverage/<change-id>.json` is authored by hand: it owns links from
|
|
5
|
+
// scenarios to tickets and declared checks, ticket roles and planned scope
|
|
6
|
+
// dispositions; it never owns requirement prose or observed outcomes. This module
|
|
7
|
+
// parses it strictly (duplicate JSON keys, unknown keys, bounds, safe paths),
|
|
8
|
+
// normalizes it for the agreement digest, and resolves it against the inventory
|
|
9
|
+
// and the change's tickets into one validated graph that every consumer reads.
|
|
10
|
+
// COVERAGE_INVALID: the file cannot be interpreted. COVERAGE_INCOMPLETE: it can,
|
|
11
|
+
// but the graph is incomplete or disagrees with the inventory and the tickets.
|
|
12
|
+
// Nothing here writes a file, launches a check or judges semantics.
|
|
13
|
+
const fs = require('node:fs');
|
|
14
|
+
const path = require('node:path');
|
|
15
|
+
const { spawnSync } = require('node:child_process');
|
|
16
|
+
const parse = require('./parse.cjs');
|
|
17
|
+
const requirements = require('./requirements.cjs');
|
|
18
|
+
const { inlineSecretLine } = require('./sanitize.cjs');
|
|
19
|
+
const { readJson } = require('./fsutil.cjs');
|
|
20
|
+
|
|
21
|
+
const SCHEMA = 1;
|
|
22
|
+
const DIR = '.prd/coverage';
|
|
23
|
+
const MAX_BYTES = 1024 * 1024;
|
|
24
|
+
const MAX_TEXT = 2000;
|
|
25
|
+
const MAX_ENTRIES = 1000;
|
|
26
|
+
const MAP_KEYS = ['schema', 'change', 'prd', 'scenarios', 'scope', 'tickets', 'checks'];
|
|
27
|
+
const SCENARIO_KEYS = ['tickets', 'checks'];
|
|
28
|
+
const SCOPE_KEYS = ['disposition', 'decision', 'prior', 'note'];
|
|
29
|
+
const TICKET_KEYS = ['role', 'rationale'];
|
|
30
|
+
const CHECK_KEYS = ['kind', 'required', 'command', 'timeout', 'cwd', 'obligation', 'note'];
|
|
31
|
+
const KINDS = ['command', 'review', 'visual'];
|
|
32
|
+
const ROLES = ['implements', 'enables'];
|
|
33
|
+
const DISPOSITIONS = ['deferred', 'removed'];
|
|
34
|
+
const CHECK_ID = /^C-[0-9]{2,6}$/;
|
|
35
|
+
const DECISION_ID = /^D-[0-9]{2,6}$/;
|
|
36
|
+
const AGREEMENT_ID = /^G-[0-9]{2,6}$/;
|
|
37
|
+
const isObject = v => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
38
|
+
const file = id => `${DIR}/${id}.json`;
|
|
39
|
+
|
|
40
|
+
// --- Strict JSON (duplicate keys are an error, unlike JSON.parse) -------------------
|
|
41
|
+
function parseStrict(text) {
|
|
42
|
+
let i = 0;
|
|
43
|
+
const fail = message => { throw new Error(`${message} at offset ${i}`); };
|
|
44
|
+
const ws = () => { while (i < text.length && ' \t\n\r'.includes(text[i])) i++; };
|
|
45
|
+
const value = () => {
|
|
46
|
+
ws();
|
|
47
|
+
const c = text[i];
|
|
48
|
+
if (c === '{') return object();
|
|
49
|
+
if (c === '[') return array();
|
|
50
|
+
if (c === '"') return string();
|
|
51
|
+
if (c === 't' && text.startsWith('true', i)) { i += 4; return true; }
|
|
52
|
+
if (c === 'f' && text.startsWith('false', i)) { i += 5; return false; }
|
|
53
|
+
if (c === 'n' && text.startsWith('null', i)) { i += 4; return null; }
|
|
54
|
+
const m = text.slice(i).match(/^-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?/);
|
|
55
|
+
if (m) { i += m[0].length; return Number(m[0]); }
|
|
56
|
+
fail(c === undefined ? 'unexpected end of input' : `unexpected token ${JSON.stringify(c)}`);
|
|
57
|
+
};
|
|
58
|
+
const string = () => {
|
|
59
|
+
let out = ''; i++;
|
|
60
|
+
for (;;) {
|
|
61
|
+
if (i >= text.length) fail('unterminated string');
|
|
62
|
+
const c = text[i++];
|
|
63
|
+
if (c === '"') return out;
|
|
64
|
+
if (c === '\\') {
|
|
65
|
+
const e = text[i++];
|
|
66
|
+
if (e === 'u') { const hex = text.slice(i, i + 4); if (!/^[0-9a-fA-F]{4}$/.test(hex)) fail('bad unicode escape'); out += String.fromCharCode(parseInt(hex, 16)); i += 4; }
|
|
67
|
+
else { const map = { '"': '"', '\\': '\\', '/': '/', b: '\b', f: '\f', n: '\n', r: '\r', t: '\t' }; if (!(e in map)) fail(`bad escape \\${e}`); out += map[e]; }
|
|
68
|
+
} else if (c < ' ') fail('control character in string');
|
|
69
|
+
else out += c;
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
const object = () => {
|
|
73
|
+
const out = {}; i++; ws();
|
|
74
|
+
if (text[i] === '}') { i++; return out; }
|
|
75
|
+
for (;;) {
|
|
76
|
+
ws();
|
|
77
|
+
if (text[i] !== '"') fail('expected a string key');
|
|
78
|
+
const key = string();
|
|
79
|
+
if (Object.prototype.hasOwnProperty.call(out, key)) throw new Error(`duplicate key ${JSON.stringify(key)}`);
|
|
80
|
+
ws();
|
|
81
|
+
if (text[i] !== ':') fail('expected ":"');
|
|
82
|
+
i++;
|
|
83
|
+
out[key] = value();
|
|
84
|
+
ws();
|
|
85
|
+
if (text[i] === ',') { i++; continue; }
|
|
86
|
+
if (text[i] === '}') { i++; return out; }
|
|
87
|
+
fail('expected "," or "}"');
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
const array = () => {
|
|
91
|
+
const out = []; i++; ws();
|
|
92
|
+
if (text[i] === ']') { i++; return out; }
|
|
93
|
+
for (;;) {
|
|
94
|
+
out.push(value());
|
|
95
|
+
ws();
|
|
96
|
+
if (text[i] === ',') { i++; continue; }
|
|
97
|
+
if (text[i] === ']') { i++; return out; }
|
|
98
|
+
fail('expected "," or "]"');
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
const result = value();
|
|
102
|
+
ws();
|
|
103
|
+
if (i < text.length) fail('trailing characters');
|
|
104
|
+
return result;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// --- Paths -------------------------------------------------------------------------------
|
|
108
|
+
// Why a repository-relative path is unsafe, or null.
|
|
109
|
+
function unsafePath(p) {
|
|
110
|
+
if (typeof p !== 'string' || p === '') return 'must be a nonempty repository-relative path';
|
|
111
|
+
if (p.startsWith('/')) return 'absolute paths are not allowed';
|
|
112
|
+
if (/^[A-Za-z]:/.test(p)) return 'drive-letter paths are not allowed';
|
|
113
|
+
if (p.includes('\\')) return 'backslashes are not allowed';
|
|
114
|
+
if (p.split('/').some(s => s === '' || s === '.' || s === '..')) return 'must be normalized (no "..", "." or empty segments)';
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
// Every component of a repository-relative path must exist and none may be a symlink.
|
|
118
|
+
function realFile(root, rel, { directory = false } = {}) {
|
|
119
|
+
const segments = rel.split('/');
|
|
120
|
+
let current = root;
|
|
121
|
+
for (let k = 0; k < segments.length; k++) {
|
|
122
|
+
current = path.join(current, segments[k]);
|
|
123
|
+
let stat;
|
|
124
|
+
try { stat = fs.lstatSync(current); } catch { return `${rel}: missing`; }
|
|
125
|
+
if (stat.isSymbolicLink()) return `${rel}: ${k === segments.length - 1 ? 'is a symbolic link' : `path component ${segments.slice(0, k + 1).join('/')} is a symbolic link`}`;
|
|
126
|
+
const last = k === segments.length - 1;
|
|
127
|
+
if (!last && !stat.isDirectory()) return `${rel}: ${segments.slice(0, k + 1).join('/')} is not a directory`;
|
|
128
|
+
if (last && (directory ? !stat.isDirectory() : !stat.isFile())) return `${rel}: not a ${directory ? 'directory' : 'regular file'}`;
|
|
129
|
+
}
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// --- Map validation ----------------------------------------------------------------------
|
|
134
|
+
// Returns null or the first problem (string). Structural (COVERAGE_INVALID) only.
|
|
135
|
+
function validateMap(doc, { change, prd, root }) {
|
|
136
|
+
const str = v => typeof v === 'string';
|
|
137
|
+
const text = v => str(v) && v.length <= MAX_TEXT;
|
|
138
|
+
const textOrNull = v => v === null || text(v);
|
|
139
|
+
const ids = (list, label, re) => {
|
|
140
|
+
if (!Array.isArray(list)) return `${label} must be an array`;
|
|
141
|
+
if (list.length > MAX_ENTRIES) return `${label} has more than ${MAX_ENTRIES} entries`;
|
|
142
|
+
const seen = new Set();
|
|
143
|
+
for (const id of list) {
|
|
144
|
+
if (!str(id) || !re.test(id)) return `${label}: ${JSON.stringify(id)} is not a valid ID`;
|
|
145
|
+
if (seen.has(id)) return `${label}: duplicate entry ${id}`;
|
|
146
|
+
seen.add(id);
|
|
147
|
+
}
|
|
148
|
+
return null;
|
|
149
|
+
};
|
|
150
|
+
if (!isObject(doc)) return 'the map must be a JSON object';
|
|
151
|
+
if (doc.schema !== SCHEMA) return `unsupported coverage map schema ${JSON.stringify(doc.schema)} (this runtime reads schema 1)`;
|
|
152
|
+
for (const k of Object.keys(doc)) if (!MAP_KEYS.includes(k)) return `unknown key ${JSON.stringify(k)}`;
|
|
153
|
+
for (const k of MAP_KEYS) if (!(k in doc)) return `missing key ${JSON.stringify(k)}`;
|
|
154
|
+
if (doc.change !== change) return `change must be ${JSON.stringify(change)} (got ${JSON.stringify(doc.change)})`;
|
|
155
|
+
if (doc.prd !== prd) return `prd must be ${prd} (got ${JSON.stringify(doc.prd)})`;
|
|
156
|
+
for (const k of ['scenarios', 'scope', 'tickets', 'checks']) {
|
|
157
|
+
if (!isObject(doc[k])) return `${k} must be an object`;
|
|
158
|
+
if (Object.keys(doc[k]).length > MAX_ENTRIES) return `${k} has more than ${MAX_ENTRIES} entries`;
|
|
159
|
+
}
|
|
160
|
+
for (const [id, s] of Object.entries(doc.scenarios)) {
|
|
161
|
+
if (!requirements.ID_RE.test(id)) return `scenarios: ${JSON.stringify(id)} is not a valid ID`;
|
|
162
|
+
if (!isObject(s)) return `${id}: must be an object`;
|
|
163
|
+
for (const k of Object.keys(s)) if (!SCENARIO_KEYS.includes(k)) return `${id}: unknown key ${JSON.stringify(k)}`;
|
|
164
|
+
for (const k of SCENARIO_KEYS) if (!(k in s)) return `${id}: missing key ${JSON.stringify(k)}`;
|
|
165
|
+
const t = ids(s.tickets, `${id}`, /^T-[0-9]{2,6}$/); if (t) return t;
|
|
166
|
+
const c = ids(s.checks, `${id}`, CHECK_ID); if (c) return c;
|
|
167
|
+
}
|
|
168
|
+
for (const [id, s] of Object.entries(doc.scope)) {
|
|
169
|
+
if (!requirements.ID_RE.test(id)) return `scope: ${JSON.stringify(id)} is not a valid ID`;
|
|
170
|
+
if (!isObject(s)) return `${id}: must be an object`;
|
|
171
|
+
for (const k of Object.keys(s)) if (!SCOPE_KEYS.includes(k)) return `${id}: unknown key ${JSON.stringify(k)}`;
|
|
172
|
+
for (const k of SCOPE_KEYS) if (!(k in s)) return `${id}: missing key ${JSON.stringify(k)}`;
|
|
173
|
+
if (!DISPOSITIONS.includes(s.disposition)) return `${id}: disposition must be deferred or removed`;
|
|
174
|
+
if (!str(s.decision) || !DECISION_ID.test(s.decision)) return `${id}: decision must be a decision ID such as D-01`;
|
|
175
|
+
if (s.disposition === 'removed') { if (!str(s.prior) || !AGREEMENT_ID.test(s.prior)) return `${id}: a removed tombstone names its prior agreement G-NN`; }
|
|
176
|
+
else if (s.prior !== null) return `${id}: prior is null for a deferral`;
|
|
177
|
+
if (!textOrNull(s.note)) return `${id}: note must be null or a short string`;
|
|
178
|
+
if (id in doc.scenarios) return `${id}: appears in both scenarios and scope`;
|
|
179
|
+
}
|
|
180
|
+
for (const [id, t] of Object.entries(doc.tickets)) {
|
|
181
|
+
if (!/^T-[0-9]{2,6}$/.test(id) || !parse.canonicalId(id)) return `tickets: ${JSON.stringify(id)} is not a canonical ticket ID`;
|
|
182
|
+
if (!isObject(t)) return `${id}: must be an object`;
|
|
183
|
+
for (const k of Object.keys(t)) if (!TICKET_KEYS.includes(k)) return `${id}: unknown key ${JSON.stringify(k)}`;
|
|
184
|
+
for (const k of TICKET_KEYS) if (!(k in t)) return `${id}: missing key ${JSON.stringify(k)}`;
|
|
185
|
+
if (!ROLES.includes(t.role)) return `${id}: role must be implements or enables`;
|
|
186
|
+
if (t.role === 'enables') { if (!text(t.rationale) || t.rationale.trim() === '') return `${id}: an enabling ticket needs a nonempty rationale`; }
|
|
187
|
+
else if (t.rationale !== null) return `${id}: rationale is null for an implementing ticket`;
|
|
188
|
+
}
|
|
189
|
+
for (const [id, c] of Object.entries(doc.checks)) {
|
|
190
|
+
if (!CHECK_ID.test(id)) return `checks: ${JSON.stringify(id)} is not a check ID such as C-01`;
|
|
191
|
+
if (!isObject(c)) return `${id}: must be an object`;
|
|
192
|
+
for (const k of Object.keys(c)) if (!CHECK_KEYS.includes(k)) return `${id}: unknown key ${JSON.stringify(k)}`;
|
|
193
|
+
for (const k of CHECK_KEYS) if (!(k in c)) return `${id}: missing key ${JSON.stringify(k)}`;
|
|
194
|
+
if (!KINDS.includes(c.kind)) return `${id}: kind must be command, review or visual`;
|
|
195
|
+
if (typeof c.required !== 'boolean') return `${id}: required must be true or false`;
|
|
196
|
+
if (!textOrNull(c.note)) return `${id}: note must be null or a short string`;
|
|
197
|
+
if (c.kind === 'command') {
|
|
198
|
+
if (!text(c.command) || c.command.trim() === '') return `${id}: command must be a nonempty string of at most ${MAX_TEXT} characters`;
|
|
199
|
+
if (!Number.isInteger(c.timeout) || c.timeout <= 0 || c.timeout > parse.MAX_TIMEOUT) return `${id}: timeout must be a positive integer number of seconds (at most ${parse.MAX_TIMEOUT})`;
|
|
200
|
+
if (c.obligation !== null) return `${id}: obligation is null for a command check`;
|
|
201
|
+
if (c.cwd !== null) {
|
|
202
|
+
const why = unsafePath(c.cwd); if (why) return `${id}: cwd ${c.cwd}: ${why}`;
|
|
203
|
+
if (root) { const real = realFile(root, c.cwd, { directory: true }); if (real) return `${id}: cwd ${real}`; }
|
|
204
|
+
}
|
|
205
|
+
if (inlineSecretLine(c.command.split('\n'))) return `${id}: command assigns a secret-like literal; reference it from the environment instead`;
|
|
206
|
+
const syntax = spawnSync('bash', ['-n', '-c', `${c.command}\n`], { encoding: 'utf8' });
|
|
207
|
+
if (syntax.error) return `${id}: cannot run bash to check the command (${syntax.error.message})`;
|
|
208
|
+
if (syntax.status !== 0) return `${id}: invalid bash syntax in command`;
|
|
209
|
+
} else {
|
|
210
|
+
if (!text(c.obligation) || c.obligation.trim() === '') return `${id}: a ${c.kind} check needs a nonempty obligation`;
|
|
211
|
+
for (const k of ['command', 'timeout', 'cwd']) if (c[k] !== null) return `${id}: ${k} is null for a ${c.kind} check`;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Canonical form: object keys sorted, ID arrays sorted, no insignificant whitespace.
|
|
218
|
+
function normalize(doc) {
|
|
219
|
+
const canon = v => {
|
|
220
|
+
if (Array.isArray(v)) return v.map(canon);
|
|
221
|
+
if (isObject(v)) { const out = {}; for (const k of Object.keys(v).sort()) out[k] = canon(v[k]); return out; }
|
|
222
|
+
return v;
|
|
223
|
+
};
|
|
224
|
+
const copy = canon(doc);
|
|
225
|
+
for (const s of Object.values(copy.scenarios)) { s.tickets = requirements.sortIds(s.tickets); s.checks = requirements.sortIds(s.checks); }
|
|
226
|
+
return `${JSON.stringify(copy)}\n`;
|
|
227
|
+
}
|
|
228
|
+
const digestOf = text => parse.sha256(text);
|
|
229
|
+
// The definition digest of a declared check (a command's equals the attempt check digest).
|
|
230
|
+
const definitionDigest = c => (c.kind === 'command' ? parse.sha256(`${c.command}\ntimeout=${c.timeout}\n`) : parse.sha256(`${c.kind}\n${c.obligation}\n`));
|
|
231
|
+
|
|
232
|
+
// Read and validate the map of a change. Returns { ok: true, map, text, normalized,
|
|
233
|
+
// digest, file } or { ok: false, code: 'COVERAGE_INVALID', problems: [string] }.
|
|
234
|
+
function readMap(root, record) {
|
|
235
|
+
const rel = file(record.change);
|
|
236
|
+
const invalid = p => ({ ok: false, code: 'COVERAGE_INVALID', problems: [`${rel}: ${p}`], file: rel });
|
|
237
|
+
const real = realFile(root, rel);
|
|
238
|
+
if (real) return real.endsWith(': missing') ? invalid(`missing — author ${rel} first (see docs/runtime-contracts.md, "Coverage map")`) : invalid(real.slice(rel.length + 2));
|
|
239
|
+
let raw;
|
|
240
|
+
try { raw = fs.readFileSync(path.join(root, rel)); } catch (error) { return invalid(error.message); }
|
|
241
|
+
if (raw.length > MAX_BYTES) return invalid(`larger than ${MAX_BYTES} bytes`);
|
|
242
|
+
const text = raw.toString('utf8');
|
|
243
|
+
let doc;
|
|
244
|
+
try { doc = parseStrict(text); } catch (error) { return invalid(/^duplicate key/.test(error.message) ? error.message : `malformed JSON (${error.message})`); }
|
|
245
|
+
const problem = validateMap(doc, { change: record.change, prd: record.prd, root });
|
|
246
|
+
if (problem) return invalid(problem);
|
|
247
|
+
const normalized = normalize(doc);
|
|
248
|
+
return { ok: true, map: doc, text, normalized, digest: digestOf(normalized), file: rel };
|
|
249
|
+
}
|
|
250
|
+
// Validate a map document that is not on disk (a snapshot's text): shape only, no cwd existence.
|
|
251
|
+
function validateText(text, { change, prd }) {
|
|
252
|
+
let doc;
|
|
253
|
+
try { doc = parseStrict(text); } catch (error) { return { problem: /^duplicate key/.test(error.message) ? error.message : `malformed JSON (${error.message})` }; }
|
|
254
|
+
const problem = validateMap(doc, { change, prd, root: null });
|
|
255
|
+
if (problem) return { problem };
|
|
256
|
+
return { map: doc, normalized: normalize(doc), digest: digestOf(normalize(doc)) };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// --- The tickets of a change ---------------------------------------------------------------
|
|
260
|
+
// { ok, tickets: { "T-NN": { file, text, fields, timeout } }, others: { "T-NN": prd } } or
|
|
261
|
+
// { ok: false, code: 'INPUT_INVALID', problems }.
|
|
262
|
+
function ticketsOf(root, record) {
|
|
263
|
+
const set = parse.validateTicketSet(root);
|
|
264
|
+
if (!set.ok) return { ok: false, code: 'INPUT_INVALID', problems: [`pincer-ticket: ${set.file ? `${set.file}: ` : ''}${set.problems[0]}`] };
|
|
265
|
+
const statusModule = require('./status.cjs');
|
|
266
|
+
const tickets = {}, others = {};
|
|
267
|
+
for (const f of set.files) {
|
|
268
|
+
const text = fs.readFileSync(path.join(root, f), 'utf8');
|
|
269
|
+
const v = parse.validateTicket(f, text);
|
|
270
|
+
const assoc = statusModule.ticketPrd(root, f, v.fields);
|
|
271
|
+
if (assoc.problem) return { ok: false, code: 'INPUT_INVALID', problems: [assoc.problem] };
|
|
272
|
+
if (assoc.prd === record.prd) tickets[v.fields.ticket] = { file: f, text, fields: v.fields, timeout: v.timeout };
|
|
273
|
+
else others[v.fields.ticket] = assoc.prd;
|
|
274
|
+
}
|
|
275
|
+
return { ok: true, tickets, others };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// --- The graph ---------------------------------------------------------------------------------
|
|
279
|
+
// Resolve a validated map against the inventory and the change's tickets. Pure:
|
|
280
|
+
// returns { complete, problems: [{ code: 'COVERAGE_INCOMPLETE', detail, ids }],
|
|
281
|
+
// scenarios, scope, tickets, checks } where every entry carries its resolved links.
|
|
282
|
+
// `priorInventory(gid)` returns the inventory snapshot of an agreement entry (or null)
|
|
283
|
+
// for removed tombstones; without it, tombstone priors are checked for shape only.
|
|
284
|
+
function resolve({ map, inventory, tickets, others = {}, priorInventory = null }) {
|
|
285
|
+
const problems = [];
|
|
286
|
+
const problem = (detail, ids) => problems.push({ code: 'COVERAGE_INCOMPLETE', detail, ids: requirements.sortIds([...new Set(ids)]) });
|
|
287
|
+
const live = inventory.scenarios;
|
|
288
|
+
const graph = { scenarios: {}, scope: {}, tickets: {}, checks: {} };
|
|
289
|
+
for (const [id, c] of Object.entries(map.checks)) graph.checks[id] = { id, ...c, digest: definitionDigest(c), scenarios: [] };
|
|
290
|
+
for (const [id, t] of Object.entries(map.tickets)) graph.tickets[id] = { id, role: t.role, rationale: t.rationale, scenarios: [], present: Boolean(tickets[id]), other: others[id] || null };
|
|
291
|
+
// Membership: every live scenario exactly once; no invented or stale rows.
|
|
292
|
+
const missing = Object.keys(live).filter(id => !(id in map.scenarios) && !(id in map.scope));
|
|
293
|
+
if (missing.length) problem(`${missing.join(', ')} ${missing.length === 1 ? 'has' : 'have'} no row in scenarios or scope`, missing);
|
|
294
|
+
for (const id of Object.keys(map.scenarios)) {
|
|
295
|
+
if (!live[id]) {
|
|
296
|
+
problem(`${id} is not a scenario of the inventory${inventory.requirements[id] ? ' (a requirement is covered through its scenarios)' : ''}`, [id]);
|
|
297
|
+
// A stale row's links are still authored links: they keep counting for classification.
|
|
298
|
+
for (const t of map.scenarios[id].tickets) if (graph.tickets[t]) graph.tickets[t].scenarios.push(id);
|
|
299
|
+
for (const c of map.scenarios[id].checks) if (graph.checks[c]) graph.checks[c].scenarios.push(id);
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
const row = map.scenarios[id];
|
|
303
|
+
const entry = { id, requirement: live[id].requirement, tickets: requirements.sortIds(row.tickets), checks: requirements.sortIds(row.checks) };
|
|
304
|
+
if (!row.tickets.length) problem(`${id}: no implementing ticket`, [id]);
|
|
305
|
+
if (!row.checks.length) problem(`${id}: no candidate check`, [id]);
|
|
306
|
+
for (const t of row.tickets) {
|
|
307
|
+
const classified = map.tickets[t];
|
|
308
|
+
if (!tickets[t]) problem(`${id}: ticket ${t} is not a ticket of this change${others[t] ? ` (WRONG_CHANGE: it belongs to ${others[t]})` : ' (no such ticket file)'}`, [id, t]);
|
|
309
|
+
else if (!classified) problem(`${id}: ticket ${t} is not classified in tickets`, [id, t]);
|
|
310
|
+
else if (classified.role !== 'implements') problem(`${id}: ticket ${t} is classified enables, but the scenario links it as implementation`, [id, t]);
|
|
311
|
+
if (graph.tickets[t]) graph.tickets[t].scenarios.push(id);
|
|
312
|
+
}
|
|
313
|
+
for (const c of row.checks) {
|
|
314
|
+
if (!map.checks[c]) problem(`${id}: check ${c} is not declared`, [id, c]);
|
|
315
|
+
else graph.checks[c].scenarios.push(id);
|
|
316
|
+
}
|
|
317
|
+
graph.scenarios[id] = entry;
|
|
318
|
+
}
|
|
319
|
+
for (const id of Object.keys(map.scope)) {
|
|
320
|
+
const row = map.scope[id];
|
|
321
|
+
const entry = { id, requirement: live[id] ? live[id].requirement : null, disposition: row.disposition, decision: row.decision, prior: row.prior, note: row.note, live: Boolean(live[id]) };
|
|
322
|
+
if (!live[id]) {
|
|
323
|
+
if (row.disposition !== 'removed') { problem(`${id} is not a scenario of the inventory (a deferral applies to a defined scenario; a withdrawn one needs a removed tombstone)`, [id]); continue; }
|
|
324
|
+
if (inventory.requirements[id]) { problem(`${id} is a requirement; scope entries name scenarios`, [id]); continue; }
|
|
325
|
+
}
|
|
326
|
+
if (row.disposition === 'removed' && priorInventory) {
|
|
327
|
+
const prior = priorInventory(row.prior);
|
|
328
|
+
if (!prior) problem(`${id}: prior agreement ${row.prior} is not a retained agreement with an inventory snapshot`, [id]);
|
|
329
|
+
else if (!prior.scenarios[id]) problem(`${id}: prior agreement ${row.prior} does not define ${id}`, [id]);
|
|
330
|
+
else entry.requirement = entry.requirement || prior.scenarios[id].requirement;
|
|
331
|
+
}
|
|
332
|
+
graph.scope[id] = entry;
|
|
333
|
+
}
|
|
334
|
+
// Classification: every ticket of the change exactly once, roles consistent with the links.
|
|
335
|
+
for (const id of Object.keys(tickets)) if (!map.tickets[id]) problem(`${id} is not classified (every ticket of the change is listed with role implements or enables)`, [id]);
|
|
336
|
+
for (const [id, t] of Object.entries(graph.tickets)) {
|
|
337
|
+
if (!tickets[id]) problem(`${id} is listed in tickets but is not a ticket of this change${others[id] ? ` (WRONG_CHANGE: it belongs to ${others[id]})` : ''}`, [id]);
|
|
338
|
+
else if (t.role === 'implements' && !t.scenarios.length) problem(`${id} is classified implements but no scenario links it (classify it enables with a rationale, or link it)`, [id]);
|
|
339
|
+
else if (t.role === 'enables' && t.scenarios.length) problem(`${id} is classified enables but ${t.scenarios.join(', ')} links it as implementation`, [id, ...t.scenarios]);
|
|
340
|
+
t.scenarios = requirements.sortIds(t.scenarios);
|
|
341
|
+
}
|
|
342
|
+
for (const c of Object.values(graph.checks)) c.scenarios = requirements.sortIds(c.scenarios);
|
|
343
|
+
return { complete: problems.length === 0, problems, ...graph };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Load everything for a change: the inventory, the map and the graph. Returns
|
|
347
|
+
// { ok, code, problems, inventory, map, tickets, graph }. `ok` is false for
|
|
348
|
+
// INPUT_INVALID / INVENTORY_INVALID / COVERAGE_INVALID (nothing usable) and for
|
|
349
|
+
// COVERAGE_INCOMPLETE (inventory and map usable, graph.problems set).
|
|
350
|
+
function load(root, record, { priorInventory = null } = {}) {
|
|
351
|
+
const inv = requirements.readInventory(root, record.prd);
|
|
352
|
+
if (!inv.ok) return { ok: false, code: inv.code, problems: inv.problems };
|
|
353
|
+
const m = readMap(root, record);
|
|
354
|
+
if (!m.ok) return { ok: false, code: m.code, problems: m.problems, inventory: inv.inventory };
|
|
355
|
+
const t = ticketsOf(root, record);
|
|
356
|
+
if (!t.ok) return { ok: false, code: t.code, problems: t.problems, inventory: inv.inventory, map: m };
|
|
357
|
+
const graph = resolve({ map: m.map, inventory: inv.inventory, tickets: t.tickets, others: t.others, priorInventory });
|
|
358
|
+
return { ok: graph.complete, code: graph.complete ? null : 'COVERAGE_INCOMPLETE', problems: graph.problems.map(p => p.detail), inventory: inv.inventory, map: m, tickets: t.tickets, others: t.others, graph };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
module.exports = { SCHEMA, DIR, MAX_BYTES, MAX_TEXT, KINDS, ROLES, DISPOSITIONS, CHECK_ID, file, parseStrict, unsafePath, realFile, validateMap, validateText, normalize, digestOf, definitionDigest, readMap, ticketsOf, resolve, load };
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// PINCER runtime — scope dispositions and deletion detection (docs/runtime-contracts.md,
|
|
3
|
+
// "Strict coverage" → "Scope dispositions"). A deferral or removal recorded in the
|
|
4
|
+
// coverage map is authorized only by a resolved decision of the same change that
|
|
5
|
+
// names the ID and by an applicable user authorization reachable from the current
|
|
6
|
+
// one through its basis chain; free text is never consulted. Deleted obligations
|
|
7
|
+
// are detected against the retained baseline inventory (the latest authorized
|
|
8
|
+
// agreement's snapshot, else the latest agreement with an inventory), so deleting
|
|
9
|
+
// prose and map row together erases nothing. Pure functions; nothing is written.
|
|
10
|
+
const agreement = require('./agreement.cjs');
|
|
11
|
+
const requirements = require('./requirements.cjs');
|
|
12
|
+
|
|
13
|
+
// Whether a decision names an ID as a whole token in its summary or excerpt.
|
|
14
|
+
function namesId(decision, id) {
|
|
15
|
+
const re = new RegExp(`(^|[^A-Z0-9-])${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![A-Z0-9-])`);
|
|
16
|
+
return re.test(decision.summary || '') || re.test(decision.excerpt || '');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// The user authorization that applies to a decision from the current authorization:
|
|
20
|
+
// the current one when it is `user` and lists the decision, else the `user`
|
|
21
|
+
// authorization its basis chain ends in when that one lists it. Returns the
|
|
22
|
+
// authorization or null with the reason.
|
|
23
|
+
function applicableUserAuthorization(record, current, decisionId) {
|
|
24
|
+
if (!current) return { authorization: null, reason: 'no current authorization' };
|
|
25
|
+
const byId = new Map(record.authorizations.map(a => [a.id, a]));
|
|
26
|
+
const seen = new Set();
|
|
27
|
+
let a = current;
|
|
28
|
+
while (a && !seen.has(a.id)) {
|
|
29
|
+
seen.add(a.id);
|
|
30
|
+
if (a.disposition === 'user') {
|
|
31
|
+
if (a.decisions.includes(decisionId)) return { authorization: a, reason: null };
|
|
32
|
+
return { authorization: null, reason: a.id === current.id ? `the current authorization ${a.id} does not name ${decisionId}` : `the current authorization ${current.id} descends from ${a.id}, which does not name ${decisionId}` };
|
|
33
|
+
}
|
|
34
|
+
a = a.basis ? byId.get(a.basis) : null;
|
|
35
|
+
}
|
|
36
|
+
return { authorization: null, reason: `no user authorization names ${decisionId}` };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Problems for every scope entry of the graph: [{ code: 'SCOPE_UNAUTHORIZED', detail, ids, id, decision, authorization }].
|
|
40
|
+
// `verdict` is the authorization verdict computed for the record (its `authorized` entry is the current one).
|
|
41
|
+
function scopeProblems(record, graph, verdict) {
|
|
42
|
+
const problems = [];
|
|
43
|
+
const resolved = [];
|
|
44
|
+
for (const entry of Object.values(graph.scope)) {
|
|
45
|
+
const id = entry.id;
|
|
46
|
+
const label = `${id} (${entry.disposition})`;
|
|
47
|
+
const decision = record.decisions.find(d => d.id === entry.decision);
|
|
48
|
+
if (!decision) { problems.push({ code: 'SCOPE_UNAUTHORIZED', detail: `${label}: no decision ${entry.decision} on change ${record.change}`, ids: [id], id, decision: entry.decision, authorization: null }); continue; }
|
|
49
|
+
if (decision.status !== 'resolved') { problems.push({ code: 'SCOPE_UNAUTHORIZED', detail: `${label}: decision ${decision.id} is open`, ids: [id], id, decision: decision.id, authorization: null }); continue; }
|
|
50
|
+
if (!namesId(decision, id)) { problems.push({ code: 'SCOPE_UNAUTHORIZED', detail: `${label}: decision ${decision.id} does not name ${id}`, ids: [id], id, decision: decision.id, authorization: null }); continue; }
|
|
51
|
+
// Every authorization that binds the current agreement is "the current one"; the
|
|
52
|
+
// disposition is authorized when any of them is, or descends from, a user
|
|
53
|
+
// authorization naming the decision. The latest one's reason is reported otherwise.
|
|
54
|
+
const current = verdict && verdict.verdict === 'current' ? record.authorizations.filter(a => a.digest === verdict.current) : [];
|
|
55
|
+
let applicable = { authorization: null, reason: 'no current authorization' };
|
|
56
|
+
for (const a of current) { applicable = applicableUserAuthorization(record, a, decision.id); if (applicable.authorization) break; }
|
|
57
|
+
if (!applicable.authorization) { problems.push({ code: 'SCOPE_UNAUTHORIZED', detail: `${label}: ${verdict && verdict.verdict !== 'current' ? `the agreement is not authorized (${verdict.verdict})` : applicable.reason}`, ids: [id], id, decision: decision.id, authorization: null }); continue; }
|
|
58
|
+
resolved.push({ id, disposition: entry.disposition, decision: decision.id, authorization: applicable.authorization.id, excerpt: decision.excerpt, reference: decision.reference });
|
|
59
|
+
}
|
|
60
|
+
return { problems, resolved };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// The baseline of a strict change: the union of the inventories of its retained
|
|
64
|
+
// agreement snapshots (the adoption agreement and every later one, authorized or
|
|
65
|
+
// not), so an obligation that was ever reviewed stays one until a tombstone
|
|
66
|
+
// withdraws it. Returns { agreements: [G-NN], scenarios: { id: { requirement,
|
|
67
|
+
// agreement: <latest defining G-NN>, authorization: <latest A-NN binding a defining
|
|
68
|
+
// agreement> | null } } } or null when no snapshot carries an inventory.
|
|
69
|
+
function baseline(root, record) {
|
|
70
|
+
const scenarios = {}, agreements = [];
|
|
71
|
+
for (const entry of record.agreements) {
|
|
72
|
+
const inventory = agreement.inventoryOf(root, record, entry);
|
|
73
|
+
if (!inventory) continue;
|
|
74
|
+
agreements.push(entry.id);
|
|
75
|
+
const auth = record.authorizations.filter(a => a.agreement === entry.id).map(a => a.id).pop() || null;
|
|
76
|
+
for (const [id, s] of Object.entries(inventory.scenarios)) {
|
|
77
|
+
const prior = scenarios[id];
|
|
78
|
+
scenarios[id] = { requirement: s.requirement, agreement: entry.id, authorization: auth || (prior ? prior.authorization : null) };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return agreements.length ? { agreements, scenarios } : null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// OBLIGATION_MISSING: baseline scenarios defined neither in the live inventory nor as
|
|
85
|
+
// a removed tombstone in the map. Returns [{ code, detail, ids }].
|
|
86
|
+
function obligationProblems(base, inventory, map) {
|
|
87
|
+
if (!base) return [];
|
|
88
|
+
const missing = Object.keys(base.scenarios).filter(id => !inventory.scenarios[id] && !(map.scope[id] && map.scope[id].disposition === 'removed'));
|
|
89
|
+
if (!missing.length) return [];
|
|
90
|
+
const ids = requirements.sortIds(missing);
|
|
91
|
+
const where = ids.map(id => `${id} (last defined by ${base.scenarios[id].agreement}${base.scenarios[id].authorization ? `, authorized by ${base.scenarios[id].authorization}` : ''})`).join(', ');
|
|
92
|
+
return [{ code: 'OBLIGATION_MISSING', detail: `${where} of the reviewed inventory ${ids.length === 1 ? 'is' : 'are'} defined neither in the PRD nor as a removed tombstone in the coverage map; restore ${ids.length === 1 ? 'it' : 'them'}, or record the decision and the tombstone`, ids }];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
module.exports = { namesId, applicableUserAuthorization, scopeProblems, baseline, obligationProblems };
|