@assuredloop/cli 0.1.0 → 0.2.1

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/src/adrs.js DELETED
@@ -1,110 +0,0 @@
1
- // Decision records ([LNK-4]): `NNNN-<decision>.md` in docs/adr and each
2
- // `adrs:` folder of .assuredloop, in the kit's format. The tool shows the
3
- // ADRs governing a section, lists those a request added or superseded, and
4
- // flags what breaks the format's rules; it never writes one.
5
- import { git } from './git.js';
6
- import { openTree } from './tree.js';
7
- import { configured, pastLines } from './spec.js';
8
- import { cites, idsOn, paths, requestOf } from './links.js';
9
-
10
- // A file name is bounded (255 bytes), so the name is too: unbounded, a long token takes quadratic time.
11
- const numbers = (s) => [...new Set([...s.matchAll(/\bADR[ \t]+(\d{4})\b|(?<![\w-])(\d{4})-[\w.-]{0,250}\.md\b/g)].map((m) => m[1] ?? m[2]))];
12
- const numberOf = (p) => p.match(/(?:^|\/)(\d{4})-[^/]*\.md$/)?.[1];
13
- // `Status:` anywhere in the header, the lines before the first `##` heading,
14
- // so `Date: … Status: superseded by …` on one line is read. The status ends
15
- // with its sentence, so a field after it on the line is not part of it.
16
- const STATUS = /(?<![\w-])Status:[ \t]*(.*?)(?:\.(?=[ \t]|$)|$)/m;
17
- const statusIn = (text) => { const end = text.search(/^##[ \t]/m); return (end < 0 ? text : text.slice(0, end)).match(STATUS); };
18
-
19
- // A `past` tree's `adrs:` lines are read leniently ([VW-8]).
20
- export const adrFolders = (top, tree, at, past = false) => [...new Set(['docs/adr', ...(past ? pastLines : configured)(top, tree, at, 'adrs')])];
21
-
22
- // Every ADR in `tree`: its path, number, text, status word, and its links.
23
- export function adrsIn(top, tree, at, past = false) {
24
- return adrFolders(top, tree, at, past).flatMap((d) => (tree.list(d) ?? []).filter((f) => numberOf(f)).map((f) => {
25
- const text = tree.read(`${d}/${f}`)?.toString('utf8') ?? '';
26
- const field = (k) => text.match(new RegExp(`^${k}:[ \\t]*(.*)$`, 'm'))?.[1] ?? '';
27
- const m = statusIn(text);
28
- const status = m?.[1] ?? '';
29
- return { path: `${d}/${f}`, n: numberOf(f), text, status: status.match(/^[a-z]+/i)?.[0].toLowerCase() ?? '', hasStatus: Boolean(m),
30
- by: /^superseded/i.test(status) ? numbers(status) : [], supersedes: numbers(field('Supersedes')), hasSupersedes: /^Supersedes:/m.test(text),
31
- request: field('Request').trim(), governs: idsOn(field('Governs')) };
32
- }));
33
- }
34
-
35
- const shown = (a) => `${a.n} ${a.status === 'superseded' ? `superseded by ${a.by.join(', ')}` : a.status || 'no status'}`;
36
-
37
- // The ADRs that govern section `id` (a Governs: line) or name it, current first.
38
- export function governing(top, tree, id, at) {
39
- return adrsIn(top, tree, at).filter((a) => a.governs.includes(id) || cites(a.text, id))
40
- .sort((x, y) => (y.status === 'accepted') - (x.status === 'accepted') || x.n.localeCompare(y.n))
41
- .map((a) => `${shown(a)} (${a.governs.includes(id) ? 'governs' : 'names'} [${id}])`);
42
- }
43
-
44
- // The ADRs request `name` added (its Request: line, or the commit that added
45
- // the file maps to it, [LNK-2]), and the ADRs those supersede.
46
- export function adrsOf(top, tree, at, name, requests) {
47
- const all = adrsIn(top, tree, at);
48
- const addedIn = new Map();
49
- let sha = null;
50
- // -z keeps each path literal: `>sha`, then its paths, each ended by NUL.
51
- for (const l of paths(git(top, ['log', '--diff-filter=A', '--name-only', '-z', '--format=>%H', at ?? 'HEAD', '--', ...adrFolders(top, tree, at)], { allowFail: true }))) {
52
- const p = l.replace(/^\n/, '');
53
- if (p.startsWith('>')) sha = p.slice(1);
54
- else if (!addedIn.has(p)) addedIn.set(p, sha);
55
- }
56
- // A commit that touched several request folders maps to none of them here.
57
- const seen = new Map();
58
- const mapped = (sha) => requestOf(top, sha, requests, seen);
59
- const added = all.filter((a) => a.request === name || (addedIn.has(a.path) && mapped(addedIn.get(a.path)).how !== 'folder, ambiguous' && mapped(addedIn.get(a.path)).names.includes(name)));
60
- return { added, superseded: all.filter((o) => added.some((a) => a.supersedes.includes(o.n) || o.by.includes(a.n))) };
61
- }
62
-
63
- // The ADR hints of branch `b`: not ok for an accepted ADR edited beyond its
64
- // status (base against the final state), a broken or one-way supersede link,
65
- // a reused number; a note for a superseded ADR cited as current.
66
- export function adrHints(top, b) {
67
- const out = [];
68
- const add = (kind, owners, text, command) => out.push({ kind, rank: kind === 'note' ? 14 : 5, owners, text, command });
69
- const all = adrsIn(top, b.tree, b.at);
70
- const byN = new Map();
71
- for (const a of all) byN.set(a.n, [...(byN.get(a.n) ?? []), a]);
72
- // A number's file names over history. A rename that keeps the number
73
- // replaces its name; a renumbering leaves the old number used.
74
- const names = new Map();
75
- const log = paths(git(top, ['log', '--reverse', '-M', '--diff-filter=AR', '--name-status', '-z', '--format=', b.at ?? 'HEAD', '--', ...adrFolders(top, b.tree, b.at)], { allowFail: true }));
76
- for (let i = 0; i < log.length; i += log[i][0] === 'R' ? 3 : 2) {
77
- const [was, p] = log[i][0] === 'R' ? [log[i + 1], log[i + 2]] : [null, log[i + 1]];
78
- if (was && numberOf(was) === numberOf(p)) names.get(numberOf(was))?.delete(was);
79
- if (numberOf(p)) names.set(numberOf(p), (names.get(numberOf(p)) ?? new Set()).add(p));
80
- }
81
- for (const a of all) names.set(a.n, (names.get(a.n) ?? new Set()).add(a.path));
82
- for (const [n, ps] of names) if (ps.size > 1) add('not ok', [], `ADR number ${n} is reused: ${[...ps].join(' and ')}; a number is never reused`, 'git mv the newer one to the next free number, then al check');
83
- for (const a of all) {
84
- // A field the record already has is changed, never added again.
85
- const links = [...a.supersedes.map((n) => [n, 'supersedes', (t) => t.by.includes(a.n),
86
- (t) => (t.hasStatus ? `set its Status: to "superseded by ADR ${a.n}" in` : `add "Status: superseded by ADR ${a.n}" to`)]),
87
- ...a.by.map((n) => [n, 'is superseded by', (t) => t.supersedes.includes(a.n),
88
- (t) => (t.hasSupersedes ? `add ADR ${a.n} to the Supersedes: line of` : `add "Supersedes: ADR ${a.n}" to`)])];
89
- for (const [n, says, back, fix] of links) {
90
- const t = byN.get(n);
91
- if (!t) add('not ok', [], `a broken supersede link: ${a.path} ${says} ADR ${n}, which does not exist`, `correct the link in ${a.path}, then al check`);
92
- else if (!t.some(back)) add('not ok', [], `a one-way supersede link: ${a.path} ${says} ADR ${n}, but ${t[0].path} does not say so back`, `${fix(t[0])} ${t[0].path}, then al check`);
93
- }
94
- }
95
- if (b.base) {
96
- const strip = (t) => { const m = statusIn(t); return m ? t.slice(0, m.index) + t.slice(m.index + m[0].length) : t; };
97
- for (const a of adrsIn(top, openTree(top, b.base), b.base, true).filter((x) => x.status === 'accepted')) {
98
- const now = b.tree.read(a.path)?.toString('utf8');
99
- if (now === undefined || strip(now) !== strip(a.text)) {
100
- add('not ok', [], `${a.path} was accepted at ${b.base.slice(0, 7)} and is ${now === undefined ? 'deleted' : 'edited beyond its Status line'} on this branch; a change is a new ADR that supersedes it`, `git diff ${b.base.slice(0, 7)} -- ${a.path}`);
101
- }
102
- }
103
- }
104
- const old = (text) => numbers(text).filter((n) => byN.get(n)?.some((a) => a.status === 'superseded'));
105
- for (const f of b.files) {
106
- for (const s of f.sections) for (const n of old(s.text)) add('note', [], `${s.id ? `[${s.id}]` : `${f.path}:${s.line}`} cites ADR ${n}, which is superseded`, `al context ${s.id ?? f.path}`);
107
- }
108
- for (const x of b.blocks.values()) if (x.open && x.now) for (const n of old(x.now)) add('note', [x.request], `${x.key} of ${x.request} cites ADR ${n}, which is superseded`, `al context ${x.request}`);
109
- return out;
110
- }
package/src/audit.js DELETED
@@ -1,176 +0,0 @@
1
- // --audit ([VW-7]): the whole trace of a request, a section or a code line,
2
- // nothing capped, every hash re-checked. History is main's first-parent line
3
- // (the --at commit's under --at): what reached main.
4
- import { isAbsolute } from 'node:path';
5
- import { Fail, HISTORY, day, git, isShallow, mainCommit, resolveCommit } from './git.js';
6
- import { openTree, findRequest, noSymlinkOn } from './tree.js';
7
- import { ROOT, rootOf, rootLine, baseline, configured } from './spec.js';
8
- import { allBlocks, stateText, statesOf } from './states.js';
9
- import { ID_TEXT, TITLES, headingAt, sameSection, sectionsById } from './sections.js';
10
- import { isSignoff, snapshots } from './snapshot.js';
11
- import { line, entriesOf, concludedOnMain, concluding } from './commands.js';
12
- import { blame, filesOf, requestCommits, requestOf, requestsIn } from './links.js';
13
- import { testMatcher } from './tests.js';
14
- import { adrsOf } from './adrs.js';
15
- import { organized, parentOf, parts, samePart, signoffState } from './signoff.js';
16
- import { sinceConclusion } from './views.js';
17
-
18
- const ID = new RegExp(`^${ID_TEXT}$`);
19
- const indented = (text) => text.replace(/\n+$/, '').split('\n').map((l) => ` ${l}`);
20
-
21
- export function audit(top, target, at) {
22
- const tree = openTree(top, at);
23
- const tip = at ? resolveCommit(top, at) : mainCommit(top);
24
- const a = { top, tree, at, tip, root: rootOf(top, tree, at), shallow: isShallow(top), requests: requestsIn(tree), seen: new Map() };
25
- a.gap = a.shallow ? HISTORY : !tip ? 'no main to read it along' : null;
26
- const pos = target.match(/^(.+):(\d+)$/);
27
- if (!pos && !ID.test(target) && !findRequest(tree, target)) throw new Fail(`no request named ${target} in the ${tree.label}`, 'al context <name | ID | path:line> --audit');
28
- return {
29
- tree,
30
- body: pos ? fromLine(a, pos[1], Number(pos[2])) : ID.test(target) ? fromSection(a, target) : trace(a, target),
31
- next: 'git show <sha> for any commit named here',
32
- notKnown: ['who spoke (the records name their source)', 'drafts inside a PR: only what reached main is kept'],
33
- };
34
- }
35
-
36
- const commitLine = (a, c) => `${c.sha.slice(0, 7)} ${day(c.when)} ${git(a.top, ['show', '-s', '--format=%s', c.sha])} (${c.names.length ? `${c.names.join(', ')} by ${c.how}` : 'no request'})`;
37
-
38
- // Main's first-parent commits that changed the baseline, oldest first, each
39
- // with its sections by ID.
40
- // Each tree is read with its own root: today's, or any `root:` line
41
- // .assuredloop held, so a moved root keeps its earlier history.
42
- function history(a) {
43
- if (a.walk || a.gap) return a.walk ?? [];
44
- const shas = (args) => git(a.top, args).split('\n').filter(Boolean);
45
- const roots = new Set([a.root, ROOT, ...shas(['log', '--format=%H', a.tip, '--', '.assuredloop']).map((sha) => rootLine(a.top, openTree(a.top, sha), sha))]);
46
- roots.delete(null);
47
- // .assuredloop too: a commit that only switches root: can change a section's text.
48
- a.walk = shas(['log', '--first-parent', '--reverse', '--format=%H', a.tip, '--', '.assuredloop', ...[...roots].map((r) => `${r}/`)]).map((sha) => {
49
- const tree = openTree(a.top, sha);
50
- const root = rootLine(a.top, tree, sha);
51
- return { sha, sections: root ? sectionsById(baseline(tree, root)) : new Map() };
52
- });
53
- return a.walk;
54
- }
55
-
56
- // The whole trace of request `name`.
57
- function trace(a, name) {
58
- const { top, tree } = a;
59
- const dir = findRequest(tree, name);
60
- const md = tree.read(`${dir}/request.md`).toString('utf8');
61
- const body = [line('Audit', `${name} ${(md.match(/^# (.*)$/m)?.[1] ?? name).trim()} (${dir})`),
62
- ...entriesOf(md, TITLES.words).map((e) => line('Words', e))];
63
- // Each snapshot of `from`'s origin/, re-checked; with `signoffsOnly`, its sign-offs and any file that is
64
- // not a valid snapshot, which may have been one. `whose` names another request's.
65
- const recheck = (from, signoffsOnly, whose = '') => {
66
- for (const { file: f, s } of snapshots(tree, from)) {
67
- const ok = !s ? 'not ok: not a valid snapshot' : s.intact ? 'SHA-256 matches' : 'not ok: no longer matches its SHA-256';
68
- if (isSignoff(s)) body.push(line('Sign-off', `${whose}origin/${f} (${s.fields.Fetched}) ${ok}; signed text:`), ...indented(s.text.toString('utf8')));
69
- else if (!signoffsOnly || !s) body.push(line('Snapshot', `${whose}origin/${f} ${s ? `(${s.fields.Source}, fetched ${s.fields.Fetched}) ` : ''}${ok}`));
70
- }
71
- };
72
- recheck(dir, false);
73
- // [REC-5]: a child inherits its parent's latest sign-off for the parts it copies word for word: named only
74
- // when it does. The parent's sign-offs are re-checked here too, as the parent's.
75
- const parent = parentOf(tree, name);
76
- if (parent) {
77
- const s = signoffState(tree, dir, name);
78
- const org = organized(md);
79
- const copied = s.parentSignoff && org ? parts(org.text, org.oneLine)
80
- .filter((p) => parts(s.parentSignoff.text, s.parentSignoff.oneLine).some((q) => samePart(p, q))).map((p) => p.key) : [];
81
- if (copied.length) body.push(line('Sign-off', `through ${parent.name} for ${copied.join(', ')}, copied word for word from its origin/${s.parentSignoff.file}${s.blocked ? `; ${s.reason}` : ''}`));
82
- recheck(parent.dir, true, `parent ${parent.name}: `);
83
- }
84
- body.push(...entriesOf(md, TITLES.decisions).map((e) => line('Decision', e)));
85
- const place = (f) => [`requests/${name}/${f}`, `requests/archive/${name}/${f}`];
86
- const records = [...place('request.md'), ...place('change.md')];
87
- const versions = a.gap ? [] : git(top, ['log', '--first-parent', '--format=%H', a.tip, '--', ...records]).split('\n').filter(Boolean);
88
- body.push(...(a.gap ? [line('Versions', a.gap)] : versions.map((sha) =>
89
- line('Version', `${commitLine(a, requestOf(top, sha, a.requests, a.seen))}: ${filesOf(top, sha).filter((p) => records.includes(p)).join(', ')}`))));
90
- // Each block, with its state and when it reached the baseline on main, read
91
- // only over the request's own life there: its first record to its conclusion.
92
- const blocks = allBlocks(tree);
93
- const order = new Map(a.gap ? [] : git(top, ['rev-list', '--first-parent', '--reverse', a.tip]).split('\n').map((sha, i) => [sha, i]));
94
- const end = concluding(top, name, a.at).sha;
95
- const [from, to] = [order.get(versions.at(-1)) ?? 0, end ? order.get(end) : Infinity];
96
- const walk = history(a).map((c, i, all) => ({ ...c, before: all[i - 1] })).filter((c) => order.get(c.sha) >= from && order.get(c.sha) <= to);
97
- const where = (landed) => a.gap ?? (landed ? `consolidated on main at ${landed.sha.slice(0, 7)}` : 'not consolidated on main');
98
- // An archived request is never re-judged against today's baseline: its sections as at conclusion, or what changed them since ([STA-8]).
99
- const since = dir.startsWith('requests/archive/') ? sinceConclusion(top, name, a.at) : null;
100
- const held = statesOf(baseline(tree, a.root), blocks, (b) => b.request === name);
101
- for (const e of held) {
102
- const b = blocks.get(e.block);
103
- // A removal lands where the section goes from present to absent.
104
- const landed = b.op === 'remove' ? walk.find((c) => !c.sections.has(b.id) && c.before?.sections.has(b.id))
105
- : walk.find((c) => c.sections.has(b.id) && sameSection(c.sections.get(b.id), b.now ?? ''));
106
- body.push(line('Section', `${e.block} ${since ? since.say(e.id) : stateText(e)}; ${where(landed)}`));
107
- }
108
- // A concluded request with no blocks (tier 1): the sections its Outcome lists, each landing where it
109
- // first took the text it had at conclusion, or, removed, where it went from present to absent.
110
- const last = walk.at(-1)?.sections ?? new Map();
111
- for (const id of held.length ? [] : since?.ids ?? []) {
112
- const landed = last.has(id) ? walk.find((c) => c.sections.has(id) && sameSection(c.sections.get(id), last.get(id)))
113
- : walk.find((c) => !c.sections.has(id) && c.before?.sections.has(id));
114
- body.push(line('Section', `[${id}] ${since.say(id)}; ${where(landed)}`));
115
- }
116
- // The linked commits ([LNK-2]) and the test files they changed.
117
- if (a.shallow) body.push(line('Commits', HISTORY));
118
- else {
119
- const rev = a.at ?? 'HEAD';
120
- // Every route [LNK-2] maps by: a Request: line, the folder, an issue number in the owner's words.
121
- const issues = [...new Set(entriesOf(md, TITLES.words).join('\n').match(/#\d+/g) ?? [])];
122
- const mine = requestCommits(top, name, issues, rev, a.requests, a.seen);
123
- body.push(...mine.map((c) => line('Commit', commitLine(a, c))));
124
- const isTest = testMatcher(a.root, configured(top, tree, a.at, 'tests'));
125
- body.push(line('Tests', [...new Set(mine.flatMap((c) => filesOf(top, c.sha)).filter(isTest))].sort().join(' · ') || 'none changed by its commits'));
126
- }
127
- const adrs = adrsOf(top, tree, a.at, name, a.requests);
128
- body.push(line('ADRs', [...adrs.added.map((x) => `${x.path} (added)`), ...adrs.superseded.map((x) => `${x.path} (superseded)`)].join(' · ') || 'none'));
129
- if (dir.startsWith('requests/archive/')) body.push(line('Concluded', concludedOnMain(top, name, a.at)));
130
- const mdLines = md.split('\n');
131
- const head = headingAt(mdLines, TITLES.outcome);
132
- const stop = mdLines.findIndex((l, i) => i > head && /^#{1,2} /.test(l));
133
- const outcome = head < 0 ? '' : mdLines.slice(head + 1, stop < 0 ? mdLines.length : stop).join('\n').trim();
134
- body.push(...(outcome ? [line('Outcome', ''), ...indented(outcome)] : [line('Outcome', 'none yet')]));
135
- return body;
136
- }
137
-
138
- // A section: its text, each main commit that changed it with its request,
139
- // then the trace of every request that shaped it or holds it.
140
- function fromSection(a, id) {
141
- const s = baseline(a.tree, a.root).flatMap((f) => f.sections.map((x) => ({ ...x, path: f.path }))).find((x) => x.id === id);
142
- const names = new Set([...allBlocks(a.tree).values()].filter((b) => b.id === id).map((b) => b.request));
143
- if (!s && !names.size) return [`[${id}] not found: not in the baseline, and no request holds it`];
144
- const body = [line('Audit', s ? `[${id}] ${s.title} in ${s.path}` : `[${id}] not in the baseline`), ...(s ? indented(s.text) : [])];
145
- if (a.gap) body.push(line('Changed', a.gap));
146
- let was;
147
- for (const c of history(a)) {
148
- const now = c.sections.get(id);
149
- if (now === was || (now !== undefined && was !== undefined && sameSection(now, was))) continue;
150
- was = now;
151
- const r = requestOf(a.top, c.sha, a.requests, a.seen);
152
- r.names.forEach((n) => names.add(n));
153
- body.push(line('Changed', `${now === undefined ? 'removed' : 'written'} at ${commitLine(a, r)}`));
154
- }
155
- for (const n of names) if (findRequest(a.tree, n)) body.push('', ...trace(a, n));
156
- return body;
157
- }
158
-
159
- // A code or baseline line: blame, the commit's request, then its trace.
160
- function fromLine(a, path, n) {
161
- // Only a path inside the repo, never through a symlink, as the tool writes ([SPC-1]).
162
- if (isAbsolute(path) || path.split('/').includes('..') || (a.at ? a.tree.linkOn(path) : !noSymlinkOn(a.top, path))) {
163
- throw new Fail(`${path} is not a path inside this repo, or goes through a symlink`, 'al context <path>:<line> --audit, for a repo-relative path');
164
- }
165
- const lines = a.tree.read(path)?.toString('utf8').replace(/\n$/, '').split('\n');
166
- if (!lines || n < 1 || n > lines.length) throw new Fail(`${path}:${n}: no such line in the ${a.tree.label}`, 'al context <path>:<line> --audit, for a line that exists');
167
- const body = [line('Audit', `${path}:${n} ${lines[n - 1].trim()}`)];
168
- if (a.shallow) return [...body, line('Blame', HISTORY)];
169
- const [b] = blame(a.top, a.at ? a.tip : null, path, [[n, n]], { code: !path.startsWith(`${a.root}/`) });
170
- if (!b || /^0+$/.test(b.sha)) return [...body, line('Blame', 'not committed yet: the trail stops here')];
171
- const c = requestOf(a.top, b.sha, a.requests, a.seen);
172
- body.push(line('Blame', commitLine(a, c)));
173
- if (!c.names.length) return [...body, line('Request', `${b.sha.slice(0, 7)} maps to no request: the trail stops here`)];
174
- for (const name of c.names) body.push('', ...(findRequest(a.tree, name) ? trace(a, name) : [line('Request', `${name}: no such request in the ${a.tree.label}`)]));
175
- return body;
176
- }
package/src/check.js DELETED
@@ -1,127 +0,0 @@
1
- // al check [--strict] [--all]: every hint for the branch ([HNT-2]), ranked and
2
- // capped ([HNT-1]); the final state is the working tree, the commits are
3
- // main..HEAD. Among them, the append-only records ([REC-12]), per commit, each
4
- // commit against its first parent: the owner's words and the decisions only
5
- // grow at the end; a file in origin/ never changes or goes; nothing changes
6
- // under a request archived on main; the move to archive/ is not an edit.
7
- // check exits 0, and --strict exits 1 on a not ok that counts ([HNT-3]).
8
- import { HISTORY, cutOffNotes, git, hasCommits, isShallow, mainCommit, mainName, ownCommits, resolveCommit } from './git.js';
9
- import { folderName, openTree } from './tree.js';
10
- import { movesOf, ownFiles } from './links.js';
11
- import { line } from './commands.js';
12
- import { TITLES, headingAt } from './sections.js';
13
- import { hintText, hintsOf, ranked, readBranch } from './hints.js';
14
- import { assertionsChanged, headNote, nearIds, resultLines, testLines } from './tests.js';
15
-
16
- const NOTE = 'append-only is checked per commit over main..HEAD; this protects a PR only when check runs on it';
17
- const APPEND_ONLY = [TITLES.words, TITLES.decisions];
18
-
19
- // Git's path lists, NUL-separated, so every name comes as it is (never quoted).
20
- const paths = (out) => out.split('\0').filter(Boolean);
21
-
22
- // The files of request `name` at `rev`, by their path inside the request folder
23
- // (open or archived), each with its blob and full path.
24
- function requestFiles(top, rev, name) {
25
- const out = git(top, ['ls-tree', '-r', '-z', rev, '--', `requests/${name}/`, `requests/archive/${name}/`]);
26
- const files = new Map();
27
- for (const l of paths(out)) {
28
- const [, sha, path] = l.match(/^\S+ \S+ (\S+)\t([\s\S]*)$/);
29
- files.set(path.replace(/^requests\/(archive\/)?[^/]+\//, ''), { sha, path });
30
- }
31
- return files;
32
- }
33
-
34
- // The entries of a `## <title>` section: the text before the first item, then
35
- // each top-level item with its continuation lines, trailing spaces dropped.
36
- function entries(md, title) {
37
- const lines = md.replace(/\r\n/g, '\n').split('\n');
38
- const at = headingAt(lines, title);
39
- const out = [];
40
- for (const l of at < 0 ? [] : lines.slice(at + 1)) {
41
- if (/^#{1,2}\s/.test(l)) break;
42
- if (!out.length || /^(-|\d+\.)\s/.test(l)) out.push(l.trimEnd());
43
- else out[out.length - 1] += `\n${l.trimEnd()}`;
44
- }
45
- return out.map((e) => e.replace(/\n+$/, '')).filter((e) => e.trim());
46
- }
47
-
48
- function problems(top, parent, commit, name, frozen) {
49
- const before = requestFiles(top, parent, name);
50
- const after = requestFiles(top, commit, name);
51
- const out = [];
52
- const bad = (path, what) => out.push({ kind: 'not ok', rank: 3, owners: [name], text: `${commit.slice(0, 7)} ${path}: ${what} ([REC-12])`, command: `git show ${commit.slice(0, 7)} -- ${path}` });
53
- if (frozen.has(name)) {
54
- const paths = new Set([...before.values(), ...after.values()].map((f) => f.path).filter((p) => p.startsWith('requests/archive/')));
55
- const sha = (files, p) => [...files.values()].find((f) => f.path === p)?.sha;
56
- for (const p of paths) if (sha(before, p) !== sha(after, p)) bad(p, 'changes a request archived on main');
57
- return out;
58
- }
59
- for (const [rel, f] of before) {
60
- if (rel.startsWith('origin/') && after.get(rel)?.sha !== f.sha) {
61
- bad(after.get(rel)?.path ?? f.path, after.has(rel) ? 'edits a file in origin/' : 'deletes a file in origin/');
62
- }
63
- }
64
- const [was, now] = [before.get('request.md'), after.get('request.md')];
65
- if (was && was.sha !== now?.sha) {
66
- const [a, b] = [git(top, ['cat-file', 'blob', was.sha]), now ? git(top, ['cat-file', 'blob', now.sha]) : ''];
67
- for (const title of APPEND_ONLY) {
68
- const [old, grown] = [entries(a, title), entries(b, title)];
69
- if (!old.every((e, i) => e === grown[i])) bad(now?.path ?? was.path, `edits or removes an entry of "## ${title}"`);
70
- }
71
- }
72
- return out;
73
- }
74
-
75
- // The append-only hints of `commits` (main..HEAD), each owned by its request.
76
- export function appendOnly(top, main, commits) {
77
- const frozen = new Set(paths(git(top, ['ls-tree', '-z', '--name-only', `${main}:requests/archive`], { allowFail: true }) ?? ''));
78
- const out = [];
79
- for (const commit of commits) {
80
- const parent = git(top, ['rev-parse', '--verify', '--quiet', `${commit}^1`], { allowFail: true });
81
- if (!parent) continue;
82
- const changed = ownFiles(top, commit).filter((p) => p.startsWith('requests/'));
83
- for (const name of new Set(changed.map(folderName).filter(Boolean))) out.push(...problems(top, parent, commit, name, frozen));
84
- }
85
- return out;
86
- }
87
-
88
- // Under --at X, the final state is X's tree and the commits main..X ([VW-8]).
89
- export function check({ top, opts }) {
90
- const main = mainCommit(top);
91
- const head = opts.at ? resolveCommit(top, opts.at) : 'HEAD';
92
- const to = opts.at ? head.slice(0, 7) : 'HEAD';
93
- const base = main ? git(top, ['merge-base', main, head], { allowFail: true }) : null;
94
- const commits = main ? ownCommits(top, main, head) : [];
95
- const range = `${mainName(top)}..${to}`;
96
- const b = readBranch(top, { base, commits, tree: openTree(top, opts.at), at: opts.at, range });
97
- b.all = opts.all; // the linked-tests note names every test only under --all
98
- // Under --at, main is read only up to X's fork point, so no later commit leaks in.
99
- const list = ranked(hintsOf(top, b, { main: opts.at ? base : main }), b);
100
- const notKnown = [...headNote(top, b), ...(opts.at ? ["main after X's fork point (read only up to it under --at)"] : [])];
101
- if (!hasCommits(top)) notKnown.push('no commits yet, so no history');
102
- if (isShallow(top)) notKnown.push(`${HISTORY}: commits before the shallow boundary`, ...cutOffNotes(top, commits));
103
- const body = [line('Serves', [...[...b.served].filter((n) => !b.archived.has(n)), ...[...b.archived].map((n) => `archives ${n}`)].join(' · ') || 'no request')];
104
- if (b.tier) {
105
- // [REC-11]: the claim with its evidence: the spec edits, the sections near the changed code, the tests whose assertions changed.
106
- // A section moved to another file, its text unchanged, is a move, not an edit ([REC-10]).
107
- const moves = movesOf(b.before, b.files).map((m) => `[${m.id}] ${m.from} → ${m.to}`);
108
- body.push(line('Tier', b.tier), line('Evidence', [...(b.changedIds.length || !moves.length ? [`edits ${b.changedIds.map((i) => `[${i}]`).join(', ') || 'no baseline section'}`] : []),
109
- ...(moves.length ? [`moves ${moves.join(', ')}, text unchanged`] : []),
110
- `near the changed code: ${nearIds(top, b).map((i) => `[${i}]`).join(', ') || 'none'}`,
111
- `tests whose assertions changed: ${assertionsChanged(top, b).join(', ') || 'none'}`].join(' · ')));
112
- }
113
- body.push(...testLines(top, b, false), ...resultLines(top, b));
114
- const cap = opts.all ? list.length : 5;
115
- body.push(...list.slice(0, cap).map(hintText));
116
- if (list.length > cap) body.push(`${list.length - cap} more hidden, --all`);
117
- if (!main) body.push('no main to compare with, so no commits were checked');
118
- body.push(NOTE);
119
- const counting = list.filter((h) => h.counts).length;
120
- return {
121
- tree: { label: `${opts.at ? `commit ${to}` : 'working tree'} and ${commits.length} commit(s) over ${range}` },
122
- body,
123
- exit: opts.strict && counting ? 1 : 0,
124
- next: counting ? 'fix or explain each not ok that counts, then al check --strict' : 'run al check again after each push; it certifies only what it read',
125
- notKnown,
126
- };
127
- }