pincer-workflow 0.4.1 → 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 +9 -7
- package/bin/pincer.js +58 -5
- package/package.json +3 -3
- package/template/.agents/skills/pincer-code/SKILL.md +117 -12
- package/template/.agents/skills/pincer-evaluate/SKILL.md +57 -10
- package/template/.agents/skills/pincer-narrow/SKILL.md +48 -8
- package/template/.agents/skills/pincer-plan/SKILL.md +12 -4
- package/template/.agents/skills/pincer-release/SKILL.md +32 -3
- package/template/.agents/skills/pincer-status/SKILL.md +25 -2
- package/template/.claude/commands/pincer-code.md +117 -12
- package/template/.claude/commands/pincer-evaluate.md +57 -10
- package/template/.claude/commands/pincer-narrow.md +48 -8
- package/template/.claude/commands/pincer-plan.md +12 -4
- package/template/.claude/commands/pincer-release.md +32 -3
- package/template/.claude/commands/pincer-status.md +25 -2
- package/template/.claude/hooks/hook-policy.cjs +24 -3
- package/template/.claude/references/prd-template.md +11 -4
- package/template/.claude/references/ticket-template.md +4 -0
- package/template/.codex/README.md +3 -2
- package/template/.github/prompts/pincer-code.prompt.md +117 -12
- package/template/.github/prompts/pincer-evaluate.prompt.md +57 -10
- package/template/.github/prompts/pincer-narrow.prompt.md +48 -8
- package/template/.github/prompts/pincer-plan.prompt.md +12 -4
- package/template/.github/prompts/pincer-release.prompt.md +32 -3
- package/template/.github/prompts/pincer-status.prompt.md +25 -2
- package/template/AGENTS.md +22 -0
- package/template/docs/dry-run-checklist.md +70 -6
- package/template/docs/release-checklist.md +5 -2
- package/template/docs/runtime-contracts.md +1683 -0
- package/template/scripts/pincer-evidence.cjs +13 -229
- 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 +676 -0
- package/template/scripts/pincer-runtime/fsutil.cjs +37 -0
- package/template/scripts/pincer-runtime/gates.cjs +73 -0
- package/template/scripts/pincer-runtime/identity.cjs +163 -0
- 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 +311 -0
- package/template/scripts/pincer-runtime/locator.cjs +158 -0
- package/template/scripts/pincer-runtime/migrate.cjs +204 -0
- package/template/scripts/pincer-runtime/parse.cjs +316 -0
- package/template/scripts/pincer-runtime/phases.cjs +245 -0
- package/template/scripts/pincer-runtime/readiness.cjs +97 -0
- 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 +242 -0
- package/template/scripts/pincer-runtime/sanitize.cjs +63 -0
- package/template/scripts/pincer-runtime/source.cjs +129 -0
- package/template/scripts/pincer-runtime/state.cjs +314 -0
- package/template/scripts/pincer-runtime/status.cjs +514 -0
- package/template/scripts/pincer-runtime/transaction.cjs +200 -0
- package/template/scripts/pincer-runtime/transitions.cjs +134 -0
- package/template/scripts/pincer-runtime.cjs +661 -0
- package/template/scripts/pincer-status.sh +11 -162
- package/template/scripts/pincer-ticket.sh +19 -139
- package/template/scripts/pincer-ticket-lib.sh +0 -321
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Small filesystem helpers shared by the runtime modules.
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const crypto = require('node:crypto');
|
|
6
|
+
const { execFileSync } = require('node:child_process');
|
|
7
|
+
|
|
8
|
+
const nowIso = () => new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
9
|
+
|
|
10
|
+
// Write via a temporary file in the same directory (or the journal directory on
|
|
11
|
+
// the same filesystem) and rename into place, so a reader never sees a partial
|
|
12
|
+
// file and a crash leaves either the old file or the new one.
|
|
13
|
+
function atomicWrite(file, content, { journalDir } = {}) {
|
|
14
|
+
const dir = journalDir || path.dirname(file);
|
|
15
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
16
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
17
|
+
const temp = path.join(dir, `.${path.basename(file)}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`);
|
|
18
|
+
fs.writeFileSync(temp, content);
|
|
19
|
+
fs.renameSync(temp, file);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function readJson(file) {
|
|
23
|
+
let raw;
|
|
24
|
+
try { raw = fs.readFileSync(file, 'utf8'); } catch (error) { return { error: error.code === 'ENOENT' ? 'missing' : error.message }; }
|
|
25
|
+
try { return { data: JSON.parse(raw) }; } catch (error) { return { error: `malformed JSON (${error.message})` }; }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function git(root, args, options = {}) {
|
|
29
|
+
return execFileSync('git', ['-C', root, '-c', 'core.quotePath=false', ...args], {
|
|
30
|
+
encoding: options.buffer ? 'buffer' : 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 256 * 1024 * 1024, ...options,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
function tryGit(root, args, options) {
|
|
34
|
+
try { return { out: git(root, args, options) }; } catch (error) { return { error: (error.stderr && error.stderr.toString().trim()) || error.message }; }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
module.exports = { nowIso, atomicWrite, readJson, git, tryGit };
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// PINCER runtime — command gates (docs/runtime-contracts.md, "Command gates").
|
|
3
|
+
// In changes mode every execution or lifecycle-writing command passes this one
|
|
4
|
+
// guard before a child process is spawned or a ticket file written: the
|
|
5
|
+
// records must be readable, a change must be selected and own the ticket (or
|
|
6
|
+
// PRD), its lifecycle state must permit the command, the repository view must
|
|
7
|
+
// be compatible, and the authorization verdict must be `current`. Refusals carry
|
|
8
|
+
// the contracted code, in the contracted order, and nothing has been written.
|
|
9
|
+
const changes = require('./changes.cjs');
|
|
10
|
+
const agreement = require('./agreement.cjs');
|
|
11
|
+
const authorization = require('./authorization.cjs');
|
|
12
|
+
const { refuse } = require('./transaction.cjs');
|
|
13
|
+
|
|
14
|
+
// Lifecycle states that permit each command.
|
|
15
|
+
const PERMITTED = {
|
|
16
|
+
start: ['active'], done: ['active'], verify: ['active', 'completed'],
|
|
17
|
+
check: ['completed'], export: ['completed'],
|
|
18
|
+
};
|
|
19
|
+
const ORDER = ['INPUT_INVALID', 'INVENTORY_INVALID', 'COVERAGE_INVALID', 'MALFORMED', 'UNSUPPORTED_SCHEMA', 'HISTORY_INVALID', 'STATE_INCOMPLETE', 'SELECTION_REQUIRED', 'SELECTION_INVALID', 'WRONG_CHANGE', 'LIFECYCLE_BLOCKED', 'BASE_MISMATCH', 'DECISION_REQUIRED', 'AUTHORIZATION_REQUIRED', 'AGREEMENT_CHANGED'];
|
|
20
|
+
|
|
21
|
+
// guard(root, { command, ticket: { file, fields } | null, prd: string | null })
|
|
22
|
+
// Returns { id, record, file, selection, view, computed, verdict, binding } where
|
|
23
|
+
// `binding` is the context every attempt, readiness and export consumes:
|
|
24
|
+
// { change, prd, prd_revision, base, agreement, legacy_receipts, mode: 'changes' }.
|
|
25
|
+
// Throws a transaction Refusal on the first failing gate.
|
|
26
|
+
function guard(root, { command, ticket = null, prd = null }) {
|
|
27
|
+
const resolved = changes.resolveSelected(root, {});
|
|
28
|
+
if (resolved.code) {
|
|
29
|
+
const hint = resolved.code === 'SELECTION_REQUIRED' || resolved.code === 'SELECTION_INVALID' ? ` (execution needs the selected change; ${resolved.problem})` : `: ${resolved.problem}`;
|
|
30
|
+
refuse(resolved.code, `${command} refused${hint}`);
|
|
31
|
+
}
|
|
32
|
+
const { id, record, file, selection, loaded } = resolved;
|
|
33
|
+
// Ownership: the ticket's PRD, or the named PRD, must belong to the selected change.
|
|
34
|
+
let owner = null;
|
|
35
|
+
if (ticket) {
|
|
36
|
+
const o = changes.ticketOwner(root, loaded, ticket.file, ticket.fields);
|
|
37
|
+
if (o.problem) refuse(o.id ? 'WRONG_CHANGE' : 'INPUT_INVALID', `${command} refused: ${o.problem}`);
|
|
38
|
+
owner = o;
|
|
39
|
+
} else if (prd) {
|
|
40
|
+
const o = changes.ownerOf(loaded, prd);
|
|
41
|
+
if (!o) refuse('INPUT_INVALID', `${command} refused: ${prd} is owned by no change record; register it first`);
|
|
42
|
+
owner = { id: o[0], prd };
|
|
43
|
+
}
|
|
44
|
+
if (owner && owner.id !== id) refuse('WRONG_CHANGE', `${command} refused: ${ticket ? `${ticket.fields.ticket} (${owner.prd})` : owner.prd} belongs to change ${owner.id}, but ${id} is selected; select it first: node scripts/pincer-runtime.cjs change select ${owner.id}`);
|
|
45
|
+
const st = record.lifecycle.state;
|
|
46
|
+
if (!PERMITTED[command].includes(st)) {
|
|
47
|
+
const next = changes.TERMINAL.includes(st) ? `the change is ${st}${record.lifecycle.superseded_by ? ` by ${record.lifecycle.superseded_by}` : ''}; register a new change and reference this one`
|
|
48
|
+
: st === 'planned' ? `activate it first: node scripts/pincer-runtime.cjs change activate ${id}`
|
|
49
|
+
: st === 'paused' ? `resume it first: node scripts/pincer-runtime.cjs change resume ${id}${record.lifecycle.reason ? ` (paused: ${record.lifecycle.reason})` : ''}`
|
|
50
|
+
: st === 'completed' ? `${command} needs an active change; reopen it first: node scripts/pincer-runtime.cjs change reopen ${id} --reason <text>`
|
|
51
|
+
: `${command} needs a completed change; complete it first: node scripts/pincer-runtime.cjs change complete ${id}`;
|
|
52
|
+
refuse('LIFECYCLE_BLOCKED', `${command} refused: change ${id} is ${st} (${command} runs on ${PERMITTED[command].join(' or ')} changes); ${next}`);
|
|
53
|
+
}
|
|
54
|
+
const view = changes.view(root, record);
|
|
55
|
+
if (view.problems.length) refuse('BASE_MISMATCH', `${command} refused: ${view.problems[0].detail}`);
|
|
56
|
+
const computed = agreement.compute(root, record);
|
|
57
|
+
if (computed.code) refuse(computed.code, `${command} refused: ${computed.problem}`);
|
|
58
|
+
const verdict = authorization.verdict(root, record, computed);
|
|
59
|
+
if (verdict.verdict !== 'current') refuse(verdict.verdict, `${command} refused: ${verdict.detail}`);
|
|
60
|
+
// A strict change's binding carries its inventory and coverage map digests (attempt schema 3).
|
|
61
|
+
const binding = { change: id, prd: record.prd, prd_revision: computed.prd.revision, base: record.base, agreement: computed.digest, legacy_receipts: record.legacy.receipts, mode: 'changes', strict: Boolean(computed.inventory), ...(computed.inventory ? { inventory: computed.inventory.digest, coverage: computed.coverage.digest } : {}) };
|
|
62
|
+
return { id, record, file, selection, view, computed, verdict, binding, loaded };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// The read-only counterpart for status/ready: the same checks as reasons, none thrown.
|
|
66
|
+
function reasons(root, { command, ticket = null, prd = null }) {
|
|
67
|
+
try { guard(root, { command, ticket, prd }); return []; } catch (error) {
|
|
68
|
+
if (error && error.refusal) return [{ code: error.code, detail: error.message }];
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
module.exports = { guard, reasons, PERMITTED, ORDER };
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// PINCER runtime — change identity (docs/runtime-contracts.md, "Change binding").
|
|
3
|
+
// A change binding under .prd/changes/<id>.json ties runtime verification to one
|
|
4
|
+
// explicitly selected PRD and its content revision. Written only here and by
|
|
5
|
+
// migration; never inferred from the highest PRD number.
|
|
6
|
+
const fs = require('node:fs');
|
|
7
|
+
const path = require('node:path');
|
|
8
|
+
const parse = require('./parse.cjs');
|
|
9
|
+
const { nowIso, atomicWrite, readJson, tryGit } = require('./fsutil.cjs');
|
|
10
|
+
|
|
11
|
+
const CHANGE_ID = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
12
|
+
const SHA256 = /^[0-9a-f]{64}$/;
|
|
13
|
+
const RUNTIME = 1;
|
|
14
|
+
const BINDING_KEYS = ['schema', 'change', 'prd', 'prd_revision', 'base', 'registered', 'authorization', 'runtime', 'legacy_receipts'];
|
|
15
|
+
|
|
16
|
+
const bindingsDir = root => path.join(root, '.prd', 'changes');
|
|
17
|
+
const CHANGES_HINT = 'this project keeps change records (schema 2) under .prd/changes/; inspect them with: node scripts/pincer-runtime.cjs change list';
|
|
18
|
+
function listBindings(root) {
|
|
19
|
+
const dir = bindingsDir(root);
|
|
20
|
+
if (!fs.existsSync(dir)) return [];
|
|
21
|
+
return fs.readdirSync(dir).filter(name => name.endsWith('.json')).sort().map(name => `.prd/changes/${name}`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function validateBinding(doc) {
|
|
25
|
+
if (!doc || typeof doc !== 'object' || Array.isArray(doc)) return 'binding must be a JSON object';
|
|
26
|
+
if (doc.schema !== 1) return `unsupported binding schema ${JSON.stringify(doc.schema)} (this runtime reads schema 1)`;
|
|
27
|
+
for (const key of Object.keys(doc)) if (!BINDING_KEYS.includes(key)) return `unknown binding key "${key}"`;
|
|
28
|
+
for (const key of BINDING_KEYS) if (!(key in doc)) return `missing binding key "${key}"`;
|
|
29
|
+
if (typeof doc.change !== 'string' || !CHANGE_ID.test(doc.change)) return 'change must match [a-z0-9][a-z0-9-]{0,63}';
|
|
30
|
+
if (typeof doc.prd !== 'string' || !parse.PRD_REF.test(doc.prd)) return 'prd must be of the form .prd/prd-vN.md';
|
|
31
|
+
if (typeof doc.prd_revision !== 'string' || !SHA256.test(doc.prd_revision)) return 'prd_revision must be a 64-hex SHA-256 digest';
|
|
32
|
+
if (typeof doc.base !== 'string' || !parse.HEX40.test(doc.base)) return 'base must be a full 40-hex commit ID';
|
|
33
|
+
if (typeof doc.registered !== 'string' || !parse.TIMESTAMP.test(doc.registered)) return 'registered must be an ISO UTC timestamp';
|
|
34
|
+
if (doc.authorization !== null && (typeof doc.authorization !== 'string' || doc.authorization.length > 2000)) return 'authorization must be null or a short string';
|
|
35
|
+
if (doc.runtime !== RUNTIME) return `unsupported runtime contract ${JSON.stringify(doc.runtime)}`;
|
|
36
|
+
if (!doc.legacy_receipts || typeof doc.legacy_receipts !== 'object' || Array.isArray(doc.legacy_receipts)) return 'legacy_receipts must be an object';
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Resolve the binding for this worktree. Returns { binding, file, prd } or
|
|
41
|
+
// { code, problem } with the contracted codes; `prd`, when given, is the PRD the
|
|
42
|
+
// caller needs — a binding for another PRD is CHANGE_REQUIRED with `other` set so
|
|
43
|
+
// callers can treat that PRD as legacy.
|
|
44
|
+
function loadBinding(root, { prd } = {}) {
|
|
45
|
+
// Mode first (docs/runtime-contracts.md, "Modes"): schema 2 records are the
|
|
46
|
+
// changes mode and never fall back to a binding or to legacy; mixed or
|
|
47
|
+
// unreadable directories are invalid.
|
|
48
|
+
const scan = require('./changes.cjs').scan(root);
|
|
49
|
+
if (scan.mode === 'changes') return { code: 'CHANGES_MODE', problem: `${CHANGES_HINT}`, scan };
|
|
50
|
+
if (scan.mode === 'invalid') return { code: scan.problems[0].code, problem: scan.problems[0].detail, scan };
|
|
51
|
+
// A committed transaction that was not fully applied is unsafe in every mode, not
|
|
52
|
+
// only in changes mode: `recover` finishes it by renaming its staged files into
|
|
53
|
+
// place, over anything written since. Changes mode raises this through
|
|
54
|
+
// changes.scan(); a migrated or legacy project reaches it here, and a migrated
|
|
55
|
+
// project is the one `migrate --apply` crashes in.
|
|
56
|
+
const pending = require('./transaction.cjs').pending(root);
|
|
57
|
+
if (pending.committed.length) {
|
|
58
|
+
const first = pending.committed[0];
|
|
59
|
+
return { code: 'STATE_INCOMPLETE', problem: `a committed transaction (${first.command || first.id}) was not fully applied; run: node scripts/pincer-runtime.cjs recover` };
|
|
60
|
+
}
|
|
61
|
+
const files = listBindings(root);
|
|
62
|
+
if (files.length === 0) {
|
|
63
|
+
const hint = prd ? `register it with: node scripts/pincer-runtime.cjs register --prd ${prd}` : 'run register or migrate';
|
|
64
|
+
return { code: 'CHANGE_REQUIRED', problem: `no change binding under .prd/changes/ — ${hint}` };
|
|
65
|
+
}
|
|
66
|
+
if (files.length > 1) return { code: 'AMBIGUOUS', problem: `several change bindings under .prd/changes/ (${files.map(f => path.basename(f)).join(', ')}) — keep exactly one` };
|
|
67
|
+
const file = files[0];
|
|
68
|
+
const read = readJson(path.join(root, file));
|
|
69
|
+
if (read.error) return { code: 'MALFORMED', problem: `${file}: ${read.error}`, file };
|
|
70
|
+
const invalid = validateBinding(read.data);
|
|
71
|
+
if (invalid) return { code: /schema|runtime contract/.test(invalid) ? 'UNSUPPORTED_SCHEMA' : 'MALFORMED', problem: `${file}: ${invalid}`, file };
|
|
72
|
+
const binding = read.data;
|
|
73
|
+
if (path.basename(file, '.json') !== binding.change) return { code: 'MALFORMED', problem: `${file}: filename does not match change "${binding.change}"`, file };
|
|
74
|
+
if (prd && binding.prd !== prd) return { code: 'CHANGE_REQUIRED', other: true, binding, file, problem: `${file} binds ${binding.prd}, not ${prd}` };
|
|
75
|
+
const prdResult = parse.validatePrd(root, binding.prd);
|
|
76
|
+
if (!prdResult.ok) return { code: 'INPUT_INVALID', problem: `${binding.prd}: ${prdResult.problems[0]}`, binding, file };
|
|
77
|
+
const revision = parse.prdDigest(prdResult.text);
|
|
78
|
+
if (revision !== binding.prd_revision) {
|
|
79
|
+
return { code: 'REVISION_CHANGED', binding, file, revision, problem: `${binding.prd} content changed since registration (revision ${binding.prd_revision.slice(0, 12)} → ${revision.slice(0, 12)}) — rebind explicitly with: node scripts/pincer-runtime.cjs register --prd ${binding.prd} --rebind` };
|
|
80
|
+
}
|
|
81
|
+
return { binding, file, prd: prdResult };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function head(root) {
|
|
85
|
+
const result = tryGit(root, ['rev-parse', '--verify', 'HEAD^{commit}']);
|
|
86
|
+
if (result.error || !parse.HEX40.test(result.out.trim())) return null;
|
|
87
|
+
return result.out.trim();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const IGNORE_LINE = '.pincer/';
|
|
91
|
+
function gitignoreHas(root) {
|
|
92
|
+
const file = path.join(root, '.gitignore');
|
|
93
|
+
if (!fs.existsSync(file)) return false;
|
|
94
|
+
return fs.readFileSync(file, 'utf8').split('\n').map(l => l.trim()).some(l => l === IGNORE_LINE || l === '/.pincer/' || l === '.pincer');
|
|
95
|
+
}
|
|
96
|
+
// Local runtime state must never become an untracked change: registration and
|
|
97
|
+
// migration both make sure .pincer/ is ignored before the binding is written.
|
|
98
|
+
function ensureIgnored(root) {
|
|
99
|
+
if (gitignoreHas(root)) return false;
|
|
100
|
+
const file = path.join(root, '.gitignore');
|
|
101
|
+
const existing = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
|
|
102
|
+
const lead = existing && !existing.endsWith('\n') ? '\n' : '';
|
|
103
|
+
fs.appendFileSync(file, `${lead}${existing ? '\n' : ''}# pincer runtime state (added by the runtime)\n${IGNORE_LINE}\n`);
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function writeBinding(root, binding) {
|
|
108
|
+
ensureIgnored(root);
|
|
109
|
+
const file = path.join(bindingsDir(root), `${binding.change}.json`);
|
|
110
|
+
atomicWrite(file, `${JSON.stringify(binding, null, 2)}\n`);
|
|
111
|
+
return `.prd/changes/${binding.change}.json`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Register (or rebind / replace) the selected PRD. Returns { binding, file,
|
|
115
|
+
// action: 'registered' | 'unchanged' | 'updated' | 'rebound' | 'replaced', notes }
|
|
116
|
+
// or { code, problem }.
|
|
117
|
+
function register(root, { prd, change, authorization = null, replace = false, rebind = false } = {}) {
|
|
118
|
+
const prdResult = parse.validatePrd(root, prd);
|
|
119
|
+
if (!prdResult.ok) return { code: 'INPUT_INVALID', problem: `${prdResult.file || prd}: ${prdResult.problems[0]}` };
|
|
120
|
+
const version = prd.match(parse.PRD_REF)[1];
|
|
121
|
+
const id = change || `prd-v${version}`;
|
|
122
|
+
if (!CHANGE_ID.test(id)) return { code: 'INPUT_INVALID', problem: `change ID must match [a-z0-9][a-z0-9-]{0,63}: ${id}` };
|
|
123
|
+
const base = head(root);
|
|
124
|
+
if (!base) return { code: 'UNSUPPORTED_INPUT', problem: 'registration needs a git repository with at least one commit (base = HEAD)' };
|
|
125
|
+
const revision = parse.prdDigest(prdResult.text);
|
|
126
|
+
const notes = [];
|
|
127
|
+
if (authorization === null) notes.push('no --authorization recorded; running registration does not prove human approval');
|
|
128
|
+
const files = listBindings(root);
|
|
129
|
+
if (files.length > 1) return { code: 'AMBIGUOUS', problem: `several change bindings under .prd/changes/ (${files.map(f => path.basename(f)).join(', ')}) — keep exactly one before registering` };
|
|
130
|
+
let existing = null;
|
|
131
|
+
if (files.length === 1) {
|
|
132
|
+
const read = readJson(path.join(root, files[0]));
|
|
133
|
+
const invalid = read.error || validateBinding(read.data);
|
|
134
|
+
if (invalid) return { code: 'MALFORMED', problem: `${files[0]}: ${invalid} — repair or remove it before registering` };
|
|
135
|
+
existing = { file: files[0], binding: read.data };
|
|
136
|
+
}
|
|
137
|
+
// v0.5.0 replaced the binding here (deleting the other PRD's); several changes
|
|
138
|
+
// are retained only by schema 2 records, so a second PRD needs the migration.
|
|
139
|
+
if (replace) return { code: 'MIGRATION_REQUIRED', problem: `--replace would delete ${existing ? existing.file : 'the binding'}; change records are retained instead — migrate first: node scripts/pincer-runtime.cjs migrate --preview --prd ${existing ? existing.binding.prd : prd}, then register ${prd} and select the change to work on with change select (retire one with change supersede or change cancel)` };
|
|
140
|
+
if (existing && (existing.binding.prd !== prd || existing.binding.change !== id)) {
|
|
141
|
+
return { code: 'MIGRATION_REQUIRED', problem: `${existing.file} binds ${existing.binding.prd} as change "${existing.binding.change}"; one binding per worktree in migrated mode — migrate to change records first: node scripts/pincer-runtime.cjs migrate --preview --prd ${existing.binding.prd}, then register ${prd}` };
|
|
142
|
+
}
|
|
143
|
+
if (existing) {
|
|
144
|
+
const current = existing.binding;
|
|
145
|
+
if (current.prd_revision === revision) {
|
|
146
|
+
if (authorization !== null && authorization !== current.authorization) {
|
|
147
|
+
const binding = { ...current, authorization };
|
|
148
|
+
return { binding, file: writeBinding(root, binding), action: 'updated', notes };
|
|
149
|
+
}
|
|
150
|
+
return { binding: current, file: existing.file, action: 'unchanged', notes };
|
|
151
|
+
}
|
|
152
|
+
if (!rebind) {
|
|
153
|
+
return { code: 'REVISION_CHANGED', problem: `${prd} content changed since registration (revision ${current.prd_revision.slice(0, 12)} → ${revision.slice(0, 12)}); pass --rebind to bind the new revision — readiness recorded for the old revision no longer applies` };
|
|
154
|
+
}
|
|
155
|
+
const binding = { ...current, prd_revision: revision, registered: nowIso(), authorization: authorization ?? current.authorization };
|
|
156
|
+
notes.push(`rebound to revision ${revision.slice(0, 12)}; attempts recorded for ${current.prd_revision.slice(0, 12)} no longer establish readiness`);
|
|
157
|
+
return { binding, file: writeBinding(root, binding), action: 'rebound', notes };
|
|
158
|
+
}
|
|
159
|
+
const binding = { schema: 1, change: id, prd, prd_revision: revision, base, registered: nowIso(), authorization, runtime: RUNTIME, legacy_receipts: {} };
|
|
160
|
+
return { binding, file: writeBinding(root, binding), action: 'registered', notes };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
module.exports = { CHANGE_ID, RUNTIME, BINDING_KEYS, IGNORE_LINE, listBindings, validateBinding, loadBinding, register, writeBinding, head, gitignoreHas, ensureIgnored };
|
|
@@ -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
|
+
};
|