@assuredloop/cli 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +260 -2
- package/bin/al.js +111 -0
- package/package.json +32 -4
- package/skills/assuredloop/SKILL.md +48 -0
- package/src/adrs.js +110 -0
- package/src/audit.js +176 -0
- package/src/check.js +127 -0
- package/src/commands.js +456 -0
- package/src/conclude.js +293 -0
- package/src/consolidate.js +279 -0
- package/src/git.js +155 -0
- package/src/hints.js +433 -0
- package/src/links.js +181 -0
- package/src/record-section.js +126 -0
- package/src/sections.js +125 -0
- package/src/signoff.js +166 -0
- package/src/snapshot.js +63 -0
- package/src/spec.js +228 -0
- package/src/states.js +227 -0
- package/src/tests.js +267 -0
- package/src/tree.js +113 -0
- package/src/views.js +390 -0
package/src/conclude.js
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
// al conclude <name> [--dropped Dn] [--yes] ([STA-7], [STA-6], [REC-9]): it
|
|
2
|
+
// refuses unless the sign-off is current and every held section is
|
|
3
|
+
// consolidated, carried, dropped while retaining nothing ([STA-3]), or kept;
|
|
4
|
+
// then it writes the Outcome, sets the Status and moves the folder to
|
|
5
|
+
// requests/archive/. It prints three lines or fewer, plus the frame.
|
|
6
|
+
import { existsSync, mkdirSync, renameSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { Fail, HISTORY, cutOff, gapFail, git, historyGap, mainCommit, mainName, ownCommits } from './git.js';
|
|
9
|
+
import { openTree, findRequest, noSymlinkOn } from './tree.js';
|
|
10
|
+
import { rootOf, rootLine, baseline } from './spec.js';
|
|
11
|
+
import { CONSOLIDATED, allBlocks, blockFault, stateText, statesOf } from './states.js';
|
|
12
|
+
import { organized, parts, signoffState, signoffStep, statusAt, statusField, tierIs } from './signoff.js';
|
|
13
|
+
import { requestToWrite, decisionList, childrenOf } from './commands.js';
|
|
14
|
+
import { decisions } from './record-section.js';
|
|
15
|
+
import { liveCode } from './views.js';
|
|
16
|
+
import { filesOf, idsOn, mayTouch, ownFiles, paths, requestOf, requestsIn, sectionsChanged } from './links.js';
|
|
17
|
+
import { TITLES, headingAt, prose, sameSection, sectionsById } from './sections.js';
|
|
18
|
+
import { adrsOf } from './adrs.js';
|
|
19
|
+
|
|
20
|
+
const NOT_KNOWN = ['whether the code does what the spec says, and whether review agreed (conclude reads only the records)'];
|
|
21
|
+
|
|
22
|
+
// Each block's fate: held, kept or dropped, or why conclude refuses it.
|
|
23
|
+
function fateOf(e, b, { name, dropped, blocks, signedR, ownerDecisions }) {
|
|
24
|
+
if (b.kept) {
|
|
25
|
+
if (!CONSOLIDATED.includes(e.state)) return { bad: `${e.block} is Kept but reads ${e.state}; align it before it is kept` };
|
|
26
|
+
// Its own signed R, or, when successors carry it, any one of theirs (design §5.4).
|
|
27
|
+
const traced = signedR(b) || (e.state === 'carried' && e.carriers.some((k) => signedR(blocks.get(k)))) || ownerDecisions.has(b.kept[1]);
|
|
28
|
+
return traced ? { fate: 'kept' } : { bad: `${e.block} is Kept, but traces to no signed requirement and no owner decision ([STA-6])` };
|
|
29
|
+
}
|
|
30
|
+
if (dropped || b.dropped) {
|
|
31
|
+
return e.retainsNothing ? { fate: 'dropped' }
|
|
32
|
+
: { bad: `${e.block} is dropped but possibly retained (${e.state}): al consolidate ${name} --revert ${e.id}, or keep it` };
|
|
33
|
+
}
|
|
34
|
+
return CONSOLIDATED.includes(e.state) ? { fate: 'held' }
|
|
35
|
+
: { bad: `${e.block} ${stateText(e)}` };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Each section's kind in the Outcome (by its blocks' net op, or Kept or
|
|
39
|
+
// Dropped), and each R's sections ([REC-9]).
|
|
40
|
+
export function outcomeFacts(org, fates) {
|
|
41
|
+
const ids = new Map();
|
|
42
|
+
for (const f of fates) ids.set(f.b.id, [...(ids.get(f.b.id) ?? []), f]);
|
|
43
|
+
const kind = (fs) => {
|
|
44
|
+
const byN = [...fs].sort((x, y) => x.b.n - y.b.n);
|
|
45
|
+
if (fs.some((f) => f.fate === 'kept')) return 'Kept';
|
|
46
|
+
if (fs.some((f) => f.fate === 'dropped')) return 'Dropped';
|
|
47
|
+
return byN[0].b.op === 'add' ? 'Added' : byN.at(-1).b.op === 'remove' ? 'Removed' : 'Modified';
|
|
48
|
+
};
|
|
49
|
+
const kinds = new Map([...ids].map(([id, fs]) => [id, kind(fs)]));
|
|
50
|
+
// A tier-1 record names the sections it amends with `Amends: [ID]`: under
|
|
51
|
+
// R<n>, for R<n>; anywhere else in the organized section, for every R-line.
|
|
52
|
+
const all = org ? parts(org.text, org.oneLine) : [];
|
|
53
|
+
const isR = (x) => /^R\d+$/.test(x.key);
|
|
54
|
+
const level = (x) => x.text.match(/^\s*(#+)\s/)?.[1].length ?? 0;
|
|
55
|
+
// A part under a deeper heading inside R<n> stays in R<n>'s scope.
|
|
56
|
+
let r = null;
|
|
57
|
+
const scope = all.map((x) => (r = isR(x) ? x : r && level(x) > level(r) ? r : null)?.key ?? null);
|
|
58
|
+
const under = (key) => all.filter((x, i) => scope[i] === key).flatMap((x) => amends(x.text));
|
|
59
|
+
const everyR = under(null);
|
|
60
|
+
const rs = all.filter(isR).map((p) => ({
|
|
61
|
+
key: p.key,
|
|
62
|
+
title: p.text.split('\n')[0].replace(/^\s*#+\s+R\d+\s*/, '').replace(/^R\d+:\s*/, '').replace(/\s*\bAmends:.*$/, '').trim(),
|
|
63
|
+
ids: [...new Set([...[...ids].filter(([id, fs]) => kinds.get(id) !== 'Dropped' && fs.some((f) => f.b.forR.includes(p.key))).map(([id]) => `[${id}]`),
|
|
64
|
+
...under(p.key), ...everyR])],
|
|
65
|
+
}));
|
|
66
|
+
return { kinds, rs };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// The IDs an `Amends:` names: the bracketed ones after it, on its line, out
|
|
70
|
+
// of fenced code.
|
|
71
|
+
export const amends = (text) => [...prose(text).matchAll(/\bAmends:([^\n]*)/g)]
|
|
72
|
+
.flatMap((m) => idsOn(m[1]).map((id) => `[${id}]`));
|
|
73
|
+
|
|
74
|
+
// A tier-1 record: request folder `dir` in `tree` has no change.md, so its
|
|
75
|
+
// sections come from the baseline itself ([REC-9]).
|
|
76
|
+
export const noChangeMd = (tree, dir) => tree.read(`${dir}/change.md`) === null;
|
|
77
|
+
|
|
78
|
+
// A request with no change.md ([REC-9]): the sections its branch added,
|
|
79
|
+
// modified and removed, from `was` (the baseline at its fork) to `now`, both
|
|
80
|
+
// maps of ID to text; and, against the IDs its Amends: names, the changed ones
|
|
81
|
+
// it doesn't name and the named ones that didn't change.
|
|
82
|
+
export function baselineLists(was, now, org, own) {
|
|
83
|
+
const named = org ? amends(org.text).map((x) => x.slice(1, -1)) : [];
|
|
84
|
+
const changed = [...new Set([...was.keys(), ...now.keys()])].filter((id) => !(was.has(id) && now.has(id) && sameSection(was.get(id), now.get(id))));
|
|
85
|
+
const ids = changed.filter((id) => own.has(id));
|
|
86
|
+
return {
|
|
87
|
+
Added: ids.filter((id) => !was.has(id)), Modified: ids.filter((id) => was.has(id) && now.has(id)), Removed: ids.filter((id) => !now.has(id)),
|
|
88
|
+
unnamed: ids.filter((id) => !named.includes(id)),
|
|
89
|
+
// "Did not change" is said of the section itself, whoever changed it.
|
|
90
|
+
unchanged: [...new Set(named)].filter((id) => !changed.includes(id)),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// A spike ([REC-10]) has findings.md, starting with its Answer, and changes no
|
|
95
|
+
// spec: what request `name` at `dir` in `tree` misses of that, as note texts,
|
|
96
|
+
// given `edited()`, the baseline IDs its own work changed. Notes only:
|
|
97
|
+
// conclude still concludes.
|
|
98
|
+
export function spikeNotes(tree, name, dir, md, edited) {
|
|
99
|
+
if (!tierIs(statusField(md, 'Tier'), 'S')) return [];
|
|
100
|
+
const findings = tree.read(`${dir}/findings.md`)?.toString('utf8');
|
|
101
|
+
const first = findings?.split('\n').find((l) => l.trim()) ?? '';
|
|
102
|
+
const out = [];
|
|
103
|
+
if (findings === undefined) out.push(`${name} is a spike with no findings.md ([REC-10])`);
|
|
104
|
+
else if (!/^\s*(#+\s*)?Answer\b/.test(first)) out.push(`${name}'s findings.md does not start with its Answer ([REC-10])`);
|
|
105
|
+
const ids = edited();
|
|
106
|
+
if (ids.length) out.push(`${name} is a spike, but it edits the baseline: ${ids.map((id) => `[${id}]`).join(', ')}; a spike changes no spec ([REC-10])`);
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// The baseline IDs request `name`'s own work changed ([LNK-2]): in the commits
|
|
111
|
+
// fork..tip that map to it, merges left out, and, when `working`, in the
|
|
112
|
+
// working tree's changes not yet committed.
|
|
113
|
+
export function ownIds(top, name, fork, root, requests, working, tip = 'HEAD') {
|
|
114
|
+
const ids = new Set();
|
|
115
|
+
const seen = new Map();
|
|
116
|
+
for (const sha of fork ? ownCommits(top, fork, tip) : []) {
|
|
117
|
+
if (!requestOf(top, sha, requests, seen, ownFiles).names.includes(name)) continue;
|
|
118
|
+
// A merge's own: the sections that differ from every parent. Past the
|
|
119
|
+
// shallow boundary, the fork stands in for the parent it lacks ([VW-9]).
|
|
120
|
+
const parents = ownFiles(top, sha) ? git(top, ['rev-list', '--parents', '-n', '1', sha]).split(' ').slice(1) : [fork];
|
|
121
|
+
const each = (parents.length ? parents : [`${sha}^`]).map((p) => new Set(sectionsChanged(top, p, sha, root, mayTouch(top, sha, fork))));
|
|
122
|
+
each[0].forEach((id) => each.every((s) => s.has(id)) && ids.add(id));
|
|
123
|
+
}
|
|
124
|
+
if (working) {
|
|
125
|
+
const files = [...paths(git(top, ['diff', '--name-only', '-z', 'HEAD'], { allowFail: true, worktree: true }) ?? ''), ...paths(git(top, ['ls-files', '--others', '--exclude-standard', '-z'], { worktree: true }))];
|
|
126
|
+
sectionsChanged(top, 'HEAD', null, root, files, openTree(top)).forEach((id) => ids.add(id));
|
|
127
|
+
}
|
|
128
|
+
return ids;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// The baseline's sections by ID in `tree`, under `root`.
|
|
132
|
+
const sectionsOf = (tree, root) => sectionsById(baseline(tree, root));
|
|
133
|
+
|
|
134
|
+
// baselineLists for request `name`'s own work in `tree` (at commit `at`, or the
|
|
135
|
+
// working tree, its uncommitted changes included), from `fork`.
|
|
136
|
+
// The fork's tree is a past tree: its root: line alone, or today's root ([VW-8]).
|
|
137
|
+
const ownLists = (top, tree, at, fork, name, org) => {
|
|
138
|
+
const root = rootOf(top, tree, at);
|
|
139
|
+
const was = fork ? sectionsOf(openTree(top, fork), rootLine(top, openTree(top, fork), fork) ?? root) : sectionsOf(tree, root);
|
|
140
|
+
return baselineLists(was, sectionsOf(tree, root), org, ownIds(top, name, fork, root, requestsIn(tree), !at, at ?? 'HEAD'));
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
// The generated block of the Outcome ([REC-9]): content facts only.
|
|
144
|
+
function outcome(md, org, fates, droppedBy, live, adrs, lists) {
|
|
145
|
+
const { kinds, rs } = outcomeFacts(org, fates);
|
|
146
|
+
// With no change.md, the added, modified and removed come from the baseline itself.
|
|
147
|
+
const list = (ks) => (lists && ks[0] in lists ? lists[ks[0]] : [...kinds].filter(([, k]) => ks.includes(k)).map(([id]) => id))
|
|
148
|
+
.map((id) => `[${id}]`).join(', ') || 'none';
|
|
149
|
+
const lines = droppedBy ? [`- Dropped as a whole by ${droppedBy}`] : [];
|
|
150
|
+
for (const r of rs) lines.push(`- ${r.key}${r.title ? ` ${r.title}` : ''}: ${r.ids.length ? `in ${r.ids.join(', ')}` : 'in no section'}`);
|
|
151
|
+
for (const k of ['Added', 'Modified', 'Removed', 'Dropped', 'Kept']) lines.push(`- ${k}: ${list([k])}`);
|
|
152
|
+
const all = decisionList(md);
|
|
153
|
+
lines.push(`- Decisions: ${all.filter((d) => !d.agent).map((d) => d.id).join(', ') || 'none'}`);
|
|
154
|
+
lines.push(`- Agent rulings: ${all.filter((d) => d.agent).map((d) => d.id).join(', ') || 'none'}`);
|
|
155
|
+
lines.push(`- ADRs added: ${adrs.added.map((a) => `${a.n}${a.status === 'proposed' ? ' (proposed)' : ''}`).join(', ') || 'none'}`);
|
|
156
|
+
lines.push(`- ADRs superseded: ${adrs.superseded.map((a) => a.n).join(', ') || 'none'}`);
|
|
157
|
+
// Any code still live for dropped work: all the request's for a dropped
|
|
158
|
+
// request, else only under the markers of its Dropped sections.
|
|
159
|
+
const droppedIds = [...kinds].filter(([, k]) => k === 'Dropped').map(([id]) => id);
|
|
160
|
+
const code = droppedBy || droppedIds.length ? live(droppedBy ? null : droppedIds) : [];
|
|
161
|
+
if (code === null) lines.push(`- Code still live for dropped work: ${HISTORY}`);
|
|
162
|
+
else if (code.length) lines.push(`- Code still live for dropped work: ${code.join(', ')}`);
|
|
163
|
+
return lines;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// request.md with the Outcome (re)generated at its place, or appended; the
|
|
167
|
+
// text from its `Notes:` line on is kept.
|
|
168
|
+
function withOutcome(md, generated) {
|
|
169
|
+
const block = `## ${TITLES.outcome}\n\n${generated.join('\n')}\n\n`;
|
|
170
|
+
const lines = md.split('\n');
|
|
171
|
+
const at = headingAt(lines, TITLES.outcome);
|
|
172
|
+
if (at < 0) return `${md.replace(/\s+$/, '')}\n\n${block}Notes:\n`;
|
|
173
|
+
const start = lines.slice(0, at).reduce((n, l) => n + l.length + 1, 0);
|
|
174
|
+
const after = start + lines[at].length;
|
|
175
|
+
const next = md.slice(after).search(/^#{1,2}\s/m);
|
|
176
|
+
const end = next < 0 ? md.length : after + next;
|
|
177
|
+
const notes = md.slice(start, end).search(/^Notes:/m);
|
|
178
|
+
return md.slice(0, start) + block + (notes < 0 ? 'Notes:\n' : md.slice(start + notes, end)) + md.slice(end);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// The value of `Status:` on the request's status line set to `status`.
|
|
182
|
+
function withStatus(md, status) {
|
|
183
|
+
const lines = md.split('\n');
|
|
184
|
+
const i = statusAt(lines);
|
|
185
|
+
if (i >= 0) lines[i] = lines[i].replace(/(\bStatus:\s*)[^·]*?(\s*(?:·|$))/, `$1${status}$2`);
|
|
186
|
+
else lines.splice(lines.findIndex((l) => l.startsWith('# ')) + 1, 0, `Status: ${status}`);
|
|
187
|
+
return lines.join('\n');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function conclude({ top, args, opts }) {
|
|
191
|
+
const [name] = args;
|
|
192
|
+
const tree = openTree(top);
|
|
193
|
+
const dir = requestToWrite(top, tree, name);
|
|
194
|
+
const target = `requests/archive/${name}`;
|
|
195
|
+
const moving = dir !== target;
|
|
196
|
+
const md = tree.read(`${dir}/request.md`).toString('utf8');
|
|
197
|
+
const dropped = opts.dropped !== undefined;
|
|
198
|
+
if (dropped && !decisions(md).names.includes(opts.dropped)) {
|
|
199
|
+
throw new Fail(`--dropped ${opts.dropped}: name an entry of ## ${TITLES.decisions} in ${dir}/request.md (the decision to drop it, with its source)`,
|
|
200
|
+
`write the decision, then al conclude ${name} --dropped Dn`);
|
|
201
|
+
}
|
|
202
|
+
if (!noSymlinkOn(top, `${dir}/request.md`) || !noSymlinkOn(top, target)) {
|
|
203
|
+
throw new Fail(`${dir} or ${target} is reached through a symlink; records are written only through real folders, and nothing was written`, 'make requests/ a real folder in this repo');
|
|
204
|
+
}
|
|
205
|
+
if (moving && existsSync(join(top, target))) throw new Fail(`${target} already exists; a request name is never reused`, `al context ${name}`);
|
|
206
|
+
|
|
207
|
+
const main = mainCommit(top);
|
|
208
|
+
const fork = main && git(top, ['merge-base', main, 'HEAD'], { allowFail: true });
|
|
209
|
+
// The Outcome is written as fact: a fork not in the clone, or a commit past
|
|
210
|
+
// the shallow boundary, hides what the branch changed ([VW-9]).
|
|
211
|
+
const gap = main && !fork && historyGap(top);
|
|
212
|
+
if (gap) throw gapFail(gap, `, and the merge-base of ${mainName(top)} and HEAD is not in it; nothing was written`);
|
|
213
|
+
const cut = fork ? ownCommits(top, fork, 'HEAD').filter((c) => cutOff(top, c)).map((c) => c.slice(0, 7)) : [];
|
|
214
|
+
if (cut.length) throw gapFail(historyGap(top), `, and ${cut.join(', ')} ${cut.length === 1 ? 'has its parent' : 'have their parents'} past its boundary; nothing was written`);
|
|
215
|
+
const { sign, org, fates, refusals } = judge(top, tree, name, dir, dropped, undefined, fork);
|
|
216
|
+
// [STA-7]: three lines or fewer, so reasons beyond the second share the third.
|
|
217
|
+
const body = [...refusals.slice(0, 2), ...(refusals.length > 2 ? [refusals.slice(2).join(' · ')] : [])].map((r) => `refused: ${r}`);
|
|
218
|
+
if (body.length) {
|
|
219
|
+
return {
|
|
220
|
+
refused: true, body,
|
|
221
|
+
next: sign.blocked && !dropped ? signoffStep(name, dir, sign)
|
|
222
|
+
: `al context ${name}; then consolidate, revert or keep what is named`,
|
|
223
|
+
notKnown: NOT_KNOWN,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const status = dropped ? 'dropped' : 'concluded';
|
|
228
|
+
const root = rootOf(top, tree);
|
|
229
|
+
const live = (ids) => liveCode(top, name, root, ids);
|
|
230
|
+
const adrs = adrsOf(top, tree, undefined, name, requestsIn(tree));
|
|
231
|
+
const own = () => ownLists(top, tree, undefined, fork, name, org);
|
|
232
|
+
const lists = noChangeMd(tree, dir) ? own() : null;
|
|
233
|
+
const generated = outcome(md, org, fates, opts.dropped, live, adrs, lists);
|
|
234
|
+
const text = withOutcome(withStatus(md, status), generated);
|
|
235
|
+
const children = childrenOf(md).filter((c) => c !== name && tree.read(`requests/${c}/request.md`) !== null);
|
|
236
|
+
// [STA-7]: three lines or fewer, so every note shares one line.
|
|
237
|
+
const notes = [...spikeNotes(tree, name, dir, md, () => { const l = lists ?? own(); return [...l.Added, ...l.Modified, ...l.Removed]; }),
|
|
238
|
+
...(children.length ? [`child request ${children.join(', ')} is still open`] : []),
|
|
239
|
+
...adrs.added.filter((a) => a.status === 'proposed').map((a) => `${a.path}, added by ${name}, is still proposed`)];
|
|
240
|
+
const note = notes.length ? [`note: ${notes.join('; ')}`] : [];
|
|
241
|
+
// [TL-1]: the text it writes, on one line ([STA-7]).
|
|
242
|
+
const what = `Status: ${status}${moving ? `, and ${dir}/ moved to ${target}/` : ''}; Outcome: ${generated.map((l) => l.replace(/^- /, '')).join(' · ')}`;
|
|
243
|
+
if (!opts.yes) return { body: [`Would conclude ${name}: ${what}`, ...note], next: 'run the same command with --yes to do it', notKnown: NOT_KNOWN };
|
|
244
|
+
|
|
245
|
+
writeFileSync(join(top, dir, 'request.md'), text);
|
|
246
|
+
if (moving) {
|
|
247
|
+
mkdirSync(join(top, 'requests/archive'), { recursive: true });
|
|
248
|
+
renameSync(join(top, dir), join(top, target));
|
|
249
|
+
}
|
|
250
|
+
return { body: [`Concluded ${name}: ${what}`, ...note], next: `review ${target}/request.md, then commit it`, notKnown: NOT_KNOWN };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// conclude's rules on request `name` in `tree` (at commit `at`, or the working
|
|
254
|
+
// tree), its branch forked from main at `fork`: the refusals, none when it may
|
|
255
|
+
// conclude, and the facts they rest on.
|
|
256
|
+
export function judge(top, tree, name, dir, dropped, at, fork) {
|
|
257
|
+
const md = tree.read(`${dir}/request.md`).toString('utf8');
|
|
258
|
+
const sign = signoffState(tree, dir, name);
|
|
259
|
+
const org = organized(md);
|
|
260
|
+
const blocks = allBlocks(tree);
|
|
261
|
+
const signed = new Map();
|
|
262
|
+
const signedRs = (request) => {
|
|
263
|
+
if (!signed.has(request)) {
|
|
264
|
+
const d = findRequest(tree, request);
|
|
265
|
+
const o = d && organized(tree.read(`${d}/request.md`).toString('utf8'));
|
|
266
|
+
signed.set(request, o && !signoffState(tree, d, request).blocked ? parts(o.text, o.oneLine).map((p) => p.key) : []);
|
|
267
|
+
}
|
|
268
|
+
return signed.get(request);
|
|
269
|
+
};
|
|
270
|
+
const facts = {
|
|
271
|
+
name, dropped,
|
|
272
|
+
blocks,
|
|
273
|
+
// Whether a block's `for R<n>` names an R its request signed off, the sign-off current ([REC-5]).
|
|
274
|
+
signedR: (x) => x.forR.some((r) => signedRs(x.request).includes(r)),
|
|
275
|
+
ownerDecisions: new Set(decisionList(md).filter((d) => /\bowner\b/.test(d.source) && !d.agent).map((d) => d.id)),
|
|
276
|
+
};
|
|
277
|
+
const fates = statesOf(baseline(tree, rootOf(top, tree, at)), blocks, (b) => b.request === name)
|
|
278
|
+
.map((e) => ({ e, b: blocks.get(e.block), ...fateOf(e, blocks.get(e.block), facts) }));
|
|
279
|
+
// [STA-3]: with no change.md, a dropped request retains its own baseline
|
|
280
|
+
// changes; it has no block to revert or keep them by.
|
|
281
|
+
const lists = dropped && noChangeMd(tree, dir) && ownLists(top, tree, at, fork, name, org);
|
|
282
|
+
const retained = lists ? [...lists.Added, ...lists.Modified, ...lists.Removed] : [];
|
|
283
|
+
// [REC-6]: blocked, only a drop where every section retains nothing goes ahead.
|
|
284
|
+
const refusals = [];
|
|
285
|
+
if (sign.blocked && !(dropped && !retained.length && fates.every((f) => f.fate === 'dropped'))) refusals.push(`${name} is blocked: ${sign.reason} ([REC-6])`);
|
|
286
|
+
if (retained.length) refusals.push(`${name} is dropped but its baseline changes are retained: ${retained.map((id) => `[${id}]`).join(', ')}; revert them by hand ([STA-7])`);
|
|
287
|
+
// [SPC-5]: a block that can't be read as written is fixed before anything is judged.
|
|
288
|
+
const unreadable = [...blocks.values()].filter((b) => b.request === name).map(blockFault).filter(Boolean);
|
|
289
|
+
if (unreadable.length) refusals.push(unreadable.join(' · '));
|
|
290
|
+
const bad = fates.filter((f) => f.bad).map((f) => f.bad);
|
|
291
|
+
if (bad.length) refusals.push(bad.join(' · '));
|
|
292
|
+
return { md, sign, org, fates, refusals };
|
|
293
|
+
}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
// al consolidate <name> [--section <ID>] [--yes], and --revert <ID> ([STA-4]):
|
|
2
|
+
// everything is validated on a copy of the baseline in memory, shown, and with
|
|
3
|
+
// --yes every changed file is written at once. Only a pending block not marked
|
|
4
|
+
// Dropped is written ([STA-6]); a blocked request ([REC-6]) and a duplicate ID
|
|
5
|
+
// in the root ([SPC-3]) refuse.
|
|
6
|
+
import { lstatSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { basename, dirname, join } from 'node:path';
|
|
8
|
+
import { Fail } from './git.js';
|
|
9
|
+
import { openTree, noSymlinkOn } from './tree.js';
|
|
10
|
+
import { parseSections, sameSection } from './sections.js';
|
|
11
|
+
import { rootOf, baseline, duplicateIds, inBaseline } from './spec.js';
|
|
12
|
+
import { CONSOLIDATED, allBlocks, anchorFault, blockFault, stateText, statesOf } from './states.js';
|
|
13
|
+
import { signoffState, signoffStep } from './signoff.js';
|
|
14
|
+
import { requestToWrite } from './commands.js';
|
|
15
|
+
|
|
16
|
+
const NOT_KNOWN = ['whether the code does what the consolidated text says (tests and review judge that)'];
|
|
17
|
+
|
|
18
|
+
// `text` without its trailing blank lines (the last line keeps its newline).
|
|
19
|
+
function trimEnd(text) {
|
|
20
|
+
const lines = text.split(/(?<=\n)/);
|
|
21
|
+
while (lines.length && !lines.at(-1).trim()) lines.pop();
|
|
22
|
+
return lines.join('');
|
|
23
|
+
}
|
|
24
|
+
const tidy = (text) => `${trimEnd(text).replace(/\n$/, '')}\n`;
|
|
25
|
+
|
|
26
|
+
// The sections of `text` with offsets: `start`, `stop` (after its last
|
|
27
|
+
// non-blank line) and `end` (after its trailing blank lines).
|
|
28
|
+
function spans(text) {
|
|
29
|
+
let at = text.length;
|
|
30
|
+
const sections = parseSections(text);
|
|
31
|
+
at -= sections.reduce((n, s) => n + s.text.length, 0);
|
|
32
|
+
return sections.map((s) => {
|
|
33
|
+
const start = at;
|
|
34
|
+
at += s.text.length;
|
|
35
|
+
return { ...s, start, stop: start + trimEnd(s.text).length, end: at };
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// `section` placed at `pos` in `text`: one blank line before it, and the gap
|
|
40
|
+
// that stood at `pos` (at least one blank line) before what follows.
|
|
41
|
+
function insertAt(text, pos, section) {
|
|
42
|
+
const before = text.slice(0, pos);
|
|
43
|
+
const rest = text.slice(pos);
|
|
44
|
+
const gap = rest.match(/^(?:[ \t]*\r?\n)*/)[0];
|
|
45
|
+
const after = rest.slice(gap.length);
|
|
46
|
+
const head = before.trim() ? `${before.replace(/\n?$/, '\n')}\n` : '';
|
|
47
|
+
return head + section + (after ? (gap || '\n') + after : '');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// After section `i` and the deeper headings under it.
|
|
51
|
+
function afterSubtree(list, i) {
|
|
52
|
+
let j = i;
|
|
53
|
+
while (j + 1 < list.length && list[j + 1].level > list[i].level) j++;
|
|
54
|
+
return list[j].stop;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// A modify keeps the baseline heading's `#` count ([SPC-4] leaves it out of the comparison).
|
|
58
|
+
const withLevelOf = (old, section) => section.replace(/^ {0,3}#+/, old.text.match(/^ {0,3}#+/)[0]);
|
|
59
|
+
|
|
60
|
+
function removeSection(text, s) {
|
|
61
|
+
const out = text.slice(0, s.start) + text.slice(s.end);
|
|
62
|
+
return s.end === text.length ? trimEnd(out) : out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// The file and section holding `id` in the in-memory baseline, if any.
|
|
66
|
+
function find(texts, id) {
|
|
67
|
+
for (const [path, text] of texts) {
|
|
68
|
+
const list = spans(text);
|
|
69
|
+
const i = list.findIndex((s) => s.id === id);
|
|
70
|
+
if (i >= 0) return { path, text, list, i, section: list[i] };
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// [SPC-5]: a block holds exactly one heading, and it carries the block's own ID.
|
|
76
|
+
export function headingFault(b, text, side) {
|
|
77
|
+
const headings = parseSections(text);
|
|
78
|
+
if (headings.length !== 1) return `${b.key} holds ${headings.length} headings in its ${side}; a block holds exactly one ([SPC-5])`;
|
|
79
|
+
if (headings[0].id !== b.id) return `${b.key}'s heading carries ${headings[0].id ? `[${headings[0].id}]` : 'no ID'}, not [${b.id}] ([SPC-5])`;
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const filesOf = (texts) => [...texts].sort(([a], [b]) => (a < b ? -1 : 1)).map(([path, text]) => ({ path, sections: parseSections(text) }));
|
|
84
|
+
|
|
85
|
+
// Apply one pending block to `texts`: what it did, or why it cannot.
|
|
86
|
+
function apply(texts, b, blocks) {
|
|
87
|
+
const now = b.now === null ? null : tidy(b.now);
|
|
88
|
+
if (b.op === 'add' && b.path) {
|
|
89
|
+
const text = texts.get(b.path) ?? '';
|
|
90
|
+
texts.set(b.path, insertAt(text, trimEnd(text).length, now));
|
|
91
|
+
return { did: `[${b.id}] at the end of ${b.path}`, text: now };
|
|
92
|
+
}
|
|
93
|
+
if (b.op === 'add') {
|
|
94
|
+
const at = find(texts, b.anchor);
|
|
95
|
+
if (!at) {
|
|
96
|
+
const holder = [...blocks.values()].find((x) => x.open && x.op === 'add' && x.id === b.anchor && !x.dropped);
|
|
97
|
+
return { cannot: `anchor [${b.anchor}] is not in the baseline${holder ? `: ${holder.key} holds it pending; consolidate them together, or that one first` : ''}` };
|
|
98
|
+
}
|
|
99
|
+
texts.set(at.path, insertAt(at.text, afterSubtree(at.list, at.i), now));
|
|
100
|
+
return { did: `[${b.id}] after [${b.anchor}] in ${at.path}`, text: now };
|
|
101
|
+
}
|
|
102
|
+
const at = find(texts, b.id);
|
|
103
|
+
if (b.op === 'remove') {
|
|
104
|
+
// [SPC-5]: "was first in <path>" holds only for that file's first section.
|
|
105
|
+
if (b.firstIn && (at.path !== b.firstIn || at.i !== 0)) return { cannot: `[${b.id}] is not the first section of ${b.firstIn}, as its remove says` };
|
|
106
|
+
texts.set(at.path, removeSection(at.text, at.section));
|
|
107
|
+
return { did: `the removal of [${b.id}] from ${at.path}` };
|
|
108
|
+
}
|
|
109
|
+
texts.set(at.path, at.text.slice(0, at.section.start) + withLevelOf(at.section, now) + at.text.slice(at.section.stop));
|
|
110
|
+
return { did: `[${b.id}] in place in ${at.path}`, text: withLevelOf(at.section, now) };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function consolidate({ top, args, opts }) {
|
|
114
|
+
const [name] = args;
|
|
115
|
+
if (opts.revert !== undefined && opts.section !== undefined) {
|
|
116
|
+
throw new Fail('give --section or --revert, not both', `al consolidate ${name} --revert <ID>`);
|
|
117
|
+
}
|
|
118
|
+
const tree = openTree(top);
|
|
119
|
+
const dir = requestToWrite(top, tree, name);
|
|
120
|
+
const root = rootOf(top, tree);
|
|
121
|
+
const original = new Map(baseline(tree, root).map((f) => [f.path, f.text]));
|
|
122
|
+
const texts = new Map(original);
|
|
123
|
+
const blocks = allBlocks(tree);
|
|
124
|
+
const id = opts.revert ?? opts.section;
|
|
125
|
+
const mine = [...blocks.values()].filter((b) => b.request === name && (id === undefined || b.id === id));
|
|
126
|
+
if (id !== undefined && !mine.length) throw new Fail(`${name} holds no block for [${id}]`, `al context ${name}`);
|
|
127
|
+
const refuse = (reasons, next = `al context ${name}`) => ({ refused: true, body: reasons.map((r) => `refused: ${r}`), next, notKnown: NOT_KNOWN });
|
|
128
|
+
|
|
129
|
+
const dups = duplicateIds(filesOf(texts));
|
|
130
|
+
if (dups.length) {
|
|
131
|
+
return refuse(dups.map(([d, at]) => `duplicate ID [${d}] in ${at.join(' and ')}; no consolidate writes until it is fixed ([SPC-3])`),
|
|
132
|
+
'rename one copy of each duplicate ID by hand, to a new ID; an ID is never reused');
|
|
133
|
+
}
|
|
134
|
+
// [SPC-5]: a block that can't be read as written is fixed before any write, a revert included.
|
|
135
|
+
const unreadable = mine.map(blockFault).filter(Boolean);
|
|
136
|
+
if (unreadable.length) return refuse(unreadable, `fix the blocks named in ${dir}/change.md`);
|
|
137
|
+
if (opts.revert !== undefined) return revert({ top, root, name, id, mine, blocks, texts, original, opts, refuse });
|
|
138
|
+
|
|
139
|
+
const state = signoffState(tree, dir, name);
|
|
140
|
+
if (state.blocked) {
|
|
141
|
+
return refuse([`${name} is blocked: ${state.reason}; it is consolidated only once the sign-off is current ([REC-6])`],
|
|
142
|
+
signoffStep(name, dir, state));
|
|
143
|
+
}
|
|
144
|
+
const todo = mine.filter((b) => !b.dropped);
|
|
145
|
+
if (id !== undefined && !todo.length) return refuse([`every block of [${id}] in ${name} is marked Dropped, and a dropped section is not written`]);
|
|
146
|
+
const faults = todo.filter((b) => b.now !== null).map((b) => headingFault(b, b.now, 'Now')).filter(Boolean);
|
|
147
|
+
// [STA-4]: a remove's anchor is checked against the baseline as it stands before any write.
|
|
148
|
+
for (const b of todo.filter((x) => x.op === 'remove' && x.anchor)) {
|
|
149
|
+
const at = find(original, b.id);
|
|
150
|
+
const wrong = at && anchorFault(b, { path: at.path, sections: at.list });
|
|
151
|
+
if (wrong) faults.push(`${b.key} ${wrong}`);
|
|
152
|
+
}
|
|
153
|
+
if (faults.length) return refuse(faults, `fix the blocks named in ${dir}/change.md`);
|
|
154
|
+
|
|
155
|
+
// Write one pending block at a time, re-reading the states after each, so a
|
|
156
|
+
// request's own chain and an add with its anchor go together.
|
|
157
|
+
const read = () => new Map(statesOf(filesOf(texts), blocks, (b) => b.request === name).map((e) => [e.block, e]));
|
|
158
|
+
const done = [];
|
|
159
|
+
const cannot = new Map();
|
|
160
|
+
for (let progress = true; progress;) {
|
|
161
|
+
progress = false;
|
|
162
|
+
const states = read();
|
|
163
|
+
for (const b of todo) {
|
|
164
|
+
if (done.some((d) => d.key === b.key) || states.get(b.key).state !== 'pending') continue;
|
|
165
|
+
const r = apply(texts, b, blocks);
|
|
166
|
+
if (r.cannot) { cannot.set(b.key, r.cannot); continue; }
|
|
167
|
+
cannot.delete(b.key);
|
|
168
|
+
done.push({ key: b.key, did: r.did, text: r.text });
|
|
169
|
+
progress = true;
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const final = read();
|
|
174
|
+
const reasons = todo.filter((b) => !CONSOLIDATED.includes(final.get(b.key).state)).map((b) => {
|
|
175
|
+
const e = final.get(b.key);
|
|
176
|
+
return `${b.key} ${cannot.get(b.key) ?? stateText(e)}`;
|
|
177
|
+
});
|
|
178
|
+
if (reasons.length) return refuse(reasons);
|
|
179
|
+
if (!done.length) return { body: [`nothing to write: ${name}'s ${id === undefined ? 'sections are' : `[${id}] is`} consolidated or carried`], next: `al context ${name}`, notKnown: NOT_KNOWN };
|
|
180
|
+
return write(top, root, original, texts, done.map((d) => ({ did: `${d.did}, from ${d.key}`, text: d.text })), opts.yes, name);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// --revert <ID>: the "was" of the request's first block for the ID goes back.
|
|
184
|
+
function revert({ top, root, name, id, mine, blocks, texts, original, opts, refuse }) {
|
|
185
|
+
const first = mine.reduce((m, b) => (b.n < m.n ? b : m));
|
|
186
|
+
// A Dropped marker never lifts the guard: carried is read as if no block were dropped ([STA-6]).
|
|
187
|
+
const undropped = new Map([...blocks].map(([k, b]) => [k, { ...b, dropped: null, requestDropped: false }]));
|
|
188
|
+
const carried = statesOf(filesOf(texts), undropped, (b) => mine.some((m) => m.key === b.key))
|
|
189
|
+
.find((e) => e.state === 'carried' && !e.by.startsWith(`${name}/`));
|
|
190
|
+
if (carried) return refuse([`[${id}] is carried by ${carried.by}; a section carried by a successor is not reverted, only kept ([STA-6])`]);
|
|
191
|
+
const fault = first.was !== null && headingFault(first, first.was, 'Was');
|
|
192
|
+
if (fault) return refuse([fault], `fix the block named in its change.md`);
|
|
193
|
+
const at = find(texts, id);
|
|
194
|
+
let did = null;
|
|
195
|
+
let text = null;
|
|
196
|
+
if (first.op === 'add') {
|
|
197
|
+
if (at) {
|
|
198
|
+
texts.set(at.path, removeSection(at.text, at.section));
|
|
199
|
+
did = `the removal of [${id}] from ${at.path}`;
|
|
200
|
+
}
|
|
201
|
+
} else if (at) {
|
|
202
|
+
if (!sameSection(at.section.text, first.was)) {
|
|
203
|
+
text = withLevelOf(at.section, tidy(first.was));
|
|
204
|
+
texts.set(at.path, at.text.slice(0, at.section.start) + text + at.text.slice(at.section.stop));
|
|
205
|
+
did = `the "was" of [${id}] in place in ${at.path}`;
|
|
206
|
+
}
|
|
207
|
+
} else if (mine.find((b) => b.op === 'remove')?.firstIn) {
|
|
208
|
+
// [STA-4]: a removed first section goes back first in its file, after any text before it.
|
|
209
|
+
const path = mine.find((b) => b.op === 'remove').firstIn;
|
|
210
|
+
const file = texts.get(path) ?? '';
|
|
211
|
+
text = tidy(first.was);
|
|
212
|
+
texts.set(path, insertAt(file, spans(file)[0]?.start ?? file.length, text));
|
|
213
|
+
did = `[${id}] back first in ${path}`;
|
|
214
|
+
} else {
|
|
215
|
+
const removal = mine.find((b) => b.op === 'remove' && b.anchor);
|
|
216
|
+
const anchor = removal?.anchor;
|
|
217
|
+
const where = anchor ? find(texts, anchor) : null;
|
|
218
|
+
if (!where) {
|
|
219
|
+
return refuse([`[${id}] cannot be put back: ${anchor ? `its anchor [${anchor}] is not in the baseline` : 'it is not in the baseline, and no remove records where it stood'}`]);
|
|
220
|
+
}
|
|
221
|
+
// A shallower anchor is the parent: the section goes back first under it.
|
|
222
|
+
// The removal's Was, which the check held against the anchor, says which.
|
|
223
|
+
const unread = headingFault(removal, removal.was, 'Was');
|
|
224
|
+
if (unread) return refuse([unread], `fix the block named in its change.md`);
|
|
225
|
+
const under = where.section.level < parseSections(removal.was)[0].level;
|
|
226
|
+
text = tidy(first.was);
|
|
227
|
+
texts.set(where.path, insertAt(where.text, under ? where.section.stop : afterSubtree(where.list, where.i), text));
|
|
228
|
+
did = `[${id}] back ${under ? 'first under' : 'after'} [${anchor}] in ${where.path}`;
|
|
229
|
+
}
|
|
230
|
+
if (!did) return { body: [`nothing to write: [${id}] already reads the "was" of ${first.key}`], next: `al context ${name}`, notKnown: NOT_KNOWN };
|
|
231
|
+
return write(top, root, original, texts, [{ did: `${did}, from ${first.key}`, text }], opts.yes, name);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Show the writes, each with the section text it writes ([TL-1]), or with
|
|
235
|
+
// --yes write every changed file at once: each to a new temporary file
|
|
236
|
+
// beside it, then all renamed into place. Every file written
|
|
237
|
+
// is a .md file under the root, reached through real folders inside the repo,
|
|
238
|
+
// and is a regular file or not there yet. If any step fails, the files already
|
|
239
|
+
// renamed are put back and the temporary files removed, so nothing is written.
|
|
240
|
+
function write(top, root, original, texts, what, yes, name) {
|
|
241
|
+
const changed = [...texts].filter(([path, text]) => original.get(path) !== text);
|
|
242
|
+
for (const [path] of changed) {
|
|
243
|
+
let kind = null;
|
|
244
|
+
try { kind = lstatSync(join(top, path)); } catch { /* not there yet */ }
|
|
245
|
+
if (!inBaseline(path, root) || !noSymlinkOn(top, path) || (kind && !kind.isFile())) {
|
|
246
|
+
throw new Fail(`${path}: consolidate writes only .md files under the baseline root ${root}/, named in plain form, inside this repo, not through a symlink and not over a folder; nothing was written`,
|
|
247
|
+
`fix the path in the block's heading in change.md, e.g. add in ${root}/<area>.md`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
const shown = (verb) => what.flatMap((w) => [`${verb} ${w.did}`, ...(w.text ? w.text.replace(/\n$/, '').split('\n') : [])]);
|
|
251
|
+
if (!yes) return { body: shown('Would write'), next: 'run the same command with --yes to write it', notKnown: NOT_KNOWN };
|
|
252
|
+
const temps = [];
|
|
253
|
+
const renamed = [];
|
|
254
|
+
try {
|
|
255
|
+
for (const [path, text] of changed) {
|
|
256
|
+
mkdirSync(dirname(join(top, path)), { recursive: true });
|
|
257
|
+
const tmp = join(top, dirname(path), `.${basename(path)}.al-${process.pid}`);
|
|
258
|
+
writeFileSync(tmp, text, { flag: 'wx' }); // a new file: never through or over what is there
|
|
259
|
+
temps.push([tmp, path]);
|
|
260
|
+
}
|
|
261
|
+
for (const [tmp, path] of temps) {
|
|
262
|
+
renameSync(tmp, join(top, path));
|
|
263
|
+
renamed.push(path);
|
|
264
|
+
}
|
|
265
|
+
} catch (e) {
|
|
266
|
+
for (const path of renamed) {
|
|
267
|
+
if (original.has(path)) writeFileSync(join(top, path), original.get(path));
|
|
268
|
+
else rmSync(join(top, path), { force: true });
|
|
269
|
+
}
|
|
270
|
+
for (const [tmp] of temps) rmSync(tmp, { force: true });
|
|
271
|
+
throw new Fail(`could not write the baseline (${e.code ?? e.message}${e.path ? ` at ${e.path}` : ''}); every file was put back as it was, and nothing was written`,
|
|
272
|
+
'remove what is in the way, then run the same command again');
|
|
273
|
+
}
|
|
274
|
+
return {
|
|
275
|
+
body: shown('Wrote'),
|
|
276
|
+
next: `review the diff of ${changed.map(([p]) => p).join(', ')} and commit it; then al context ${name}`,
|
|
277
|
+
notKnown: NOT_KNOWN,
|
|
278
|
+
};
|
|
279
|
+
}
|