@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/links.js
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// Rough links, derived when read and never kept ([LNK-1]): a bracketed ID,
|
|
2
|
+
// blame, files changed together, and shared words; and a commit's request
|
|
3
|
+
// ([LNK-2]): a Request: line, else the request folder it touched, else an
|
|
4
|
+
// issue number in a request's owner's words.
|
|
5
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { HISTORY, cutOff, day, git } from './git.js';
|
|
8
|
+
import { folderOf, requestDirs } from './tree.js';
|
|
9
|
+
import { ID_TEXT, TITLES, parseSections, sameSection, sectionsById } from './sections.js';
|
|
10
|
+
import { entriesOf } from './commands.js';
|
|
11
|
+
|
|
12
|
+
export const WIDE = 30;
|
|
13
|
+
const REQUEST_LINE = /^[ \t]*(?:[-*][ \t]+)?Request:[ \t]*([a-z0-9][a-z0-9-]*)[ \t]*$/gm;
|
|
14
|
+
const ID_MARK = new RegExp(`\\[(${ID_TEXT})\\]`, 'g');
|
|
15
|
+
const STOP = new Set(['must', 'with', 'from', 'that', 'this', 'each', 'when', 'have', 'into', 'than', 'then', 'only', 'also', 'more', 'none', 'what', 'which', 'their', 'there', 'they', 'will', 'shall', 'should']);
|
|
16
|
+
|
|
17
|
+
export const paths = (out) => (out ?? '').split('\0').filter(Boolean);
|
|
18
|
+
export const idsOn = (line) => [...line.matchAll(ID_MARK)].map((m) => m[1]);
|
|
19
|
+
export const cites = (text, id) => text.includes(`[${id}]`);
|
|
20
|
+
|
|
21
|
+
// The words of a heading or a path, for link 4: split on non-letters and at
|
|
22
|
+
// camelCase, lowercased, four letters or more, without a few common words.
|
|
23
|
+
export const wordsOf = (text) => new Set(text.replace(/([a-z])([A-Z])/g, '$1 $2').toLowerCase().split(/[^a-z]+/)
|
|
24
|
+
.filter((w) => w.length >= 4 && !STOP.has(w)));
|
|
25
|
+
|
|
26
|
+
// Every request in `tree`, open or archived, with its records.
|
|
27
|
+
export function requestsIn(tree) {
|
|
28
|
+
return requestDirs(tree).map((r) => ({ ...r, md: tree.read(`${r.dir}/request.md`)?.toString('utf8') ?? '', change: tree.read(`${r.dir}/change.md`)?.toString('utf8') ?? '' }))
|
|
29
|
+
.filter((r) => r.md);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// The files a commit changed, against its first parent (all of them for a root
|
|
33
|
+
// commit); null, unknown, for one whose parent is past the shallow boundary ([VW-9]).
|
|
34
|
+
export function filesOf(top, sha) {
|
|
35
|
+
const parent = git(top, ['rev-parse', '--verify', '--quiet', `${sha}^1`], { allowFail: true });
|
|
36
|
+
if (!parent && cutOff(top, sha)) return null;
|
|
37
|
+
return paths(parent ? git(top, ['diff', '--name-only', '-z', '--no-renames', parent, sha])
|
|
38
|
+
: git(top, ['diff-tree', '-r', '-z', '--no-commit-id', '--name-only', '--root', sha]));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// The files a commit changed on its own: for a merge, those that differ from
|
|
42
|
+
// every parent (an edit made while merging), never what it brought from
|
|
43
|
+
// either side; for any other commit, filesOf (null when unknown).
|
|
44
|
+
export function ownFiles(top, sha) {
|
|
45
|
+
if (!git(top, ['rev-parse', '--verify', '--quiet', `${sha}^2`], { allowFail: true })) return filesOf(top, sha);
|
|
46
|
+
return paths(git(top, ['diff-tree', '-r', '-z', '--no-commit-id', '--name-only', '-c', sha]));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// For a read bounded by the branch's final state: a commit's own files, or,
|
|
50
|
+
// when they are unknown, those that differ from the fork `base` (every file of
|
|
51
|
+
// its tree with no fork); never to say what it changed ([VW-9]).
|
|
52
|
+
export const mayTouch = (top, sha, base, files = ownFiles) => files(top, sha)
|
|
53
|
+
?? paths(git(top, base ? ['diff', '--name-only', '-z', '--no-renames', base, sha] : ['ls-tree', '-r', '-z', '--name-only', sha]));
|
|
54
|
+
|
|
55
|
+
// The commits up to `rev` that map to request `name`, by every route [LNK-2]
|
|
56
|
+
// maps by: a Request: line, its folder, an issue number in its owner's words
|
|
57
|
+
// (`issues`, such as ['#123']). Each as requestOf gives it.
|
|
58
|
+
export function requestCommits(top, name, issues, rev, requests, seen = new Map()) {
|
|
59
|
+
const shas = [...new Set([
|
|
60
|
+
...git(top, ['log', '-E', `--grep=^[[:space:]]*([-*][[:space:]]+)?Request:[[:space:]]*${name}[[:space:]]*$`, '--format=%H', rev]).split('\n'),
|
|
61
|
+
...git(top, ['log', '--format=%H', rev, '--', `requests/${name}/`, `requests/archive/${name}/`]).split('\n'),
|
|
62
|
+
...issues.flatMap((n) => git(top, ['log', '-E', `--grep=${n}([^0-9]|$)`, '--format=%H', rev]).split('\n')),
|
|
63
|
+
].filter(Boolean))];
|
|
64
|
+
return shas.map((sha) => requestOf(top, sha, requests, seen)).filter((c) => c.names.includes(name));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// The request a commit maps to ([LNK-2]): { names, how, when }, cached in `seen`.
|
|
68
|
+
// `files` gives a commit's files for the folder route (ownFiles for a branch's commits).
|
|
69
|
+
export function requestOf(top, sha, requests, seen = new Map(), files = filesOf) {
|
|
70
|
+
if (seen.has(sha)) return seen.get(sha);
|
|
71
|
+
const [when, ...body] = git(top, ['show', '-s', '--format=%ct%n%B', sha]).split('\n');
|
|
72
|
+
const message = body.join('\n');
|
|
73
|
+
let names = [...new Set([...message.matchAll(REQUEST_LINE)].map((m) => m[1]))];
|
|
74
|
+
let how = 'Request: line';
|
|
75
|
+
let fs;
|
|
76
|
+
if (!names.length) {
|
|
77
|
+
fs = files(top, sha);
|
|
78
|
+
names = [...new Set((fs ?? []).map(folderOf).filter(Boolean))];
|
|
79
|
+
how = names.length > 1 ? 'folder, ambiguous' : 'folder';
|
|
80
|
+
}
|
|
81
|
+
for (const [, n] of names.length ? [] : message.matchAll(/#(\d+)(?!\d)/g)) {
|
|
82
|
+
names = requests.filter((r) => new RegExp(`#${n}(?!\\d)`).test(entriesOf(r.md, TITLES.words).join('\n'))).map((r) => r.name);
|
|
83
|
+
how = `issue #${n}`;
|
|
84
|
+
if (names.length) break;
|
|
85
|
+
}
|
|
86
|
+
// Its files unknown, the folder route is too: not "no request" ([VW-9]).
|
|
87
|
+
const found = { sha, names, how: names.length ? how : fs === null ? HISTORY : 'no request', when: new Date(Number(when) * 1000) };
|
|
88
|
+
seen.set(sha, found);
|
|
89
|
+
return found;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// `a, b by <how>`, or `no request (<sha> <date>)`.
|
|
93
|
+
export const describe = (c) => (c.names.length ? `${c.names.join(', ')} by ${c.how}` : `no request (${c.sha.slice(0, 7)} ${day(c.when)})`);
|
|
94
|
+
|
|
95
|
+
// The commits .git-blame-ignore-revs lists, as it is at `at` (the working
|
|
96
|
+
// tree when null), each one that git knows as `--ignore-rev <sha>`.
|
|
97
|
+
function ignored(top, at) {
|
|
98
|
+
const file = join(top, '.git-blame-ignore-revs');
|
|
99
|
+
const text = at ? git(top, ['show', `${at}:.git-blame-ignore-revs`], { allowFail: true }) : existsSync(file) ? readFileSync(file, 'utf8') : null;
|
|
100
|
+
return (text ?? '').split('\n').map((l) => l.replace(/#.*/, '').trim()).filter(Boolean)
|
|
101
|
+
.filter((r) => git(top, ['rev-parse', '--verify', '--quiet', `${r}^{commit}`], { allowFail: true }))
|
|
102
|
+
.flatMap((r) => ['--ignore-rev', r]);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Blame of `ranges` ([from, to] pairs) of `path` at `rev` (the working tree when
|
|
106
|
+
// null): each line's commit and time. -w -M always, -C for code, and the
|
|
107
|
+
// project's .git-blame-ignore-revs as it is at `ignoreAt` (by default `rev`,
|
|
108
|
+
// so a view of commits reads only committed records) ([LNK-1] #2).
|
|
109
|
+
export function blame(top, rev, path, ranges, { code, ignoreAt = rev }) {
|
|
110
|
+
if (!ranges.length) return [];
|
|
111
|
+
// --ignore-revs-file '' first clears any file git's own config names (blame.ignoreRevsFile).
|
|
112
|
+
const out = git(top, ['blame', '--porcelain', '-w', '-M', ...(code ? ['-C'] : []), '--ignore-revs-file', '', ...ignored(top, ignoreAt),
|
|
113
|
+
...ranges.flatMap(([a, b]) => ['-L', `${a},${b}`]), ...(rev ? [rev] : []), '--', path], { allowFail: true, worktree: !rev }) ?? '';
|
|
114
|
+
const result = [];
|
|
115
|
+
const times = new Map();
|
|
116
|
+
for (const l of out.split('\n')) {
|
|
117
|
+
const h = l.match(/^([0-9a-f]{40}) \d+ (\d+)/);
|
|
118
|
+
if (h) result.push({ sha: h[1], line: Number(h[2]) });
|
|
119
|
+
const t = l.match(/^committer-time (\d+)/);
|
|
120
|
+
if (t) times.set(result.at(-1).sha, Number(t[1]));
|
|
121
|
+
}
|
|
122
|
+
return result.map((r) => ({ ...r, when: new Date((times.get(r.sha) ?? 0) * 1000) }));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// The sections by ID in a Markdown text.
|
|
126
|
+
export const byId = (text) => sectionsById([{ sections: parseSections(text) }]);
|
|
127
|
+
|
|
128
|
+
// The IDs of the baseline sections that differ between two revs (or that one
|
|
129
|
+
// of them lacks), over the root's Markdown files among `files`.
|
|
130
|
+
export function sectionsChanged(top, from, to, root, files, tree) {
|
|
131
|
+
const ids = [];
|
|
132
|
+
for (const f of files.filter((p) => p.startsWith(`${root}/`) && p.endsWith('.md'))) {
|
|
133
|
+
// `to` null with a `tree`: the working tree's text.
|
|
134
|
+
const show = (rev) => byId(rev ? git(top, ['show', `${rev}:${f}`], { allowFail: true }) ?? '' : tree?.read(f)?.toString('utf8') ?? '');
|
|
135
|
+
const [a, b] = [show(from), show(to)];
|
|
136
|
+
for (const id of new Set([...a.keys(), ...b.keys()])) if (!(a.has(id) && b.has(id) && sameSection(a.get(id), b.get(id)))) ids.push(id);
|
|
137
|
+
}
|
|
138
|
+
return ids;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// The sections found in another file of `after` than in `before` (baseline
|
|
142
|
+
// files, each with its sections), their text unchanged ([SPC-4]): { id, from, to }.
|
|
143
|
+
export function movesOf(before, after) {
|
|
144
|
+
const where = (files) => new Map(files.flatMap((f) => f.sections.filter((s) => s.id).map((s) => [s.id, { path: f.path, text: s.text }])));
|
|
145
|
+
const [was, now] = [where(before), where(after)];
|
|
146
|
+
return [...now].filter(([id, n]) => was.has(id) && was.get(id).path !== n.path && sameSection(was.get(id).text, n.text))
|
|
147
|
+
.map(([id, n]) => ({ id, from: was.get(id).path, to: n.path }));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Link 3: the commits (up to `rev`) that changed `path`, with their files;
|
|
151
|
+
// commits over 30 files are skipped and counted.
|
|
152
|
+
export function changedWith(top, rev, path) {
|
|
153
|
+
const commits = [];
|
|
154
|
+
let skipped = 0;
|
|
155
|
+
for (const sha of git(top, ['log', '--format=%H', rev, '--', path]).split('\n').filter(Boolean)) {
|
|
156
|
+
const files = filesOf(top, sha);
|
|
157
|
+
if (!files) continue;
|
|
158
|
+
if (files.length > WIDE) skipped++;
|
|
159
|
+
else commits.push({ sha, files });
|
|
160
|
+
}
|
|
161
|
+
return { commits, skipped };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Link 1 for a change at new-side line `at` (the first changed line) of a file:
|
|
165
|
+
// the IDs on the changed lines, each at the first line naming it, else the
|
|
166
|
+
// nearest [ID] line above, at `at` with its distance.
|
|
167
|
+
export function idNear(fileLines, at, changed) {
|
|
168
|
+
const on = new Map();
|
|
169
|
+
for (const n of changed) for (const id of idsOn(fileLines[n - 1] ?? '')) if (!on.has(id)) on.set(id, n);
|
|
170
|
+
if (on.size) return [...on].map(([id, line]) => ({ id, line, reason: `names [${id}] on the changed line` }));
|
|
171
|
+
for (let n = at - 1; n >= 1; n--) {
|
|
172
|
+
const ids = idsOn(fileLines[n - 1] ?? '');
|
|
173
|
+
if (ids.length) return ids.map((id) => ({ id, line: at, reason: `names [${id}], ${at - n} line${at - n === 1 ? '' : 's'} above` }));
|
|
174
|
+
}
|
|
175
|
+
return [];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Ranges of line numbers, as `a-b` or `a`.
|
|
179
|
+
export const ranges = (numbers) => [...numbers].sort((x, y) => x - y)
|
|
180
|
+
.reduce((out, n) => (out.length && out.at(-1)[1] === n - 1 ? (out.at(-1)[1] = n, out) : [...out, [n, n]]), [])
|
|
181
|
+
.map(([a, b]) => (a === b ? `${a}` : `${a}-${b}`));
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// al record <name> section <ID> [--builds-on <request>] [--accept] [--decision Dn] [--yes]
|
|
2
|
+
// ([STA-5], [TL-1]): hold a baseline section, build on another request's
|
|
3
|
+
// block, revise one's own block under an existing decision, or accept the
|
|
4
|
+
// text underneath. Shows what it would write, and writes on --yes.
|
|
5
|
+
import { writeFileSync } from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { Fail, day, historyGap, now } from './git.js';
|
|
8
|
+
import { openTree, noSymlinkOn, findRequest, isName } from './tree.js';
|
|
9
|
+
import { rootOf, baseline, idsUsed, nextId } from './spec.js';
|
|
10
|
+
import { blockFault, parseChange, takeText, withRepeats } from './states.js';
|
|
11
|
+
import { BAD_NAME, decisionList, requestToWrite } from './commands.js';
|
|
12
|
+
import { headingFault } from './consolidate.js';
|
|
13
|
+
import { TITLES, headingAt } from './sections.js';
|
|
14
|
+
|
|
15
|
+
const indent = (text) => text.replace(/\n+$/, '').split('\n').map((l) => (l ? ` ${l}` : '')).join('\n') + '\n';
|
|
16
|
+
const render = (head, was, now) => `### ${head}\n` + (was != null ? `Was:\n\n${indent(was)}\n` : '') + (now != null ? `Now:\n\n${indent(now)}\n` : '');
|
|
17
|
+
|
|
18
|
+
// The line range of block `<ID>@<n>` in change.md: from its heading to the next `### ` heading.
|
|
19
|
+
function range(lines, id, n) {
|
|
20
|
+
const start = lines.findIndex((l) => l.startsWith(`### [${id}]@${n} `) || l === `### [${id}]@${n}`);
|
|
21
|
+
let end = start + 1;
|
|
22
|
+
while (end < lines.length && !/^#{2,3}\s/.test(lines[end])) {
|
|
23
|
+
if (/^(Was|Now):\s*$/.test(lines[end].trim())) end = takeText(lines, end + 1).next;
|
|
24
|
+
else end++;
|
|
25
|
+
}
|
|
26
|
+
return { start, end };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// The D-numbers of the entries in request.md's `## Decisions` (to the next
|
|
30
|
+
// level-1/2 heading), and where the next entry goes.
|
|
31
|
+
export function decisions(md) {
|
|
32
|
+
const lines = md.split('\n');
|
|
33
|
+
const at = headingAt(lines, TITLES.decisions);
|
|
34
|
+
let end = lines.length;
|
|
35
|
+
if (at >= 0) for (let i = at + 1; i < lines.length; i++) if (/^#{1,2}\s/.test(lines[i])) { end = i; break; }
|
|
36
|
+
const names = decisionList(md).map((d) => d.id);
|
|
37
|
+
const ids = names.map((n) => Number(n.slice(1)));
|
|
38
|
+
let last = end;
|
|
39
|
+
while (last > at + 1 && !lines[last - 1].trim()) last--;
|
|
40
|
+
return { at, ids, names, insertAt: last, lines };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function recordSection({ top, args, opts }) {
|
|
44
|
+
const [name, , id] = args;
|
|
45
|
+
const tree = openTree(top);
|
|
46
|
+
const dir = requestToWrite(top, tree, name);
|
|
47
|
+
if (!id) throw new Fail('which section? al record <name> section <ID>', `al record ${name} section <ID>`);
|
|
48
|
+
const changePath = `${dir}/change.md`;
|
|
49
|
+
const reqPath = `${dir}/request.md`;
|
|
50
|
+
if (!noSymlinkOn(top, changePath) || !noSymlinkOn(top, reqPath)) {
|
|
51
|
+
throw new Fail(`${dir} is reached through a symlink; records are written only through real folders, and nothing was written`, `make ${dir} a real folder in this repo`);
|
|
52
|
+
}
|
|
53
|
+
const change = tree.read(changePath)?.toString('utf8') ?? null;
|
|
54
|
+
const md = tree.read(reqPath).toString('utf8');
|
|
55
|
+
const mine = change ? parseChange(change, name).filter((b) => b.id === id) : [];
|
|
56
|
+
const latest = mine.reduce((m, b) => (!m || b.n > m.n ? b : m), null);
|
|
57
|
+
const section = baseline(tree, rootOf(top, tree)).flatMap((f) => f.sections).find((s) => s.id === id);
|
|
58
|
+
const underneath = section ? section.text.replace(/\n+$/, '\n') : null;
|
|
59
|
+
const date = day(now());
|
|
60
|
+
const notKnown = ['whether the new text is right (review judges that)'];
|
|
61
|
+
let newChange;
|
|
62
|
+
let newRequest = md;
|
|
63
|
+
let shown;
|
|
64
|
+
|
|
65
|
+
if (opts.accept) {
|
|
66
|
+
if (!latest || !underneath) throw new Fail(`--accept needs a block of ${name} holding [${id}] and [${id}] in the baseline`, `al context ${name}`);
|
|
67
|
+
const lines = change.split('\n');
|
|
68
|
+
const { start, end } = range(lines, id, latest.n);
|
|
69
|
+
const head = lines[start].slice(4);
|
|
70
|
+
newChange = [...lines.slice(0, start), render(head, latest.was, underneath).replace(/\n$/, ''), ...lines.slice(end)].join('\n');
|
|
71
|
+
const { at, ids, insertAt, lines: mdLines } = decisions(md);
|
|
72
|
+
const d = `- D${Math.max(0, ...ids) + 1}, ${date}. Source: the agent. Accepted the text underneath [${id}] as the new "now" for ${latest.key}.`;
|
|
73
|
+
newRequest = at < 0 ? `${md.replace(/\n*$/, '\n')}\n## ${TITLES.decisions}\n\n${d}\n`
|
|
74
|
+
: [...mdLines.slice(0, insertAt), d, ...mdLines.slice(insertAt)].join('\n');
|
|
75
|
+
shown = [`${latest.key}: Now becomes the text underneath:`, ...underneath.replace(/\n$/, '').split('\n'), `and request.md gets: ${d}`];
|
|
76
|
+
} else if (latest) {
|
|
77
|
+
if (opts['builds-on']) throw new Fail(`${name} already holds [${id}] (${latest.key}); a new version builds on it`, `al record ${name} section ${id} --decision Dn`);
|
|
78
|
+
const d = opts.decision;
|
|
79
|
+
if (!decisions(md).names.includes(d)) {
|
|
80
|
+
throw new Fail(`a revision of ${latest.key} needs --decision Dn naming an entry in ## ${TITLES.decisions}: write the decision first (why, and its source), then pass --decision Dn`,
|
|
81
|
+
`write the decision in ${reqPath}, then al record ${name} section ${id} --decision Dn`);
|
|
82
|
+
}
|
|
83
|
+
const lines = change.split('\n');
|
|
84
|
+
const { start, end } = range(lines, id, latest.n);
|
|
85
|
+
const marked = `${lines[start]} Revised ${date} (${d})`;
|
|
86
|
+
const next = render(`[${id}]@${latest.n + 1} modify`, latest.now, latest.now);
|
|
87
|
+
newChange = [...lines.slice(0, start), marked, ...lines.slice(start + 1, end), next, ...lines.slice(end)].join('\n');
|
|
88
|
+
shown = [`${latest.key} is marked Revised ${date} (${d}), and after it:`, ...next.replace(/\n$/, '').split('\n')];
|
|
89
|
+
} else {
|
|
90
|
+
let head = `[${id}]@1 modify`;
|
|
91
|
+
let text = underneath;
|
|
92
|
+
if (opts['builds-on']) {
|
|
93
|
+
const other = opts['builds-on'];
|
|
94
|
+
if (!isName(other)) throw new Fail(BAD_NAME(other), `al record ${name} section ${id} --builds-on <request>`);
|
|
95
|
+
const dir = findRequest(tree, other);
|
|
96
|
+
if (!dir) throw new Fail(`no request named ${other} in the ${tree.label}`, 'al context for the requests there are');
|
|
97
|
+
const theirs = parseChange(tree.read(`${dir}/change.md`)?.toString('utf8') ?? '', other).filter((b) => b.id === id);
|
|
98
|
+
const top1 = theirs.reduce((m, b) => (!m || b.n > m.n ? b : m), null);
|
|
99
|
+
if (!top1) throw new Fail(`${other} holds no block for [${id}]`, `al context ${other}`);
|
|
100
|
+
head += ` builds on ${top1.key}`;
|
|
101
|
+
text = top1.now;
|
|
102
|
+
} else if (!underneath) {
|
|
103
|
+
// [SPC-3]: a new section takes the next ID ever free for its prefix.
|
|
104
|
+
const prefix = id.split('-')[0];
|
|
105
|
+
const root = rootOf(top, tree);
|
|
106
|
+
const gap = historyGap(top);
|
|
107
|
+
if (gap) throw new Fail(`[${id}] is in neither the baseline nor ${name}, and the next free ${prefix} ID is not known: this clone is ${gap.kind}, without the full history`, gap.next);
|
|
108
|
+
const free = nextId(idsUsed(top, tree, root), prefix);
|
|
109
|
+
throw new Fail(`[${id}] is in neither the baseline nor ${name}. A new section takes the next free ${prefix} ID, [${free}]: write its block by hand in ${changePath}, headed ### [${free}]@1 add in ${root}/<file>.md (or add after [ID]), then Now: and the section, indented`,
|
|
110
|
+
`edit ${changePath}, then al context ${name}`);
|
|
111
|
+
}
|
|
112
|
+
const block = render(head, text, text);
|
|
113
|
+
const base = change ?? `## ${TITLES.specChanges}\n`;
|
|
114
|
+
const withSpec = headingAt(base.split('\n'), TITLES.specChanges) >= 0 ? base : `${base.replace(/\n*$/, '\n')}\n## ${TITLES.specChanges}\n`;
|
|
115
|
+
newChange = `${withSpec.replace(/\n*$/, '\n')}\n${block}`;
|
|
116
|
+
shown = block.replace(/\n$/, '').split('\n');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// [SPC-5]: the request's blocks that break the one-heading rule, said; drafting is never refused.
|
|
120
|
+
const faults = withRepeats(change ? parseChange(change, name) : []).flatMap((b) => [blockFault(b), ...[[b.now, 'Now'], [b.was, 'Was']].map(([t, side]) => t !== null && headingFault(b, t, side))])
|
|
121
|
+
.filter(Boolean).map((f) => `not ok: ${f}; fix it in ${changePath}, then al context ${name}`);
|
|
122
|
+
if (!opts.yes) return { body: ['Would write:', ...shown, ...faults], next: 'run the same command with --yes to write it', notKnown };
|
|
123
|
+
writeFileSync(join(top, changePath), newChange);
|
|
124
|
+
if (newRequest !== md) writeFileSync(join(top, reqPath), newRequest);
|
|
125
|
+
return { body: ['Wrote:', ...shown, ...faults], next: `al context ${name}`, notKnown };
|
|
126
|
+
}
|
package/src/sections.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Sections and their IDs ([SPC-2]), and when two sections are the same ([SPC-4]).
|
|
2
|
+
|
|
3
|
+
// An ATX heading as in CommonMark: 0-3 spaces, 1-6 `#`, then a space, a tab or
|
|
4
|
+
// the end of the line. Groups: the marks, the gap after them, the heading text.
|
|
5
|
+
const HEADING = /^( {0,3}#{1,6})([ \t]+|$)(.*)$/;
|
|
6
|
+
// The ID grammar ([SPC-2]): a prefix of capitals and digits, starting with a
|
|
7
|
+
// capital, a hyphen, then numbers joined by dots. Every pattern for an ID is
|
|
8
|
+
// built from these.
|
|
9
|
+
export const PREFIX_TEXT = '[A-Z][A-Z0-9]*';
|
|
10
|
+
export const ID_TEXT = `${PREFIX_TEXT}-\\d+(?:\\.\\d+)*`;
|
|
11
|
+
const ID = new RegExp(`^\\[(${ID_TEXT})\\][ \\t]*`);
|
|
12
|
+
const FENCE = /^ {0,3}(`{3,}|~{3,})/;
|
|
13
|
+
|
|
14
|
+
// The titles of the record sections the tool reads and writes: request.md's
|
|
15
|
+
// ([REC-2], [REC-7], [REC-8], [REC-9]) and change.md's ([SPC-5]).
|
|
16
|
+
export const TITLES = { words: "Owner's words and dialog", decisions: 'Decisions', parts: 'Parts', outcome: 'Outcome', specChanges: 'Spec changes' };
|
|
17
|
+
|
|
18
|
+
// The index among `lines` of the line that heads a record's `## <title>`
|
|
19
|
+
// section, or -1: one form for every reader and writer.
|
|
20
|
+
export const headingAt = (lines, title) => lines.findIndex((l) => new RegExp(`^##\\s+${title}\\s*$`).test(l));
|
|
21
|
+
|
|
22
|
+
// Whether `line` closes the fence `fence` opened: 0-3 spaces, then the same
|
|
23
|
+
// mark, at least as long, and nothing else.
|
|
24
|
+
export function closes(line, fence) {
|
|
25
|
+
const f = line.match(FENCE);
|
|
26
|
+
return Boolean(f && f[1][0] === fence[0] && f[1].length >= fence.length && line.trim() === f[1]);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Each line of `text` with its ending, whether it is a heading, and whether it
|
|
30
|
+
// is fenced code (a fence line included): lines in fenced code are not
|
|
31
|
+
// headings, and indented code never matches HEADING.
|
|
32
|
+
function scan(text) {
|
|
33
|
+
const lines = text.split(/(?<=\n)/);
|
|
34
|
+
let fence = null;
|
|
35
|
+
return lines.map((raw) => {
|
|
36
|
+
const line = raw.replace(/\r?\n$/, '');
|
|
37
|
+
const f = line.match(FENCE);
|
|
38
|
+
if (fence) {
|
|
39
|
+
if (closes(line, fence)) fence = null;
|
|
40
|
+
return { raw, heading: null, code: true };
|
|
41
|
+
}
|
|
42
|
+
if (f) {
|
|
43
|
+
fence = f[1];
|
|
44
|
+
return { raw, heading: null, code: true };
|
|
45
|
+
}
|
|
46
|
+
return { raw, heading: line.match(HEADING), code: false };
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// For each line of `text` (split after each newline), whether it is fenced code.
|
|
51
|
+
export const codeLines = (text) => scan(text).map((l) => l.code);
|
|
52
|
+
|
|
53
|
+
// `text` without its fenced code.
|
|
54
|
+
export const prose = (text) => scan(text).filter((l) => !l.code).map((l) => l.raw).join('');
|
|
55
|
+
|
|
56
|
+
// A section is one heading and the text up to the next heading of any level.
|
|
57
|
+
export function parseSections(text) {
|
|
58
|
+
const sections = [];
|
|
59
|
+
scan(text).forEach(({ raw, heading }, i) => {
|
|
60
|
+
if (heading) {
|
|
61
|
+
const content = heading[3].trim();
|
|
62
|
+
const id = content.match(ID);
|
|
63
|
+
sections.push({
|
|
64
|
+
id: id ? id[1] : null,
|
|
65
|
+
level: heading[1].trim().length,
|
|
66
|
+
title: id ? content.slice(id[0].length) : content,
|
|
67
|
+
text: raw,
|
|
68
|
+
line: i + 1,
|
|
69
|
+
});
|
|
70
|
+
} else if (sections.length) {
|
|
71
|
+
sections.at(-1).text += raw;
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
return sections;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// The sections of `files` (each with its `sections`) by ID, each as `pick` gives it (its text
|
|
78
|
+
// by default); of a duplicate ID the first copy counts, as in statesOf and
|
|
79
|
+
// consolidate ([SPC-3]).
|
|
80
|
+
export function sectionsById(files, pick = (s) => s.text) {
|
|
81
|
+
const byId = new Map();
|
|
82
|
+
for (const f of files) for (const s of f.sections) if (s.id && !byId.has(s.id)) byId.set(s.id, pick(s, f));
|
|
83
|
+
return byId;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// The text outside every section with an ID, one piece each: before the
|
|
87
|
+
// first heading, then each section whose heading has none.
|
|
88
|
+
export function unheld(text) {
|
|
89
|
+
const sections = parseSections(text);
|
|
90
|
+
return [text.slice(0, text.length - sections.reduce((n, s) => n + s.text.length, 0)), ...sections.filter((s) => !s.id).map((s) => s.text)];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// `text` with every heading that has no ID given `[<prefix>-n] `, n counting
|
|
94
|
+
// up from `first`; everything else byte for byte. Returns the new text and the
|
|
95
|
+
// headings it numbered.
|
|
96
|
+
export function numberHeadings(text, prefix, first) {
|
|
97
|
+
let n = first;
|
|
98
|
+
const numbered = [];
|
|
99
|
+
const out = scan(text).map(({ raw, heading }) => {
|
|
100
|
+
if (!heading || heading[3].trim().match(ID)) return raw;
|
|
101
|
+
const id = `[${prefix}-${n++}]`;
|
|
102
|
+
const line = heading[3] === '' ? `${heading[1]} ${id}` : `${heading[1]}${heading[2]}${id} ${heading[3]}`;
|
|
103
|
+
numbered.push(line);
|
|
104
|
+
return line + raw.slice(raw.replace(/\r?\n$/, '').length);
|
|
105
|
+
});
|
|
106
|
+
return { text: out.join(''), numbered };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Normalized only as [SPC-4] allows: line endings, trailing spaces and tabs,
|
|
110
|
+
// blank lines at the edges and after the heading, and the heading's `#` count.
|
|
111
|
+
function normalize(text) {
|
|
112
|
+
const lines = text.replace(/\r\n/g, '\n').split('\n').map((l) => l.replace(/[ \t]+$/, ''));
|
|
113
|
+
while (lines.length && lines[0] === '') lines.shift();
|
|
114
|
+
while (lines.length && lines.at(-1) === '') lines.pop();
|
|
115
|
+
const heading = lines.length ? lines[0].match(HEADING) : null;
|
|
116
|
+
if (heading) {
|
|
117
|
+
lines[0] = lines[0].replace(/#+/, '#');
|
|
118
|
+
while (lines.length > 1 && lines[1] === '') lines.splice(1, 1);
|
|
119
|
+
}
|
|
120
|
+
return lines.join('\n');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function sameSection(a, b) {
|
|
124
|
+
return normalize(a) === normalize(b);
|
|
125
|
+
}
|
package/src/signoff.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// The organized section ([REC-4]), its sign-offs ([REC-5]) and the blocked
|
|
2
|
+
// state ([REC-6]). Everything is judged from the text in the files, never from
|
|
3
|
+
// commits, so a squash changes nothing.
|
|
4
|
+
import { codeLines, parseSections, prose, sameSection } from './sections.js';
|
|
5
|
+
import { isSignoff, snapshots } from './snapshot.js';
|
|
6
|
+
import { childrenOf } from './commands.js';
|
|
7
|
+
import { requestDirs } from './tree.js';
|
|
8
|
+
|
|
9
|
+
const ORGANIZED = /^Organized (requirement|question)$/;
|
|
10
|
+
export const SIGNED_OFF = /^Signed off:/;
|
|
11
|
+
|
|
12
|
+
// [REC-4]: only a tier-1 request (`Tier: 1` on its status line) MAY write a
|
|
13
|
+
// requirement as one line, `R1: …`.
|
|
14
|
+
// Whether a tier claim, such as `0 — a fix`, is tier `t`.
|
|
15
|
+
export const tierIs = (claim, t) => new RegExp(`^${t}\\b`).test(claim ?? '');
|
|
16
|
+
|
|
17
|
+
export const tierOne = (md) => tierIs(statusField(md, 'Tier'), '1');
|
|
18
|
+
|
|
19
|
+
// The index of request.md's status line among its `lines`: the first with `Status:`.
|
|
20
|
+
export const statusAt = (lines) => lines.findIndex((l) => /\bStatus:/.test(l));
|
|
21
|
+
|
|
22
|
+
// The value of `key` (Status, Tier, Follows…) on request.md's status line, up
|
|
23
|
+
// to the next `·`, trimmed; undefined when the line has none.
|
|
24
|
+
export const statusField = (md, key) => {
|
|
25
|
+
const lines = md.split('\n');
|
|
26
|
+
return (lines[statusAt(lines)] ?? '').match(new RegExp(`\\b${key}:\\s*([^·]+)`))?.[1].trim();
|
|
27
|
+
};
|
|
28
|
+
export const isDropped = (md) => /^dropped\b/.test(statusField(md, 'Status') ?? '');
|
|
29
|
+
// The request names after `Follows:`.
|
|
30
|
+
export const follows = (md) => statusField(md, 'Follows')?.split(/[\s,]+/).filter(Boolean) ?? [];
|
|
31
|
+
|
|
32
|
+
// The organized section of request.md: from its heading to the next heading of
|
|
33
|
+
// level 1 or 2, without any `Signed off:` line. `start` and `end` are offsets
|
|
34
|
+
// in `md`; `text` is what a sign-off binds; `oneLine`, whether `R1: …` lines
|
|
35
|
+
// are requirements.
|
|
36
|
+
export function organized(md) {
|
|
37
|
+
const sections = parseSections(md);
|
|
38
|
+
let offset = md.length - sections.reduce((n, s) => n + s.text.length, 0);
|
|
39
|
+
let found = null;
|
|
40
|
+
for (const s of sections) {
|
|
41
|
+
if (found && s.level <= 2) break;
|
|
42
|
+
if (!found && s.level <= 2 && ORGANIZED.test(s.title)) found = { start: offset, raw: '' };
|
|
43
|
+
if (found) found.raw += s.text;
|
|
44
|
+
offset += s.text.length;
|
|
45
|
+
}
|
|
46
|
+
if (!found) return null;
|
|
47
|
+
const text = found.raw.split(/(?<=\n)/).filter((l) => !SIGNED_OFF.test(l)).join('');
|
|
48
|
+
return { start: found.start, end: found.start + found.raw.length, raw: found.raw, text, oneLine: tierOne(md) };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// The parts of an organized section: the intro, each `### R<n>` sub-section,
|
|
52
|
+
// and the `Out:` and `Assumed:` paragraphs, and, when `oneLine`, each `R<n>:`
|
|
53
|
+
// line. Parts with no text are left out. A line in fenced code is text of the
|
|
54
|
+
// part it sits in.
|
|
55
|
+
export function parts(text, oneLine = false) {
|
|
56
|
+
const out = [];
|
|
57
|
+
let current = { key: 'intro', text: '' };
|
|
58
|
+
const lines = text.split(/(?<=\n)/).slice(1);
|
|
59
|
+
const code = codeLines(text).slice(1);
|
|
60
|
+
const subheads = new Set(parseSections(text).filter((s) => s.level >= 3).map((s) => s.line - 2));
|
|
61
|
+
lines.forEach((line, i) => {
|
|
62
|
+
let key = null;
|
|
63
|
+
if (subheads.has(i)) key = line.match(/^\s*#+\s+(R\d+)\b/)?.[1] ?? line.replace(/^\s*#+\s+/, '').trim();
|
|
64
|
+
else if (code[i]) key = null;
|
|
65
|
+
else if (/^(Out|Assumed):/.test(line)) key = line.match(/^(Out|Assumed)/)[1];
|
|
66
|
+
// [REC-4]: a tier-1 requirement MAY be one line, `R1: …`, read as an `### R1` sub-section.
|
|
67
|
+
else if (oneLine && /^R\d+:\s/.test(line)) key = line.match(/^(R\d+)/)[1];
|
|
68
|
+
if (key) {
|
|
69
|
+
out.push(current);
|
|
70
|
+
current = { key, text: '' };
|
|
71
|
+
}
|
|
72
|
+
current.text += line;
|
|
73
|
+
});
|
|
74
|
+
out.push(current);
|
|
75
|
+
return out.filter((p) => p.text.trim());
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// [REC-4]: the lines of an organized requirement `org` that say MUST, SHOULD
|
|
79
|
+
// or MAY before its first requirement (in the intro, out of fenced code), so
|
|
80
|
+
// are not read as one.
|
|
81
|
+
export function unlabelled(org) {
|
|
82
|
+
if (!/requirement/.test(org.text.split('\n')[0])) return [];
|
|
83
|
+
const intro = parts(org.text, org.oneLine).find((p) => p.key === 'intro')?.text ?? '';
|
|
84
|
+
return prose(intro).split('\n').filter((l) => /\b(MUST|SHOULD|MAY)\b/.test(l)).map((l) => l.trim());
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// The same part, ignoring the number in `### R<n>` (a child renumbers what it copies).
|
|
88
|
+
const unnumbered = (text) => text.replace(/^(\s*#+\s+)R\d+\b/, '$1R').replace(/^R\d+:/, 'R:');
|
|
89
|
+
export const samePart = (a, b) => sameSection(unnumbered(a.text), unnumbered(b.text));
|
|
90
|
+
|
|
91
|
+
// The sign-off that counts: the one with the latest Fetched among those whose
|
|
92
|
+
// signed text still matches its SHA-256. On a tie, the one the `Signed off:`
|
|
93
|
+
// line names; if it names none of them, `tie` gives the time.
|
|
94
|
+
export function latestSignoff(tree, dir, md) {
|
|
95
|
+
const signoffs = [];
|
|
96
|
+
for (const { file, s } of snapshots(tree, dir)) {
|
|
97
|
+
if (isSignoff(s) && s.intact) signoffs.push({ file, fetched: s.fields.Fetched, text: s.text.toString('utf8'), oneLine: tierOne(md) });
|
|
98
|
+
}
|
|
99
|
+
if (!signoffs.length) return { signoff: null };
|
|
100
|
+
// Fetched is a stamp (STAMP in git.js), so the latest is the greatest string.
|
|
101
|
+
const last = signoffs.reduce((m, s) => (s.fetched > m ? s.fetched : m), '');
|
|
102
|
+
const latest = signoffs.filter((s) => s.fetched === last);
|
|
103
|
+
if (latest.length === 1) return { signoff: latest[0] };
|
|
104
|
+
const line = md.split('\n').find((l) => SIGNED_OFF.test(l)) ?? '';
|
|
105
|
+
// Only the line's first pointer counts; later ones cite earlier sign-offs.
|
|
106
|
+
const named = line.match(/origin\/([^\s,;()]+)/)?.[1];
|
|
107
|
+
const chosen = latest.find((s) => s.file === named);
|
|
108
|
+
return chosen ? { signoff: chosen } : { signoff: null, tie: last };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// The request whose `## Parts` section names `name` as its child ([REC-8]):
|
|
112
|
+
// an open one first, else an archived one, since [REC-5] sets no condition
|
|
113
|
+
// that the parent stays open.
|
|
114
|
+
export function parentOf(tree, name) {
|
|
115
|
+
for (const { name: other, dir } of requestDirs(tree)) {
|
|
116
|
+
if (other === name) continue;
|
|
117
|
+
const md = tree.read(`${dir}/request.md`)?.toString('utf8');
|
|
118
|
+
if (md && childrenOf(md).includes(name)) return { name: other, dir, md };
|
|
119
|
+
}
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Whether a request is blocked, and why ([REC-6]). One function for every
|
|
124
|
+
// command that must know.
|
|
125
|
+
export function signoffState(tree, dir, name) {
|
|
126
|
+
const md = tree.read(`${dir}/request.md`).toString('utf8');
|
|
127
|
+
const org = organized(md);
|
|
128
|
+
if (!org) return { blocked: true, unwritten: true, reason: 'no organized requirement in request.md' };
|
|
129
|
+
const { signoff: own, tie } = latestSignoff(tree, dir, md);
|
|
130
|
+
const parent = parentOf(tree, name);
|
|
131
|
+
const parentSignoff = parent ? latestSignoff(tree, parent.dir, parent.md).signoff : null;
|
|
132
|
+
|
|
133
|
+
if (!parentSignoff) {
|
|
134
|
+
if (!own) {
|
|
135
|
+
const why = tie ? `: two sign-offs at ${tie}; which counts is not known` : '';
|
|
136
|
+
return { blocked: true, reason: `awaiting owner sign-off${why}` };
|
|
137
|
+
}
|
|
138
|
+
if (sameSection(org.text, own.text)) return { blocked: false, signoff: own };
|
|
139
|
+
return { blocked: true, reason: `changed since ${own.file}`, signoff: own, changed: changedParts(org.text, own.text, org.oneLine) };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// A child: every part is in the parent's or its own signed text, and every
|
|
143
|
+
// part it signed itself is still there.
|
|
144
|
+
const now = parts(org.text, org.oneLine);
|
|
145
|
+
const covers = [...parts(parentSignoff.text, parentSignoff.oneLine), ...(own ? parts(own.text, own.oneLine) : [])];
|
|
146
|
+
const uncovered = now.filter((p) => !covers.some((c) => samePart(p, c))).map((p) => p.key);
|
|
147
|
+
const dropped = own ? parts(own.text, own.oneLine).filter((p) => !now.some((c) => samePart(p, c))).map((p) => p.key) : [];
|
|
148
|
+
if (!uncovered.length && !dropped.length) return { blocked: false, signoff: own, parent: parent.name, parentSignoff };
|
|
149
|
+
const changed = [...uncovered, ...dropped];
|
|
150
|
+
if (own) return { blocked: true, reason: `changed since ${own.file}`, signoff: own, parent: parent.name, parentSignoff, changed };
|
|
151
|
+
return { blocked: true, reason: `awaiting owner sign-off (not in ${parent.name}'s signed text)`, parent: parent.name, parentSignoff, changed };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// The step that unblocks request `name` at `dir`, given its signoffState:
|
|
155
|
+
// write the organized section, or, on the owner's OK only, record the sign-off.
|
|
156
|
+
export const signoffStep = (name, dir, state) => (state.unwritten ? `write the organized requirement in ${dir}/request.md, then al context ${name}`
|
|
157
|
+
: `show the owner the organized requirement; on their OK: al record ${name} signoff --source <where> --words <quote> --yes`);
|
|
158
|
+
|
|
159
|
+
// The parts added, changed or removed between the signed text and now.
|
|
160
|
+
export function changedParts(now, signed, oneLine) {
|
|
161
|
+
const a = parts(now, oneLine);
|
|
162
|
+
const b = parts(signed, oneLine);
|
|
163
|
+
const changed = a.filter((p) => !b.some((q) => q.key === p.key && sameSection(q.text, p.text))).map((p) => p.key);
|
|
164
|
+
const removed = b.filter((q) => !a.some((p) => p.key === q.key)).map((q) => q.key);
|
|
165
|
+
return [...changed, ...removed];
|
|
166
|
+
}
|