@assuredloop/cli 0.0.1 → 0.2.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.
@@ -0,0 +1,383 @@
1
+ // The checks that read records (T10; design.md 3-7 and 9, schema.md 5; the
2
+ // architect's answers for #179, decision D15). Each finding is
3
+ // { severity, code, file, line, id, message }: `not ok`, `hint` or `info`.
4
+ // They compare the working tree with the base (the merge-base with main) and
5
+ // read the records of the open requests. No AI, no network.
6
+ import { createHash } from 'node:crypto';
7
+ import { parse as parseYaml } from 'yaml';
8
+ import { read } from './base.js';
9
+ import { judgeRequest } from './dispositions.js';
10
+ import { commitsOf, git } from './git.js';
11
+ import { diffParagraphs } from './ids.js';
12
+ import { crossResults } from './repos.js';
13
+ import { loadResults } from './results.js';
14
+ import {
15
+ hasBaselineEffect, isPromise, loadState, named, qualify, requestOf, resolve, servesSigned,
16
+ } from './state.js';
17
+ import { NEAR, closeness, diffWords, marks, requirementWords, sentencesOf, showHunk } from './words.js';
18
+ import { groupOf, sameBlocks } from './markers.js';
19
+
20
+ const DESIGN = ['component', 'interface', 'data', 'flow', 'choice'];
21
+ const TIER = /^[ \t]*Tier:[ \t]*(0|1d|1|2|3|S)\b[ \t]*(?:—|–|--?)?[ \t]*(.*)$/m;
22
+
23
+ // The PR's path claim: the Tier line of the newest commit in base..HEAD that
24
+ // has one (as v1 reads it); null when none has. `commits` is how many there are.
25
+ export function readClaim(top, base) {
26
+ if (!base) return { claim: null, commits: 0 };
27
+ const out = git(top, ['log', '--format=%B%x00', `${base}..HEAD`], { allowFail: true }) ?? '';
28
+ const messages = out.split('\0').map((m) => m.trim()).filter(Boolean);
29
+ for (const m of messages) {
30
+ const lines = [...m.matchAll(new RegExp(TIER.source, 'gm'))];
31
+ if (lines.length) {
32
+ const [line, tier, rest] = lines.at(-1);
33
+ const typo = /^typo in\s+(.+)$/i.exec(rest.trim());
34
+ const ids = typo ? typo[1].split(/\s*(?:,|\band\b)\s*/).map((s) => s.trim().replace(/^central:/, '').replace(/[.;]$/, '')).filter(Boolean) : [];
35
+ return { claim: { tier, rest: rest.trim(), line: line.trim(), typo: new Set(ids) }, commits: messages.length };
36
+ }
37
+ }
38
+ return { claim: null, commits: messages.length };
39
+ }
40
+
41
+ // Each paragraph's change from the base to the head, per scope, with both
42
+ // versions: { scope, request, id, name, head, base, changes, changed, kindChange }.
43
+ function changesOf(now, was) {
44
+ const out = [];
45
+ for (const scope of new Set([...now.scopes.keys(), ...(was?.scopes.keys() ?? [])])) {
46
+ const head = now.scopes.get(scope) ?? [];
47
+ const before = was?.scopes.get(scope) ?? [];
48
+ const h = new Map(head.map((p) => [p.id, p]));
49
+ const b = new Map(before.map((p) => [p.id, p]));
50
+ const request = requestOf(scope);
51
+ for (const c of diffParagraphs(before, head)) {
52
+ const hp = h.get(c.id) ?? null;
53
+ const bp = b.get(c.id) ?? null;
54
+ const changed = c.changes.some((x) => x !== 'Moved');
55
+ const kindChange = !!(hp && bp && hp.kind !== bp.kind);
56
+ if (!changed && !kindChange) continue;
57
+ out.push({ scope, request, id: c.id, name: named(request, c.id), head: hp, base: bp, changes: c.changes, changed, kindChange });
58
+ }
59
+ }
60
+ return out;
61
+ }
62
+
63
+ const changeWord = (c) => (c.changes.includes('New') ? 'new' : c.changes.includes('Removed') ? 'removed' : c.changed ? 'changed' : 'given another kind');
64
+
65
+ export function checks(top, { base, strict = false }) {
66
+ const now = loadState(top);
67
+ const was = base ? loadState(top, base) : null;
68
+ const findings = [];
69
+ const notKnown = [];
70
+ const at = (p) => (p ? `${p.file}:${p.line}` : '-');
71
+ const add = (severity, code, p, id, message) => findings.push({ severity, code, where: typeof p === 'string' ? p : at(p), id, message });
72
+ const promise = (k) => isPromise(now, k);
73
+ const open = [...now.requests.values()].filter((r) => r.open);
74
+ // Adoption on the branch (D16, D21): a spec paragraph that is new at the
75
+ // head is adopted text, not a change, when the adoption record names it with
76
+ // its current hash and the same text is a block of its file at the base, in
77
+ // its place (sameBlocks: one block covers one paragraph). An adoption record
78
+ // cannot cover new text.
79
+ const captured = adoptionEntries(now);
80
+ const notAdopted = new Set();
81
+ const atBase = new Map();
82
+ const adoptedHere = (c) => {
83
+ if (c.request || !c.head || c.base || !captured.get(c.id)?.has(c.head.sha256)) return false;
84
+ const file = c.head.file;
85
+ if (!atBase.has(file)) {
86
+ const text = was.get(file);
87
+ atBase.set(file, text == null ? new Set() : sameBlocks(text, (now.scopes.get(c.scope) ?? []).filter((p) => p.file === file), { file, living: new Set(now.spec.keys()) }));
88
+ }
89
+ return atBase.get(file).has(c.id);
90
+ };
91
+ const changes = [];
92
+ for (const c of was ? changesOf(now, was) : []) {
93
+ if (adoptedHere(c)) continue;
94
+ if (!c.request && c.head && !c.base && captured.has(c.id)) notAdopted.add(c.id);
95
+ changes.push(c);
96
+ }
97
+ const { claim, commits } = readClaim(top, base);
98
+ const typo = claim?.tier === '0' ? claim.typo : new Set();
99
+
100
+ // A promise change: a promise-kind paragraph new, changed or removed, or a
101
+ // kind change into or out of a promise kind.
102
+ const isPromiseChange = (c) => (c.changed && (promise(c.head?.kind) || promise(c.base?.kind)))
103
+ || (c.kindChange && (promise(c.head.kind) || promise(c.base.kind)));
104
+
105
+ // Sign-off coverage (design.md 6): a promise change with no signed requirement that covers it.
106
+ for (const c of changes.filter(isPromiseChange)) {
107
+ if (typo.has(c.name)) continue;
108
+ let covered;
109
+ if (!c.head) {
110
+ if (c.request) continue; // a change spec paragraph taken out is no promise change
111
+ const removers = open.flatMap((r) => [...r.paras.values()].filter((p) => p.links.removes.includes(c.id)).map((p) => [r, p]));
112
+ covered = removers.some(([r, p]) => servesSigned(now, p, r.name));
113
+ } else {
114
+ covered = servesSigned(now, c.head, c.request);
115
+ }
116
+ if (!covered) add('not ok', 'signoff-coverage', c.head ?? c.base, c.name, `a ${(c.head ?? c.base).kind} paragraph is ${changeWord(c)} with no signed requirement that covers it`);
117
+ }
118
+
119
+ // The path claim (design.md 7), and the typo claim's words (design.md 6).
120
+ if (!base) notKnown.push('no base, so the path claim was not read');
121
+ else if (!claim) {
122
+ if (commits) add('hint', 'no-claim', '-', '-', `no Tier line in ${base.slice(0, 7)}..HEAD; add "Tier: <n> — <claim>" to a commit message`);
123
+ notKnown.push(commits ? 'the path checks: no Tier line' : 'the path checks: no commit on this branch');
124
+ } else if (claim.tier === '0') {
125
+ for (const c of changes) {
126
+ if (typo.has(c.name)) continue;
127
+ if (isPromiseChange(c)) add('not ok', 'path-claim', c.head ?? c.base, c.name, `path 0, but a ${(c.head ?? c.base).kind} paragraph is ${changeWord(c)} with no typo claim`);
128
+ else if (!c.request && ['design', 'informative'].includes(groupOf((c.head ?? c.base).kind, now.kinds))) {
129
+ add('hint', 'path-claim', c.head ?? c.base, c.name, `path 0, but a ${(c.head ?? c.base).kind} paragraph is ${changeWord(c)}: path 1d or 2?`);
130
+ }
131
+ }
132
+ } else if (claim.tier === '1d') {
133
+ for (const c of changes) {
134
+ const p = c.head ?? c.base;
135
+ if (isPromiseChange(c)) add('not ok', 'path-claim', p, c.name, `path 1d, but a ${p.kind} paragraph is ${changeWord(c)}; 1d is for names and wording of design paragraphs`);
136
+ else if (DESIGN.includes(p.kind) && (!c.head || !c.base)) add('not ok', 'path-claim', p, c.name, `path 1d, but a ${p.kind} paragraph is ${changeWord(c)}; 1d adds or removes no design paragraph`);
137
+ }
138
+ }
139
+ const byName = new Map(changes.map((c) => [c.name, c]));
140
+ const shownWords = (name, why) => {
141
+ const c = byName.get(name);
142
+ if (!c?.head || !c.base || c.head.sha256 === c.base.sha256) {
143
+ add('info', 'typo-words', c?.head ?? '-', name, `${why}, but this branch does not change its text`);
144
+ return;
145
+ }
146
+ const hunks = diffWords(c.base.text, c.head.text);
147
+ add('info', 'typo-words', c.head, name, `${hunks.map(showHunk).join(' · ')}; now: "${sentencesOf(c.head.text, hunks)}"`);
148
+ const m = marks(hunks);
149
+ if (why === 'typo claim' && m.length) add('hint', 'typo-mark', c.head, name, `meaning-sensitive; review the typo claim (${m.join(', ')})`);
150
+ };
151
+ for (const id of typo) shownWords(id, 'typo claim');
152
+ if (claim?.tier === '1d') for (const c of changes) if (c.head && c.base && DESIGN.includes(c.head.kind) && c.changed) shownWords(c.name, '1d claim');
153
+
154
+ // A spec edit that makes an open change's incorporated disposition invalid.
155
+ for (const c of changes.filter((x) => !x.request && x.base && x.changed)) {
156
+ for (const r of open) {
157
+ for (const d of Array.isArray(r.data?.dispositions) ? r.data.dispositions : []) {
158
+ if (d?.disposition === 'incorporated' && d.spec === c.id && d.spec_sha256 === c.base.sha256) {
159
+ add('info', 'invalidates', c.head ?? c.base, c.id, `${d.source} incorporated this text; this edit makes that disposition invalid, so ${r.name} aligns`);
160
+ }
161
+ }
162
+ }
163
+ }
164
+
165
+ // Requirement words in a non-promise kind (design.md 3).
166
+ for (const c of changes) {
167
+ const p = c.head;
168
+ if (!p?.kind || promise(p.kind) || !c.changed) continue;
169
+ const w = requirementWords(p.text);
170
+ if (w.length) add('hint', 'promise-kind', p, c.name, `promise kind? a ${p.kind} paragraph holds ${w.join(', ')}`);
171
+ }
172
+
173
+ // Near matches (design.md 3): a change paragraph close to a spec paragraph it does not link.
174
+ for (const c of changes) {
175
+ const r = c.request && now.requests.get(c.request);
176
+ if (!r?.open || r.spike || !c.head || !hasBaselineEffect(now, c.head.kind)) continue;
177
+ const linked = new Set([...c.head.links.changes, ...c.head.links.buildsOn, ...c.head.links.removes].map((x) => x.replace(/^central:/, '')));
178
+ for (const d of Array.isArray(r.data?.dispositions) ? r.data.dispositions : []) if (d?.source === c.name && d.spec) linked.add(d.spec);
179
+ for (const s of now.spec.values()) {
180
+ if (linked.has(s.id) || !s.kind || s.kind === 'note') continue;
181
+ if (closeness(c.head.text, s.text) >= NEAR) add('hint', 'near-match', c.head, c.name, `does this change ${s.id}? its text is close, and it has no changes or builds-on link to it`);
182
+ }
183
+ }
184
+
185
+ // Overlaps (design.md 4): two open changes that both change one spec ID.
186
+ const changers = new Map();
187
+ for (const r of open) {
188
+ for (const p of r.paras.values()) {
189
+ for (const t of p.links.changes) {
190
+ const id = t.replace(/^central:/, '');
191
+ if (!changers.has(id)) changers.set(id, []);
192
+ changers.get(id).push({ r, p });
193
+ }
194
+ }
195
+ }
196
+ for (const [id, xs] of changers) {
197
+ if (new Set(xs.map((x) => x.r.name)).size < 2) continue;
198
+ for (const { r, p } of xs) {
199
+ const others = xs.filter((x) => x.r !== r).map((x) => `${x.r.name}/${x.p.id}`);
200
+ add('hint', 'overlap', p, `${r.name}/${p.id}`, `${others.join(', ')} also change${others.length === 1 ? 's' : ''} ${id}: two open changes change one paragraph; align them`);
201
+ }
202
+ }
203
+
204
+ // Links that do not resolve, and targets that a change removed (design.md 3).
205
+ // The request that removed an ID is looked up only for a link that does not
206
+ // resolve, and only a record whose text names the ID is parsed for it; the
207
+ // last request in order wins.
208
+ const removers = new Map();
209
+ const removerOf = (id) => {
210
+ if (removers.has(id)) return removers.get(id);
211
+ let by = null;
212
+ for (const r of now.requests.values()) {
213
+ for (const p of r.paras.values()) if (p.links.removes.includes(id)) by = r.name;
214
+ if (!r.mentions(id)) continue;
215
+ for (const d of Array.isArray(r.data?.dispositions) ? r.data.dispositions : []) if (d?.disposition === 'removed' && d.spec === id) by = r.name;
216
+ }
217
+ removers.set(id, by);
218
+ return by;
219
+ };
220
+ const holders = [
221
+ ...now.spec.values(),
222
+ ...open.flatMap((r) => [...r.paras.values()].map((p) => ({ p, r }))),
223
+ ...[...now.adrs.values()].filter((a) => a.head && a.status !== 'superseded').map((a) => ({ p: a.head, adr: a })),
224
+ ].map((x) => (x.p ? x : { p: x }));
225
+ for (const { p, r, adr } of holders) {
226
+ const own = r ? new Set(r.paras.keys()) : new Set();
227
+ for (const [word, refs] of Object.entries(p.links)) {
228
+ if (word === 'for') continue; // a task reference; tasks are optional
229
+ for (const ref of refs) {
230
+ const t = qualify(ref, r?.name ?? null, own);
231
+ if (t.cross || resolve(now, t)) continue;
232
+ const who = named(r?.name ?? null, p.id);
233
+ if (word === 'removes' && !t.req && !now.spec.has(t.id) && r) continue; // its own removal, done
234
+ if (!t.req && removerOf(t.id)) add('hint', 'target-removed', p, who, `${ref}: target removed by ${removerOf(t.id)}; align this link`);
235
+ else add('not ok', 'unresolved-link', p, who, `${ref}: no such ID${adr ? ` (in ${adr.id})` : ''}`);
236
+ }
237
+ }
238
+ }
239
+
240
+ // Bindings whose target changed since they were bound (design.md 4, 6).
241
+ for (const r of open) {
242
+ for (const b of Array.isArray(r.data?.bindings) ? r.data.bindings : []) {
243
+ if (!b?.target || !b.holder || !b.target_sha256) continue;
244
+ const t = qualify(String(b.target), null);
245
+ if (t.cross) continue;
246
+ const cur = resolve(now, t);
247
+ if (!cur || !cur.sha || cur.sha === b.target_sha256) continue;
248
+ // The holder's own change made the target what it is now: its disposition names that text.
249
+ if ((Array.isArray(r.data?.dispositions) ? r.data.dispositions : []).some((d) => d?.source === b.holder && d.spec === b.target && d.spec_sha256 === cur.sha)) continue;
250
+ // A superseded ADR's links are history (schema.md 5).
251
+ if (now.adrs.get(String(b.holder))?.status === 'superseded') continue;
252
+ const hq = qualify(String(b.holder), null);
253
+ const hp = hq.req ? now.requests.get(hq.req)?.paras.get(hq.id) : now.spec.get(hq.id);
254
+ const msg = /^[RQ]\d+$/.test(t.id ?? '')
255
+ ? `${b.target} changed after this paragraph was linked to it. Check that it still ${b.link} ${t.id}, then al index --align ${b.holder}`
256
+ : `${b.link} ${b.target}, which changed since it was bound. Align it: check it, then al index --align ${b.holder}`;
257
+ add('hint', 'stale-base', hp ?? '-', b.holder, msg);
258
+ }
259
+ }
260
+
261
+ // AI hints made from another version of their paragraph (design.md 10).
262
+ const docs = [...new Set([...now.spec.values(), ...open.flatMap((r) => [...r.paras.values()])].map((p) => p.file))];
263
+ for (const file of docs) {
264
+ let rec;
265
+ try {
266
+ rec = parseYaml(read(top, `.assuredloop/records/${file}.yaml`) ?? 'null');
267
+ } catch {
268
+ continue;
269
+ }
270
+ const paras = new Map([...now.scopes.values()].flat().filter((p) => p.file === file).map((p) => [p.id, p]));
271
+ for (const e of Array.isArray(rec?.paragraphs) ? rec.paragraphs : []) {
272
+ const p = paras.get(e?.id);
273
+ if (!p || !e.hint?.basis_sha256 || e.hint.basis_sha256 === p.sha256) continue;
274
+ const r = requestOfFile(file);
275
+ add('hint', 'stale-ai-hint', p, named(r, p.id), 'its AI hint was made from another version of the text; refresh it (a hint is stale even when its quote still matches)');
276
+ }
277
+ }
278
+
279
+ // Dispositions of the open requests (design.md 5).
280
+ const baseSpec = was?.spec ?? null;
281
+ for (const r of open) {
282
+ const judged = judgeRequest(now, r, { typo, baseSpec });
283
+ for (const j of judged) {
284
+ if (j.status === 'pending') continue;
285
+ if (!j.valid) add(strict ? 'not ok' : 'hint', 'disposition', j.p, j.id, `${j.status} is not valid: ${j.reason}`);
286
+ for (const n of j.notes) add(n.startsWith('near-match') ? 'hint' : 'info', n.startsWith('near-match') ? 'near-match' : 'disposition', j.p, j.id, n.replace(/^near-match /, ''));
287
+ }
288
+ const pending = judged.filter((j) => j.status === 'pending').map((j) => j.p.id);
289
+ if (pending.length) add('info', 'disposition-pending', r.specFile, r.name, `${pending.length} of ${judged.length} paragraphs with a baseline effect have no disposition for their current version: ${pending.join(', ')}`);
290
+ }
291
+
292
+ // ADRs (schema.md 5).
293
+ for (const [id, a] of was?.adrs ?? []) {
294
+ if (!['accepted', 'superseded'].includes(a.status)) continue;
295
+ const h = now.adrs.get(id);
296
+ if (!h) add('not ok', 'adr-changed', a.file, id, `${id} was ${a.status} and its file is gone; an ADR is superseded, never edited or deleted`);
297
+ else if (h.body !== a.body) add('not ok', 'adr-changed', h.head ?? h.file, id, `the text of ${id} changed after it was accepted; write a new ADR that supersedes it`);
298
+ }
299
+ for (const a of now.adrs.values()) {
300
+ for (const old of a.head?.links.supersedes ?? []) {
301
+ const o = now.adrs.get(old);
302
+ if (o && o.status !== 'superseded') add('hint', 'adr-supersede', a.head, a.id, `${a.id} supersedes ${old}, whose status is ${o.status ?? 'missing'}; set it to superseded`);
303
+ }
304
+ }
305
+ for (const c of changes.filter((x) => !x.request)) {
306
+ const p = c.head ?? c.base;
307
+ const govern = [...now.adrs.values()].filter((a) => a.status === 'accepted'
308
+ && ((a.head?.links.decides ?? []).includes(c.id) || p.links.governedBy.includes(a.id)));
309
+ for (const a of govern) add('hint', 'adr-governs', p, c.id, `check that ${a.id} still holds`);
310
+ }
311
+
312
+ // Results (design.md 9): whether each one applies to the current text. The
313
+ // commits they ran at are looked up in one batch.
314
+ const results = loadResults(top);
315
+ const ran = commitsOf(top, results.filter((res) => !res.problems.length && res.inputs.length && res.commit !== 'unknown').map((res) => res.commit));
316
+ for (const res of results) {
317
+ if (res.problems.length) {
318
+ add('hint', 'result', res.file, res.check ?? '-', `not a valid result: ${res.problems.join('; ')}`);
319
+ continue;
320
+ }
321
+ add('info', 'result', res.file, res.check, `${res.outcome} at ${res.commit === 'unknown' ? 'an unknown commit' : res.commit.slice(0, 7)}: ${applicability(top, res, ran)}`);
322
+ }
323
+
324
+ // Output repos (T12, design.md 12): their results, and each repo that cannot be read.
325
+ const cross = crossResults(top);
326
+ for (const repo of cross.repos) if (repo.unknown) add('info', 'output-repo', repo.name, '-', `unknown: ${repo.unknown}`);
327
+ for (const res of cross.results) {
328
+ add('info', 'result', `${res.repo}/${res.file}`, res.check ?? '-', `${res.outcome ?? 'no outcome'} at ${res.repo}@${String(res.resolved ?? res.commit ?? 'unknown').slice(0, 7)}: ${res.applies}`);
329
+ }
330
+
331
+ for (const id of notAdopted) {
332
+ const p = now.spec.get(id);
333
+ add('hint', 'adoption', p, id, `the adoption record names ${id}, but its text is not a paragraph of ${p.file} at the base, so it reads as a change`);
334
+ }
335
+
336
+ // Adopted paragraphs (D16): a disposition with `source: adoption` names the
337
+ // spec paragraph and its captured hash; it stays adopted while its text is that.
338
+ // Only a record whose text holds `source: adoption` is parsed for it.
339
+ const adopted = new Set();
340
+ for (const [id, hashes] of captured) if (!notAdopted.has(id) && now.spec.has(id) && hashes.has(now.spec.get(id).sha256)) adopted.add(id);
341
+
342
+ return { findings, notKnown, adopted };
343
+ }
344
+
345
+ // Whether a result applies (design.md 9): never "applies", only what is known.
346
+ // `ran` holds the commits already looked up (commitsOf); with none, it looks.
347
+ export function applicability(top, res, ran = null) {
348
+ if (!res.inputs.length) return 'applicability unknown: no declared inputs';
349
+ if (res.commit === 'unknown') return 'applicability unknown: the commit is unknown';
350
+ const commit = ran?.has(res.commit) ? ran.get(res.commit) : commitsOf(top, [res.commit]).get(res.commit);
351
+ if (!commit) return `applicability unknown: commit ${res.commit} is not in this clone`;
352
+ const changed = [];
353
+ for (const x of res.inputs) {
354
+ let bytes;
355
+ try {
356
+ bytes = read(top, String(x.file), null);
357
+ } catch {
358
+ bytes = null;
359
+ }
360
+ if (bytes === null) return `applicability unknown: the declared input ${x.file} is not there`;
361
+ if (createHash('sha256').update(bytes).digest('hex') !== String(x.sha256)) changed.push(x.file);
362
+ }
363
+ if (changed.length) return `does not apply to the current text: ${changed.join(', ')} changed since ${res.commit.slice(0, 7)}`;
364
+ return `declared inputs unchanged since ${res.commit.slice(0, 7)} (that they are complete is a claim)`;
365
+ }
366
+
367
+ // The spec IDs that a disposition with `source: adoption` names, each with
368
+ // the text hashes it was captured with.
369
+ function adoptionEntries(state) {
370
+ const named = new Map();
371
+ for (const r of state.requests.values()) {
372
+ if (!r.mayAdopt) continue;
373
+ for (const d of Array.isArray(r.data?.dispositions) ? r.data.dispositions : []) {
374
+ if (d?.source !== 'adoption' || !d.spec) continue;
375
+ if (!named.has(d.spec)) named.set(d.spec, new Set());
376
+ named.get(d.spec).add(d.spec_sha256);
377
+ }
378
+ }
379
+ return named;
380
+ }
381
+
382
+ const REQUEST_FILE = /^requests\/(?:archive\/)?([^/]+)\/spec\.md$/;
383
+ const requestOfFile = (file) => REQUEST_FILE.exec(file)?.[1] ?? null;
@@ -0,0 +1,168 @@
1
+ // al new and al record origin|signoff|decision, v4: they write the request's
2
+ // text and snapshots as before, and the request record beside them.
3
+ import { readFileSync } from 'node:fs';
4
+ import { resolve } from 'node:path';
5
+ import { stringify } from 'yaml';
6
+ import { Fail, SCHEMA, STAMP, day, exists, freeName, guard, isName, now, read, recordPath, sha256, stamp, write } from './base.js';
7
+ import { formatSnapshot, slug } from './snapshot.js';
8
+ import { addDecision, decisionNumbers, requirements, setSignedOff, signedText } from './request-md.js';
9
+ import { YAML_OPTIONS, addVersions, append, highest, latest, openRecord, recordText } from './records.js';
10
+
11
+ const TIERS = ['1', '1d', '2', '3', 'S'];
12
+
13
+ function readInput(from, cwd) {
14
+ if (from === undefined) throw new Fail('--from <file|-> is missing', 'pass the text with --from <file>, or --from - on standard input');
15
+ try {
16
+ return from === '-' ? readFileSync(0) : readFileSync(resolve(cwd, from));
17
+ } catch (e) {
18
+ throw new Fail(`cannot read ${from}: ${e.code || e.message}`);
19
+ }
20
+ }
21
+
22
+ // al new <name> --from <file|-> [--title <t>] [--tier 1|1d|2|3|S]
23
+ export function newRequest({ top, cwd, args, opts }) {
24
+ const [name] = args;
25
+ oneLine(opts, ['title']);
26
+ if (!isName(name)) throw new Fail(`bad request name ${JSON.stringify(name ?? '')}: use lowercase letters, digits and hyphens`, 'al new <name> --from <file|->');
27
+ if (opts.tier === '0') throw new Fail('tier 0 has no record: a fix says so in its PR', 'al new <name> --tier 1 (or higher) when the work changes a promise');
28
+ if (opts.tier !== undefined && !TIERS.includes(opts.tier)) throw new Fail(`bad tier ${opts.tier}: one of ${TIERS.join(', ')}`);
29
+ const used = [`requests/${name}`, `requests/archive/${name}`, recordPath(name)].find((p) => exists(top, p));
30
+ if (used) throw new Fail(`the name ${name} is already used (${used}); a request name is never reused`, 'al new <another name> --from <file|->');
31
+ const words = readInput(opts.from, cwd);
32
+ if (words.length === 0) throw new Fail('no owner\'s words given', 'pass the words with --from <file> or on standard input');
33
+
34
+ const when = now();
35
+ const file = `${day(when)}-owner-words.md`;
36
+ const dir = `requests/${name}`;
37
+ guard(top, [`${dir}/origin/${file}`, `${dir}/request.md`, recordPath(name)]);
38
+ write(top, `${dir}/origin/${file}`, formatSnapshot({ source: opts.from === '-' ? 'standard input' : opts.from, fetched: stamp(when), text: words }));
39
+ write(top, `${dir}/request.md`, [
40
+ `# ${opts.title ?? name}`,
41
+ opts.tier === undefined ? 'Status: open' : `Tier: ${opts.tier} · Status: open`,
42
+ '',
43
+ '## Owner\'s words and dialog',
44
+ '',
45
+ `- ${day(when)} the owner's words, snapshot origin/${file}`,
46
+ '',
47
+ ].join('\n'));
48
+ const record = {
49
+ schema: SCHEMA,
50
+ request: name,
51
+ ...(opts.tier === undefined ? {} : { tier: opts.tier }),
52
+ status: 'open',
53
+ sources: [{ file, kind: 'owner-words', sha256: sha256(words), taken: stamp(when) }],
54
+ requirements: [], signoff: [], decisions: [], tasks: [], bindings: [], dispositions: [],
55
+ };
56
+ write(top, recordPath(name), stringify(record, YAML_OPTIONS));
57
+ return {
58
+ body: [`Wrote ${dir}/request.md, ${dir}/origin/${file} and ${recordPath(name)}`],
59
+ next: `write the organized requirement in ${dir}/request.md, then al index`,
60
+ };
61
+ }
62
+
63
+ // The folder and record of an open request that the tool may write.
64
+ function openRequest(top, name) {
65
+ if (!isName(name)) throw new Fail(`${JSON.stringify(name ?? '')} is not a request name`);
66
+ if (!exists(top, `requests/${name}/request.md`)) {
67
+ if (exists(top, `requests/archive/${name}`)) throw new Fail(`${name} is archived, and an archived request is not edited`, 'start a new request: al new <name> --from <file|->');
68
+ throw new Fail(`no request named ${name}`, 'al new <name> --from <file|->');
69
+ }
70
+ const rec = openRecord(top, name);
71
+ if (!rec) throw new Fail(`${name} has no record ${recordPath(name)}`, 'a v4 request is made with al new');
72
+ return { dir: `requests/${name}`, rec };
73
+ }
74
+
75
+ const oneLine = (opts, keys) => {
76
+ const bad = keys.find((k) => /[\r\n]/.test(opts[k] ?? ''));
77
+ if (bad) throw new Fail(`--${bad} holds a line break; it takes one line`);
78
+ };
79
+
80
+ // al record <name> origin|signoff|decision ...
81
+ export function record(ctx) {
82
+ const [, kind] = ctx.args;
83
+ if (kind === 'origin') return recordOrigin(ctx);
84
+ if (kind === 'signoff') return recordSignoff(ctx);
85
+ if (kind === 'decision') return recordDecision(ctx);
86
+ throw new Fail(`record ${kind ?? ''}: al record <name> origin|signoff|decision`);
87
+ }
88
+
89
+ // al record <name> origin --url <source> --from <file|-> [--fetched <stamp>] [--yes]
90
+ function recordOrigin({ top, cwd, args, opts }) {
91
+ const [name] = args;
92
+ oneLine(opts, ['url']);
93
+ if (!opts.url) throw new Fail('--url <source> is missing', `al record ${name} origin --url <source> --from -`);
94
+ if (opts.fetched !== undefined && !STAMP.test(opts.fetched)) throw new Fail(`--fetched ${opts.fetched}: write it as YYYY-MM-DDTHH:MMZ`);
95
+ const { dir, rec } = openRequest(top, name);
96
+ const text = readInput(opts.from, cwd);
97
+ if (text.length === 0) throw new Fail('the text is empty', 'pass the text with --from <file> or on standard input');
98
+ const fetched = opts.fetched ?? stamp(now());
99
+ const file = freeName(top, `${dir}/origin`, `${fetched.slice(0, 10)}-${slug(opts.url)}`);
100
+ const snapshot = formatSnapshot({ source: opts.url, fetched, text });
101
+ const shown = snapshot.toString('utf8').replace(/\n$/, '').split('\n');
102
+ if (!opts.yes) return { body: [`Would write ${dir}/origin/${file}:`, ...shown], next: 'run the same command with --yes to write it' };
103
+ guard(top, [`${dir}/origin/${file}`, rec.path]);
104
+ append(rec, 'sources', { file, kind: 'owner-words', sha256: sha256(text), taken: fetched, url: opts.url });
105
+ write(top, `${dir}/origin/${file}`, snapshot);
106
+ write(top, rec.path, recordText(rec));
107
+ return { body: [`Wrote ${dir}/origin/${file} and source ${file} in ${rec.path}`], next: `name it in a requirement's marker: <!-- R<n> from:${file} -->` };
108
+ }
109
+
110
+ // al record <name> signoff --source <where> [--words <quote>] [--presented <x>] [--transcribed-by <who>] [--yes]
111
+ function recordSignoff({ top, args, opts }) {
112
+ const [name] = args;
113
+ oneLine(opts, ['source', 'words', 'presented', 'transcribed-by']);
114
+ if (!opts.source) throw new Fail('--source <where the owner signed> is missing', `al record ${name} signoff --source <where> --words <quote>`);
115
+ const { dir, rec } = openRequest(top, name);
116
+ const md = read(top, `${dir}/request.md`);
117
+ const text = signedText(md);
118
+ if (text === null) throw new Fail(`${dir}/request.md has no "## Organized requirement" (or question) to sign`, `write the organized requirement in ${dir}/request.md`);
119
+ const reqs = requirements(md);
120
+ const signoffs = rec.data.signoff ?? [];
121
+ const last = signoffs.at(-1);
122
+ // Each requirement's version now: its latest, when the text is that version's; else a new one to come.
123
+ const version = (r) => { const v = latest(rec.data, r.id); return v?.sha256 === r.sha256 ? v.version : 'new'; };
124
+ const covered = (last?.covers ?? []).map((c) => `${c.id} ${c.version} ${c.sha256}`).sort().join('\n');
125
+ if (last && last.sha256 === sha256(text) && covered === reqs.map((r) => `${r.id} ${version(r)} ${r.sha256}`).sort().join('\n')) {
126
+ return { body: [`nothing to sign: unchanged since ${last.id} (${last.file})`], next: `al index` };
127
+ }
128
+ const when = now();
129
+ const file = freeName(top, `${dir}/origin`, `${day(when)}-signoff`);
130
+ const body = [last ? `To be signed (last signed: ${last.id}):` : 'To be signed (first sign-off):', ...text.replace(/\n$/, '').split('\n')];
131
+ if (!opts.yes) return { body: [...body, `Would write ${dir}/origin/${file}, ${rec.path} and the Signed off line`], next: 'show this to the owner; on their OK, run the same command with --yes' };
132
+
133
+ guard(top, [`${dir}/origin/${file}`, rec.path, `${dir}/request.md`]);
134
+ addVersions(rec, reqs);
135
+ const data = rec.data;
136
+ const id = `S${highest(rec, 'signoff', 'S') + 1}`;
137
+ const entry = { id, file, sha256: sha256(text), signed: stamp(when), source: opts.source };
138
+ if (opts.words !== undefined) entry.words = opts.words;
139
+ if (opts.presented !== undefined) entry.presented = opts.presented;
140
+ if (opts['transcribed-by'] !== undefined) entry.transcribed_by = opts['transcribed-by'];
141
+ entry.covers = reqs.map((r) => ({ id: r.id, version: latest(data, r.id).version, sha256: r.sha256 }));
142
+ const node = append(rec, 'signoff', entry);
143
+ for (const c of node.get('covers').items) c.flow = true;
144
+ write(top, `${dir}/origin/${file}`, formatSnapshot({ source: opts.source, words: opts.words, fetched: stamp(when), text, signed: true }));
145
+ write(top, rec.path, recordText(rec));
146
+ write(top, `${dir}/request.md`, setSignedOff(md, `Signed off: ${day(when)} owner, origin/${file}`));
147
+ return { body: [...body, `Wrote ${dir}/origin/${file}, ${id} in ${rec.path} and the Signed off line in ${dir}/request.md`], next: 'al index' };
148
+ }
149
+
150
+ // al record <name> decision --source <who> --text <decision> [--clarifies R2,R3] [--yes]
151
+ function recordDecision({ top, args, opts }) {
152
+ const [name] = args;
153
+ if (opts.source === undefined || opts.text === undefined) throw new Fail('record decision needs --source and --text', `al record ${name} decision --source <who> --text <decision> --yes`);
154
+ oneLine(opts, ['source', 'text', 'clarifies']);
155
+ const { dir, rec } = openRequest(top, name);
156
+ const md = read(top, `${dir}/request.md`);
157
+ const n = Math.max(highest(rec, 'decisions', 'D'), ...decisionNumbers(md)) + 1;
158
+ const date = day(now());
159
+ const line = `- D${n}, ${date}. Source: ${opts.source}. ${opts.text}`;
160
+ if (!opts.yes) return { body: [`Would add to ${dir}/request.md, ## Decisions, and to ${rec.path}:`, line], next: 'run the same command with --yes to write it' };
161
+ guard(top, [`${dir}/request.md`, rec.path]);
162
+ const entry = { id: `D${n}`, date, source: opts.source, text: opts.text, sha256: sha256(opts.text) };
163
+ if (opts.clarifies) entry.clarifies = opts.clarifies.split(',').map((s) => s.trim()).filter(Boolean);
164
+ append(rec, 'decisions', entry);
165
+ write(top, `${dir}/request.md`, addDecision(md, line));
166
+ write(top, rec.path, recordText(rec));
167
+ return { body: [`Added D${n} to ${dir}/request.md and ${rec.path}:`, line], next: 'al index' };
168
+ }