@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/git.js DELETED
@@ -1,155 +0,0 @@
1
- // Every call to git goes through here. Output is a Buffer unless asked as text.
2
- import { spawnSync } from 'node:child_process';
3
- import { existsSync, readFileSync } from 'node:fs';
4
-
5
- // The tool's own failure or misuse: exit 2 ([HNT-3]), with a Next line.
6
- export class Fail extends Error {
7
- constructor(message, next) {
8
- super(message);
9
- this.next = next;
10
- }
11
- }
12
-
13
- // Once remember() is called (bin/al.js, at the start of a run), the same call in
14
- // the same folder is asked of git once and its answer kept until the process
15
- // ends; nothing outlives the run ([TL-2]). A `worktree` call reads the working
16
- // tree or the index, and is asked every time.
17
- let asked = null;
18
- export const remember = () => { asked = new Map(); };
19
-
20
- export function git(cwd, args, { text = true, allowFail = false, worktree = false, env } = {}) {
21
- const key = asked && !worktree && !env ? JSON.stringify([cwd, ...args]) : null;
22
- const r = asked?.get(key) ?? spawnSync('git', args, { cwd, maxBuffer: 1 << 30, env });
23
- if (key) asked.set(key, r);
24
- if (r.error) throw new Fail(`git could not run: ${r.error.message}`, 'install git and try again');
25
- if (r.status !== 0) {
26
- if (allowFail) return null;
27
- throw new Fail(`git ${args.join(' ')} failed: ${r.stderr.toString().trim()}`);
28
- }
29
- return text ? r.stdout.toString().replace(/\n$/, '') : r.stdout;
30
- }
31
-
32
- // [VW-9]: how the tool says a clone lacks history: `unavailable('single-branch',
33
- // ' without main')` is `history unavailable (single-branch clone without main)`.
34
- export const unavailable = (kind = 'shallow', rest = '') => `history unavailable (${kind} clone${rest})`;
35
- export const HISTORY = unavailable();
36
-
37
- // The failure of a read that needs history this clone lacks, given
38
- // historyGap's answer; `rest` says what is missing.
39
- export const gapFail = (gap, rest) => new Fail(`history unavailable: this clone is ${gap.kind}${rest}`, gap.next);
40
-
41
- export function topLevel(cwd) {
42
- const top = git(cwd, ['rev-parse', '--show-toplevel'], { allowFail: true });
43
- if (top === null) throw new Fail('not inside a git repository', 'cd into the project and run it again');
44
- return top;
45
- }
46
-
47
- // The full commit id for `rev`, or a Fail when it names no commit. A clone that
48
- // lacks history cannot tell a missing commit from a wrong one ([VW-9]).
49
- export function resolveCommit(top, rev) {
50
- const sha = git(top, ['rev-parse', '--verify', '--quiet', '--end-of-options', `${rev}^{commit}`], { allowFail: true });
51
- if (sha) return sha;
52
- const gap = historyGap(top);
53
- if (gap) throw gapFail(gap, `, and ${rev} is not in it`);
54
- throw new Fail(`${rev}: unknown commit`, 'name a commit in this repo');
55
- }
56
-
57
- // How this clone lacks history, and the fetch that fills it; null for a full clone.
58
- export function historyGap(top) {
59
- if (isShallow(top)) return { kind: 'shallow', next: 'git fetch --unshallow, then run it again' };
60
- if (isSingleBranch(top)) {
61
- return { kind: 'single-branch', next: "git fetch origin '+refs/heads/*:refs/remotes/origin/*', then run it again" };
62
- }
63
- return null;
64
- }
65
-
66
- // A commit at the shallow boundary: the clone's shallow file lists it, and its
67
- // object names a parent the clone does not hold, so what it changed cannot be
68
- // read ([VW-9]). A root made by a graft is not one: git reads it as a root.
69
- export function cutOff(top, sha) {
70
- const shallow = git(top, ['rev-parse', '--path-format=absolute', '--git-path', 'shallow']);
71
- return existsSync(shallow) && readFileSync(shallow, 'utf8').split('\n').includes(sha)
72
- && /^parent /m.test(git(top, ['cat-file', 'commit', sha]).split('\n\n')[0]);
73
- }
74
-
75
- // The Not known entries for `commits` at the shallow boundary ([VW-9]).
76
- export const cutOffNotes = (top, commits) => commits.filter((c) => cutOff(top, c))
77
- .map((c) => `${HISTORY}: the files ${c.slice(0, 7)} touched (its parent is past the shallow boundary)`);
78
-
79
- export function isShallow(top) {
80
- return git(top, ['rev-parse', '--is-shallow-repository'], { allowFail: true }) === 'true';
81
- }
82
-
83
- // Cloned with --single-branch: origin fetches named branches, not refs/heads/*.
84
- function isSingleBranch(top) {
85
- const specs = git(top, ['config', '--get-all', 'remote.origin.fetch'], { allowFail: true });
86
- return Boolean(specs) && !specs.includes('*');
87
- }
88
-
89
- // The branch's commits over base..head, oldest first, in one order for every
90
- // reader. A merge among them counts by its own edits only (ownFiles in
91
- // links.js): a merge of main brings main's work, which is not the branch's
92
- // own ([HNT-3]).
93
- export const ownCommits = (top, base, head) => git(top, ['rev-list', '--reverse', '--topo-order', `${base}..${head}`]).split('\n').filter(Boolean);
94
-
95
- // Whether HEAD names a commit: a repo with none yet has no history to read.
96
- export const hasCommits = (top) => git(top, ['rev-parse', '--verify', '--quiet', 'HEAD'], { allowFail: true }) !== null;
97
-
98
- // Main as the tool reads it: origin/main when there is one, else local main.
99
- export function mainCommit(top) {
100
- for (const ref of ['refs/remotes/origin/main', 'refs/heads/main']) {
101
- if (git(top, ['rev-parse', '--verify', '--quiet', ref], { allowFail: true }) !== null) return ref;
102
- }
103
- return null;
104
- }
105
-
106
- // The main the tool reads, by the name a command takes: `origin/main` or `main`.
107
- export const mainName = (top) => mainCommit(top)?.replace(/^refs\/(remotes|heads)\//, '') ?? 'main';
108
-
109
- // The ref the tool reads as main, and the time git last recorded for it
110
- // ([VW-9]): origin/main's latest reflog entry (it moved then, by a fetch, a
111
- // push or the clone), else origin/HEAD's clone entry; never the clock.
112
- // FETCH_HEAD is not used: it keeps lines from earlier fetches under a new
113
- // time, so it cannot say which fetch touched main. `unknown` goes on the Not
114
- // known line.
115
- export function mainRef(top) {
116
- const main = mainCommit(top);
117
- if (!main) {
118
- const gap = historyGap(top);
119
- return { label: 'no main (no origin/main, no main branch)', unknown: gap && unavailable(gap.kind, ' without main') };
120
- }
121
- if (main !== 'refs/remotes/origin/main') return { label: 'local main' };
122
- const [last] = reflogTimes(top, 'refs/remotes/origin/main');
123
- const clone = last ? null : reflogTimes(top, 'refs/remotes/origin/HEAD').find((e) => e.subject.startsWith('clone:'));
124
- const when = (last ?? clone)?.when;
125
- if (!when) return { label: 'origin/main (time unknown)' };
126
- return {
127
- label: `origin/main as of ${stamp(when)}${clone ? ' (clone)' : ''}`,
128
- unknown: `whether origin/main moved after ${stamp(when)}; a fetch that finds nothing new leaves no record`,
129
- };
130
- }
131
-
132
- // A ref's reflog entries, newest first, with their recorded times.
133
- function reflogTimes(top, ref) {
134
- const out = git(top, ['reflog', 'show', '--format=%gd %gs', '--date=unix', ref], { allowFail: true });
135
- if (!out) return [];
136
- return out.split('\n').map((l) => l.match(/@\{(\d+)\} (.*)$/)).filter(Boolean)
137
- .map((m) => ({ when: new Date(Number(m[1]) * 1000), subject: m[2] }));
138
- }
139
-
140
- // A timestamp as the tool writes it: UTC to the minute, `2026-09-23T10:14Z`;
141
- // STAMP is its form, so stamps sort as strings in time order.
142
- export const STAMP = /^\d{4}-\d\d-\d\dT\d\d:\d\dZ$/;
143
- export function stamp(date) {
144
- return date.toISOString().slice(0, 16) + 'Z';
145
- }
146
-
147
- // The day of a date as the tool writes it: UTC, `2026-09-23`.
148
- export const day = (date) => date.toISOString().slice(0, 10);
149
-
150
- // The clock, read only to stamp what the tool writes ([TL-2]).
151
- // SOURCE_DATE_EPOCH (seconds) stands in for it when set.
152
- export function now() {
153
- const epoch = process.env.SOURCE_DATE_EPOCH;
154
- return epoch ? new Date(Number(epoch) * 1000) : new Date();
155
- }
package/src/hints.js DELETED
@@ -1,433 +0,0 @@
1
- // The hints ([HNT-1], [HNT-2]) of a branch and of the requests it reads, each
2
- // with its owners, so `check --strict` counts only what the branch serves or
3
- // archives, or what no request owns ([HNT-3]). One list feeds check, context
4
- // --diff and context <name>.
5
- import { posix } from 'node:path';
6
- import { git } from './git.js';
7
- import { folderOf, isName, openTree } from './tree.js';
8
- import { rootOf, rootLine, baseline, duplicateHint, duplicateIds, idsUsed, inBaseline, nextId, trimBlanks } from './spec.js';
9
- import { allBlocks, anchorFault, blockFault, statesOf } from './states.js';
10
- import { TITLES, sameSection, sectionsById, unheld } from './sections.js';
11
- import { follows, isDropped, organized, parentOf, parts, samePart, signoffState, signoffStep, tierIs, tierOne, unlabelled } from './signoff.js';
12
- import { isSignoff, notSnapshot, snapshots } from './snapshot.js';
13
- import { childrenOf, entriesOf, line, partsOf } from './commands.js';
14
- import { headingFault } from './consolidate.js';
15
- import { amends, baselineLists, judge, noChangeMd, outcomeFacts, ownIds, spikeNotes } from './conclude.js';
16
- import { appendOnly } from './check.js';
17
- import { liveCode } from './views.js';
18
- import { filesOf, idsOn, mayTouch, ownFiles, paths, requestOf, requestsIn, sectionsChanged } from './links.js';
19
- import { testHints } from './tests.js';
20
- import { adrHints, adrsOf } from './adrs.js';
21
-
22
- const BAD = ['differs', 'base revised', 'base dropped', 'broken link'];
23
- // [SPC-4]: two texts the same outside their sections with an ID, piece by piece.
24
- const sameUnheld = (a, b) => { const [x, y] = [unheld(a), unheld(b)]; return x.length === y.length && x.every((t, i) => sameSection(t, y[i])); };
25
-
26
- // What the hints read: the final state (`tree`, the working tree or the tree
27
- // of `at`), the commits base..head, the requests they serve, and what the
28
- // branch changes. `range` names the commits read, as the hints say it.
29
- export function readBranch(top, { base, commits, tree, at, range }) {
30
- const root = rootOf(top, tree, at);
31
- const files = baseline(tree, root);
32
- // The fork's tree is a past tree: its root: line alone, or today's root ([VW-8]).
33
- const before = base ? baseline(openTree(top, base), rootLine(top, openTree(top, base), base) ?? root) : files;
34
- const [was, now] = [sectionsById(before), sectionsById(files)];
35
- const changedIds = [...new Set([...was.keys(), ...now.keys()])].filter((id) => !(was.has(id) && now.has(id) && sameSection(was.get(id), now.get(id))));
36
- // The working tree's final state includes its untracked files; a commit's is only its tree.
37
- const changed = base ? [...paths(git(top, ['diff', '--name-only', '-z', '--no-renames', base, ...(at ? [at] : [])], { worktree: !at })),
38
- ...(at ? [] : paths(git(top, ['ls-files', '--others', '--exclude-standard', '-z'], { worktree: true })))] : [];
39
- const requests = requestsIn(tree);
40
- const seen = new Map();
41
- const mapped = commits.map((sha) => requestOf(top, sha, requests, seen, ownFiles));
42
- const served = new Set([...mapped.flatMap((c) => c.names), ...changed.map(folderOf).filter(Boolean)]);
43
- const archived = new Set(base ? requests.filter((r) => !r.open && git(top, ['cat-file', '-e', `${base}:${r.dir}/request.md`], { allowFail: true }) === null).map((r) => r.name) : []);
44
- // Work is anything a request's commits touch beyond its own request.md and origin/.
45
- // A merge on main (earlyWork) counts by all it brings: `files` is filesOf there.
46
- const work = (name, sha, skip = () => false, files = ownFiles) => mayTouch(top, sha, base, files).some((f) => !new RegExp(`^requests/(?:archive/)?${name}/(?:request\\.md$|origin/)`).test(f) && !skip(f));
47
- const delivered = new Set(mapped.flatMap((c) => c.names.filter((n) => work(n, c.sha))));
48
- for (const p of changed) if (/^requests\/(?:archive\/)?[^/]+\/change\.md$/.test(p) && folderOf(p)) delivered.add(folderOf(p));
49
- // Captured whole and trimmed in JS: a regex that trims a long line's spaces takes quadratic time.
50
- const tiers = commits.map((sha) => [...git(top, ['show', '-s', '--format=%B', sha]).matchAll(/^[ \t]*Tier:(.*)$/gm)].map((m) => trimBlanks(m[1])).filter(Boolean).at(-1));
51
- const tier = tiers.filter(Boolean).at(-1) ?? null;
52
- return { root, files, before, was, now, changedIds, changed, requests, blocks: allBlocks(tree), seen, mapped, served, archived, delivered, work, tier, base, commits, tree, at, range };
53
- }
54
-
55
- // [REC-10]: whether baseline files `after` differ from `before` only by IDs
56
- // added to headings that had none and by sections moved with their text
57
- // unchanged ([SPC-4]): each section after matches one before, its ID kept or
58
- // newly given, and each file's text before its first heading is the same as
59
- // that file's before ([SPC-4]); a file added or removed has none. `causes`
60
- // names, beyond sections with an ID, what does more ([HNT-1]).
61
- function idsOrMoves(before, after) {
62
- const intros = (fs) => new Map(fs.map((f) => [f.path, f.text.slice(0, f.text.length - f.sections.reduce((n, s) => n + s.text.length, 0))]));
63
- const [was, now] = [intros(before), intros(after)];
64
- const causes = [...new Set([...was.keys(), ...now.keys()])].filter((p) => !sameSection(was.get(p) ?? '', now.get(p) ?? '')).map((p) => `${p}, its intro text changed`);
65
- const plain = (s) => s.text.replace(/^[^\n]*/, () => `${'#'.repeat(s.level)} ${s.title}`);
66
- const heading = (s) => s.text.replace(/\r?\n[\s\S]*$/, '').trim();
67
- const left = before.flatMap((f) => f.sections.map((s) => ({ ...s, path: f.path })));
68
- const right = after.flatMap((f) => f.sections.map((s) => ({ ...s, path: f.path })));
69
- const extra = right.filter((s) => {
70
- const same = (x) => sameSection(plain(x), plain(s));
71
- const i = [left.findIndex((x) => x.id === s.id && same(x)), left.findIndex((x) => x.id === null && same(x))].find((k) => k >= 0) ?? -1;
72
- if (i >= 0) left.splice(i, 1);
73
- return i < 0;
74
- });
75
- const ok = !causes.length && !extra.length && !left.length;
76
- for (const s of extra.filter((x) => x.id === null)) {
77
- const i = left.findIndex((x) => x.id === null && x.path === s.path && x.title === s.title);
78
- if (i >= 0) left.splice(i, 1);
79
- causes.push(`${s.path}, ${i >= 0 ? `the text under ${heading(s)} changed` : `a new heading without an ID: ${heading(s)}`}`);
80
- }
81
- for (const x of left.filter((y) => y.id === null)) causes.push(`${x.path}, a heading without an ID removed: ${heading(x)}`);
82
- return { ok, causes };
83
- }
84
-
85
- // Every hint for branch `b` ([HNT-2]), unranked: { kind, rank (HNT-2's list
86
- // order), owners, text, command, counts? }.
87
- export function hintsOf(top, b, { main }) {
88
- const out = [];
89
- const add = (kind, rank, owners, text, command, counts) => out.push({ kind, rank, owners, text, command, counts });
90
- const open = b.requests.filter((r) => r.open);
91
- const names = new Set(b.requests.map((r) => r.name));
92
- const known = (id) => b.now.has(id) || [...b.blocks.values()].some((x) => x.id === id);
93
- const signed = new Map(open.map((r) => [r.name, signoffState(b.tree, r.dir, r.name)]));
94
- const step = (name) => signoffStep(name, open.find((r) => r.name === name).dir, signed.get(name));
95
-
96
- // A held section in a state to fix; two open holders not linked.
97
- const states = statesOf(b.files, b.blocks);
98
- for (const e of states) {
99
- if (BAD.includes(e.state) || e.state === 'not found') {
100
- add('not ok', e.state === 'not found' ? 6 : 1, [e.request], `[${e.id}] held by ${e.request} reads ${e.state}${e.candidates.length ? `; candidates ${e.candidates.join(', ')}` : ''}`, e.state === 'differs' ? `al record ${e.request} section ${e.id} --accept` : `al context ${e.request}`);
101
- }
102
- }
103
- const reached = (x) => {
104
- const out = [];
105
- for (let cur = x, n = 0; cur?.base && n < 100; cur = b.blocks.get(cur.base), n++) out.push(cur.base.split('/')[0]);
106
- return out;
107
- };
108
- const holders = new Map();
109
- for (const x of b.blocks.values()) if (x.open && !x.dropped && !x.requestDropped) holders.set(x.id, [...(holders.get(x.id) ?? []), x]);
110
- for (const [id, xs] of holders) {
111
- const reqs = [...new Set(xs.map((x) => x.request))];
112
- const reaches = (p, q) => xs.some((x) => x.request === p && reached(x).includes(q));
113
- const loose = reqs.filter((p) => reqs.some((q) => q !== p && !reaches(p, q) && !reaches(q, p)));
114
- if (!loose.length) continue;
115
- // The fix: a tip (no holder builds on it) and a holder unlinked from it whose
116
- // own base the tip already reaches; that one's first block builds on the
117
- // tip's latest. The tip does not reach it, so no cycle, and what it reached
118
- // the tip reaches, so no holder is left loose by it.
119
- const unlinked = (p, q) => p !== q && !reaches(p, q) && !reaches(q, p);
120
- const of = (q) => xs.filter((x) => x.request === q).sort((x, y) => x.n - y.n);
121
- const below = (q) => reached(of(q)[0]).find((r) => r !== q);
122
- // A broken chain can leave no tip; then any unlinked pair.
123
- const tip = loose.find((p) => !reqs.some((q) => q !== p && reaches(q, p))) ?? loose[0];
124
- const onto = reqs.find((q) => unlinked(tip, q) && (!below(q) || reaches(tip, below(q)))) ?? reqs.find((q) => unlinked(tip, q));
125
- const [first, last] = [of(onto)[0], of(tip).at(-1)];
126
- const dir = b.requests.find((r) => r.name === onto).dir;
127
- add('not ok', 2, loose, `[${id}] held by ${loose.join(' and ')}, neither builds on the other`,
128
- `in ${dir}/change.md, set "builds on ${last.key}" in the heading of ${first.key} (in place of any builds on there) and set its Was to the Now of ${last.key}, then al check`);
129
- }
130
- if (main) out.push(...appendOnly(top, main, b.commits));
131
-
132
- // The baseline: conflict markers, a Was:/Now: block, duplicate IDs; no request owns these.
133
- for (const f of b.files) {
134
- const ls = f.text.split('\n');
135
- const marker = ls.findIndex((l) => /^(<{7}|={7}|>{7})( |$)/.test(l));
136
- if (marker >= 0) add('not ok', 4, [], `a conflict marker in ${f.path}:${marker + 1}`, 'resolve it, then al check');
137
- const block = ls.findIndex((l) => /^(Was|Now):\s*$/.test(l));
138
- if (block >= 0) add('not ok', 4, [], `a Was:/Now: block in ${f.path}:${block + 1}; the baseline holds promises, not changes`, 'move it into a change.md, then al check');
139
- }
140
- out.push(...duplicateIds(b.files).map(duplicateHint));
141
- // [REC-1]: a request folder whose name is not a request name, or a name both open and archived; no request owns these.
142
- const held = (at) => (b.tree.list(at) ?? []).filter((n) => n !== 'archive' && b.tree.read(`${at}/${n}/request.md`) !== null);
143
- const [openDirs, archivedDirs] = [held('requests'), held('requests/archive')];
144
- for (const [at, n] of [...openDirs.map((n) => ['requests', n]), ...archivedDirs.map((n) => ['requests/archive', n])].filter(([, n]) => !isName(n))) {
145
- add('not ok', 5, [], `${at}/${n} is not a request name (lowercase letters, digits and hyphens), so no command reads it`, `git mv ${at}/${n} ${at}/<name>, then al check`);
146
- }
147
- for (const n of openDirs.filter((x) => isName(x) && archivedDirs.includes(x))) {
148
- add('not ok', 5, [], `${n} is in both requests/${n} and requests/archive/${n}; only the open one is read`, `git mv requests/${n} requests/<new name>, then al check`);
149
- }
150
-
151
- // An open request citing what does not exist, or a block that breaks [SPC-5].
152
- for (const r of open) {
153
- const org = organized(r.md);
154
- const rKeys = org ? parts(org.text, org.oneLine).map((p) => p.key) : null;
155
- for (const x of [...b.blocks.values()].filter((y) => y.request === r.name)) {
156
- const unreadable = blockFault(x);
157
- if (unreadable) add('not ok', 5, [r.name], unreadable, `fix the block in ${r.dir}/change.md, then al check`);
158
- if (x.op === 'add' && x.path && !inBaseline(x.path, b.root)) add('not ok', 5, [r.name], `${x.key}: add in ${x.path} is not a .md file under the baseline root ${b.root}/ ([SPC-5])`, `fix the block in ${r.dir}/change.md, then al check`);
159
- // [STA-4]: a remove's anchor is where its revert puts it back.
160
- const file = x.op === 'remove' && x.anchor && !x.dropped && b.files.find((f) => f.sections.some((s) => s.id === x.id));
161
- const misplaced = file && anchorFault(x, file);
162
- if (misplaced) add('not ok', 5, [r.name], `${x.key}: ${misplaced}`, `fix the block in ${r.dir}/change.md, then al check`);
163
- if (x.anchor && !known(x.anchor)) add('not ok', 5, [r.name], `${r.name} cites [${x.anchor}] as the anchor of ${x.key}, which is in no section or block`, `al context ${r.name}`);
164
- // D6: a block marked Dropped (and not Kept) records the drop; its R-lines are not checked.
165
- for (const k of rKeys && !(x.dropped && !x.kept) ? x.forR.filter((y) => !rKeys.includes(y)) : []) add('not ok', 5, [r.name], `${r.name} cites ${k} in ${x.key}, which its organized requirement lacks`, `al context ${r.name}`);
166
- for (const [text, side] of [[x.now, 'Now'], [x.was, 'Was']]) {
167
- const fault = text !== null && headingFault(x, text, side);
168
- if (fault) add('not ok', 5, [r.name], fault, `al context ${r.name}`);
169
- }
170
- }
171
- for (const l of org ? unlabelled(org) : []) {
172
- add('note', 24, [r.name], `${r.name}'s organized requirement says "${l.length > 60 ? `${l.slice(0, 59)}…` : l}" before any requirement, so it is not read as a requirement ([REC-4])`,
173
- `make it ${org.oneLine ? 'an R<n>: line or ' : ''}an ### R<n> sub-section in ${r.dir}/request.md, then al context ${r.name}`);
174
- }
175
- // An Amends: names a section in the baseline, at the fork or now, or in a block.
176
- for (const id of new Set(org ? amends(org.text).map((x) => x.slice(1, -1)) : [])) {
177
- if (!known(id) && !b.was.has(id)) add('not ok', 5, [r.name], `${r.name} cites [${id}] in its Amends:, which is in no section or block`, `al context ${r.name}`);
178
- }
179
- for (const e of entriesOf(r.md, TITLES.decisions)) {
180
- for (const id of idsOn(e)) if (!known(id)) add('not ok', 5, [r.name], `${r.name} cites [${id}] in ${e.match(/^- (D\d+)/)?.[1] ?? 'a decision'}, which is in no section or block`, `al context ${r.name}`);
181
- }
182
- for (const [n, where] of [...follows(r.md).map((n) => [n, 'Follows']), ...childrenOf(r.md).map((n) => [n, TITLES.parts])]) {
183
- if (!names.has(n)) add('not ok', 5, [r.name], `${r.name} cites request ${n} (${where}), which does not exist`, `al context ${r.name}`);
184
- }
185
- }
186
-
187
- // [SPC-3]: a pending add whose ID was used before: in the baseline at some
188
- // commit, or in the change.md of a request that is not open.
189
- const adds = states.filter((e) => e.op === 'add' && e.state === 'pending');
190
- const used = adds.length ? idsUsed(top, b.tree, b.root, b.at ?? '--all') : null;
191
- for (const e of adds) {
192
- const other = (path) => path.match(/^requests\/(?:archive\/)?([^/]+)\/change\.md$/)?.[1];
193
- const where = [...(used.get(e.id) ?? [])].find((path) => inBaseline(path, b.root) || (other(path) && other(path) !== e.request && !open.some((r) => r.name === other(path))));
194
- const prefix = e.id.split('-')[0];
195
- if (where) add('not ok', 5, [e.request], `${e.block} adds [${e.id}], which was used before, in ${where}; an ID is never reused ([SPC-3])`,
196
- `give it the next free ${prefix} ID, [${nextId(used, prefix)}], in ${open.find((r) => r.name === e.request).dir}/change.md, then al check`);
197
- }
198
-
199
- // [STA-8]: conclude's rules re-run on each request the branch archives.
200
- const facts = new Map();
201
- for (const name of b.archived) {
202
- const r = b.requests.find((x) => x.name === name);
203
- const j = judge(top, b.tree, name, r.dir, isDropped(r.md), b.at, b.base);
204
- facts.set(name, { r, j });
205
- if (j.refusals.length) add('not ok', 7, [name], `${name} is archived on this branch but no longer meets conclude's rules: ${j.refusals.join('; ')}`, `al conclude ${name}`);
206
- }
207
-
208
- // [REC-6]: a blocked request's Now written into the baseline, or its work delivered; each counts.
209
- for (const id of b.changedIds) {
210
- for (const x of b.blocks.values()) {
211
- if (x.open && x.id === id && signed.get(x.request)?.blocked && x.now && b.now.has(id) && sameSection(b.now.get(id), x.now)) {
212
- add('not ok', 8, [x.request], `[${id}] changed on this branch equals the Now of ${x.key}, and ${x.request} is blocked (${signed.get(x.request).reason})`, step(x.request), true);
213
- }
214
- }
215
- }
216
- // [REC-6]: work for a blocked request counts only when the branch's final state leaves something
217
- // applied, each part touched by its own work and still changed by the branch: a file outside its
218
- // records and the baseline's .md files; a baseline section, unless every block of it retains nothing
219
- // ([STA-3]), as after a revert; baseline text outside any section with an ID; its change.md, unless
220
- // every block is Dropped, not Kept, and retains nothing.
221
- const uncommitted = b.at ? [] : [...paths(git(top, ['diff', '--name-only', '-z', 'HEAD'], { allowFail: true, worktree: true }) ?? ''), ...paths(git(top, ['ls-files', '--others', '--exclude-standard', '-z'], { worktree: true }))];
222
- const textAt = (rev, p) => (rev ? git(top, ['show', `${rev}:${p}`], { allowFail: true }) : b.tree.read(p)?.toString('utf8')) ?? '';
223
- const applied = (name) => {
224
- const touched = (p) => b.mapped.some((c) => c.names.includes(name) && mayTouch(top, c.sha, b.base).includes(p)) || (b.served.has(name) && uncommitted.includes(p));
225
- const es = statesOf(b.files, b.blocks, (x) => x.request === name);
226
- const change = new RegExp(`^requests/(?:archive/)?${name}/change\\.md$`);
227
- const left = (id) => !es.some((e) => e.id === id) || es.some((e) => e.id === id && !e.retainsNothing);
228
- const withdrawn = es.length > 0 && es.every((e) => b.blocks.get(e.block).dropped && !b.blocks.get(e.block).kept && e.retainsNothing);
229
- return b.mapped.some((c) => c.names.includes(name) && b.work(name, c.sha, (f) => !b.changed.includes(f) || (f.startsWith(`${b.root}/`) && f.endsWith('.md')) || change.test(f)))
230
- || [...ownIds(top, name, b.base, b.root, b.requests, !b.at && b.served.has(name), b.at ?? 'HEAD')].some((id) => b.changedIds.includes(id) && left(id))
231
- || b.changed.some((p) => p.startsWith(`${b.root}/`) && p.endsWith('.md') && touched(p) && !sameUnheld(textAt(b.base, p), textAt(null, p)))
232
- || (!withdrawn && b.changed.some((p) => change.test(p)));
233
- };
234
- for (const name of b.delivered) {
235
- if (signed.get(name)?.blocked && applied(name)) add('not ok', 9, [name], `this branch delivers work for ${name}, which is blocked (${signed.get(name).reason})`, step(name), true);
236
- }
237
- // [REC-10]: tier 0 and S change no promise; a file under the root that is not .md is outside the baseline ([SPC-1]).
238
- const edits = b.changed.filter((p) => p.startsWith(`${b.root}/`) && p.endsWith('.md'));
239
- const promiseless = ['0', 'S'].find((t) => tierIs(b.tier, t));
240
- const moves = promiseless && edits.length ? idsOrMoves(b.before, b.files) : { ok: true };
241
- if (!moves.ok) {
242
- const causes = [...(b.changedIds.length ? [b.changedIds.map((i) => `[${i}]`).join(', ')] : []), ...moves.causes];
243
- add('not ok', 10, [], `the claim is tier ${promiseless}, but ${b.range} edits the baseline${causes.length ? `: ${causes.join('; ')}` : ''}`, `al context --diff ${b.range}`);
244
- }
245
-
246
- // Snapshots: a text that no longer matches its hash; a served request's web source not re-checked.
247
- for (const r of b.requests) {
248
- for (const { file: f, s } of snapshots(b.tree, r.dir)) {
249
- if (!s) add('not ok', 11, [r.name], notSnapshot(`origin/${f} of ${r.name}`), `al record ${r.name} origin --url <source> --from -`);
250
- else if (!s.intact) add('not ok', 11, [r.name], `origin/${f} of ${r.name} no longer matches its SHA-256`, `al record ${r.name} origin --verify ${f} --from -`);
251
- else if (b.served.has(r.name) && /^https?:\/\//.test(s.fields.Source)) {
252
- add('note', 19, [r.name], `origin/${f} of ${r.name}, fetched ${s.fields.Fetched}; no re-check recorded (an unchanged --verify writes nothing)`, `al record ${r.name} origin --verify ${f} --from -`);
253
- }
254
- }
255
- }
256
-
257
- // Notes about the branch's own changes.
258
- const ours = (n) => b.served.has(n) || b.archived.has(n);
259
- for (const id of b.changedIds) {
260
- const others = [...new Set([...b.blocks.values()].filter((x) => x.open && x.id === id && !ours(x.request)).map((x) => x.request))];
261
- if (others.length) add('note', 12, [], `${b.range} edits [${id}], which ${others.join(' and ')} ${others.length > 1 ? 'hold' : 'holds'}: a hotfix, which the holder re-bases onto or accepts`, `al context ${others[0]}`);
262
- }
263
- const mainSha = main && git(top, ['rev-parse', '--verify', '--quiet', main], { allowFail: true });
264
- if (mainSha && b.base) {
265
- const moved = sectionsChanged(top, b.base, mainSha, b.root, paths(git(top, ['diff', '--name-only', '-z', '--no-renames', b.base, mainSha])));
266
- for (const id of b.changedIds.filter((i) => moved.includes(i))) add('note', 13, [], `[${id}], edited on this branch, changed on main since the fork`, `git merge ${main.replace(/^refs\/(remotes|heads)\//, '')}`);
267
- }
268
- for (const c of b.mapped.filter((x) => !x.names.length)) {
269
- const fs = (ownFiles(top, c.sha) ?? []).filter((p) => p.startsWith(`${b.root}/`));
270
- if (fs.length) add('note', 14, [], `${c.sha.slice(0, 7)} changes ${fs.join(', ')} with no request linked (fine for tier 0; say why)`, `git show ${c.sha.slice(0, 7)}`);
271
- }
272
- for (const name of new Set([...b.served, ...b.archived])) {
273
- const r = b.requests.find((x) => x.name === name);
274
- const ids = [...b.blocks.values()].filter((x) => x.request === name && x.dropped && !x.kept).map((x) => x.id);
275
- if (!r || !(isDropped(r.md) || ids.length)) continue;
276
- const live = liveCode(top, name, b.root, isDropped(r.md) ? null : ids, b.at);
277
- if (!live?.length) continue;
278
- const plans = [r, ...b.requests.filter((p) => childrenOf(p.md).includes(name))].flatMap((p) => partsOf(p.md).map((e) => [p.name, e]))
279
- .find(([, e]) => live.some((l) => e.includes(l.split(':')[0])));
280
- // Ranked first among the notes: live code for dropped work is a hazard on main.
281
- const files = [...new Set(live.map((l) => l.split(':')[0]))];
282
- const shown = files.map((f) => [live.filter((l) => l.startsWith(`${f}:`)).join(', '), ...importers(top, b.at ?? 'HEAD', f)].join(', '));
283
- add('note', 11, [name], `code still live for dropped work of ${name}: ${shown.join(', ')}; ${plans ? `a part of ${plans[0]} plans its removal: ${plans[1]}` : 'no part plans its removal'}`, `al context ${name}`);
284
- }
285
- for (const name of b.served) {
286
- const s = signed.get(name);
287
- // A blocked not ok already says it ([HNT-1]: one hint, not two).
288
- if (!s?.blocked || out.some((h) => h.kind === 'not ok' && [8, 9].includes(h.rank) && h.owners.includes(name))) continue;
289
- add('note', 18, [name], s.signoff ? `${name} changed since its sign-off (${s.signoff.file})${s.changed?.length ? `: ${s.changed.join(', ')}` : ''}` : `${name} is not signed off yet: ${s.reason}`,
290
- step(name));
291
- }
292
- // [REC-10]: a spike open, or archived on this branch, with no findings.md, its Answer not first, or a baseline edit:
293
- // by its own commits, and, in the working tree, by uncommitted edits when the branch serves it.
294
- for (const r of [...open, ...[...facts.values()].map((f) => f.r)]) {
295
- const edited = () => {
296
- const own = ownIds(top, r.name, b.base, b.root, b.requests, !b.at && b.served.has(r.name), b.at ?? 'HEAD');
297
- return b.changedIds.filter((id) => own.has(id));
298
- };
299
- for (const n of spikeNotes(b.tree, r.name, r.dir, r.md, edited)) add('note', 24, [r.name], n, n.includes('findings.md') ? `write ${r.dir}/findings.md, starting with its Answer, then al context ${r.name}` : `al context --diff ${b.range}`);
300
- }
301
- if (b.commits.length && !b.tier) add('note', 22, [], `no Tier line in ${b.range}`, 'add "Tier: <n> — <claim>" to a commit message, e.g. with git commit --amend');
302
- out.push(...testHints(top, b), ...adrHints(top, b));
303
- if (mainSha) out.push(...earlyWork(top, b, mainSha));
304
- for (const [name, { r, j }] of facts) {
305
- // A record with no change.md should name what it changes with Amends: ([REC-9]).
306
- if (noChangeMd(b.tree, r.dir)) {
307
- const l = baselineLists(b.was, b.now, j.org, ownIds(top, name, b.base, b.root, b.requests, false, b.at ?? 'HEAD'));
308
- for (const id of l.unnamed) add('note', 24, [name], `${name} changes [${id}], which its Amends: does not name`, `al context ${name}`);
309
- for (const id of l.unchanged) add('note', 24, [name], `${name}'s Amends: names [${id}], which did not change`, `al context ${name}`);
310
- }
311
- for (const x of outcomeFacts(j.org, j.fates).rs.filter((y) => !y.ids.length)) add('note', 24, [name], `${name} ${x.key}${x.title ? ` ${x.title}` : ''} is in no section at conclusion`, `al context ${name}`);
312
- for (const c of childrenOf(r.md).filter((n) => open.some((o) => o.name === n))) add('note', 25, [name], `${name} concludes while its child ${c} is still open`, `al context ${c}`);
313
- for (const a of adrsOf(top, b.tree, b.at, name, b.requests).added.filter((x) => x.status === 'proposed')) add('note', 24, [name], `${name} concludes with ${a.path} still proposed`, 'set its Status line, then al check');
314
- }
315
- return out;
316
- }
317
-
318
- // Work for a served request that reached main before its first sign-off,
319
- // judged by when each reached main: along main's first-parent line, where a
320
- // merge brings itself and its branch's commits all at once, after the first main-line
321
- // commit that holds the sign-off.
322
- function earlyWork(top, b, mainSha) {
323
- const out = [];
324
- let line = null;
325
- for (const name of b.served) {
326
- const r = b.requests.find((x) => x.name === name);
327
- if (!r) continue;
328
- // Its own sign-offs, and a parent's where it inherits from them: those
329
- // holding one of its parts word for word ([REC-5]).
330
- const org = organized(r.md);
331
- const mine = parts(org?.text ?? '', org?.oneLine);
332
- const signoffsOf = (n, dir, keep = () => true) => snapshots(b.tree, dir)
333
- .filter(({ s }) => isSignoff(s) && keep(s))
334
- .flatMap(({ file: f }) => [`requests/${n}/origin/${f}`, `requests/archive/${n}/origin/${f}`]);
335
- const parent = parentOf(b.tree, name);
336
- const copied = (s) => parts(s.text.toString('utf8'), tierOne(parent.md)).some((q) => mine.some((p) => samePart(p, q)));
337
- const places = [...signoffsOf(name, r.dir), ...(parent ? signoffsOf(parent.name, parent.dir, copied) : [])];
338
- const first = places.length ? git(top, ['log', '--reverse', '--diff-filter=A', '--format=%H', mainSha, '--', ...places]).split('\n')[0] : '';
339
- line ??= git(top, ['rev-list', '--first-parent', mainSha]).split('\n').filter(Boolean).map((c) => ({
340
- c, brought: git(top, ['rev-parse', '--verify', '--quiet', `${c}^2`], { allowFail: true }) ? [c, ...git(top, ['rev-list', `${c}^1..${c}^2`]).split('\n').filter(Boolean)] : [c],
341
- }));
342
- for (const { c, brought } of line) {
343
- if (first && git(top, ['merge-base', '--is-ancestor', first, c], { allowFail: true }) !== null) continue;
344
- for (const sha of brought.filter((s) => requestOf(top, s, b.requests, b.seen).names.includes(name) && b.work(name, s, undefined, filesOf))) {
345
- out.push({ kind: 'note', rank: 23, owners: [name], text: `${sha.slice(0, 7)}, work for ${name}, reached main before its first sign-off`, command: `git show ${sha.slice(0, 7)}` });
346
- }
347
- }
348
- }
349
- return out;
350
- }
351
-
352
- // Ranked ([HNT-1]): not ok before note; those that count before information;
353
- // then [HNT-2]'s order. A not ok counts when the branch serves or archives an
354
- // owner, or when no request owns it ([HNT-3]).
355
- export function ranked(list, b) {
356
- for (const h of list) h.counts = h.kind === 'not ok' && Boolean(h.counts || !h.owners.length || h.owners.some((n) => b.served.has(n) || b.archived.has(n)));
357
- return list.map((h, i) => ({ h, i }))
358
- .sort((x, y) => (x.h.kind === y.h.kind ? 0 : x.h.kind === 'not ok' ? -1 : 1) || (y.h.counts - x.h.counts) || (x.h.rank - y.h.rank) || (x.i - y.i))
359
- .map((x) => x.h);
360
- }
361
-
362
- // A hint as one line; a not ok that does not count is labelled information,
363
- // except in the view of request `self`, which owns it ([HNT-3]).
364
- export const hintText = (h, self) => `${h.kind}: ${h.text}${h.kind === 'not ok' && !h.counts && !h.owners.includes(self) ? ` (information: owned by ${h.owners.join(', ')})` : ''}; ${h.command}`;
365
-
366
- // Hint lines for a view: up to `cap` under a Hint label, the last one saying
367
- // how many more are hidden and `more`, the command that shows them ([HNT-1]).
368
- export function hintLines(list, cap, { more = '--all', self } = {}) {
369
- const shown = list.slice(0, cap).map((h) => hintText(h, self));
370
- if (list.length > cap && shown.length) shown[shown.length - 1] += `; ${list.length - cap} more hidden, ${more}`;
371
- return shown.map((t, i) => line(i ? '' : 'Hint', t));
372
- }
373
-
374
- // The files at `at` that import or require `path` by a relative specifier, as
375
- // rough links with their reason ([LNK-1]).
376
- function importers(top, at, path) {
377
- const base = posix.basename(path).replace(/\.[^.]*$/, '');
378
- // An index file is named by itself, or by its folder in a directory specifier.
379
- const stems = base === 'index' && posix.dirname(path) !== '.' ? [base, posix.basename(posix.dirname(path))] : [base];
380
- const found = git(top, ['grep', '-l', '-F', ...stems.flatMap((s) => ['-e', s]), at, '--', '*.js', '*.mjs', '*.cjs', '*.ts'], { allowFail: true }) ?? '';
381
- const out = [];
382
- for (const f of found.split('\n').filter(Boolean).map((l) => l.slice(at.length + 1)).filter((f) => f !== path)) {
383
- for (const [how, spec] of specifiers(git(top, ['show', `${at}:${f}`], { allowFail: true }) ?? '')) {
384
- const p = posix.join(posix.dirname(f), spec);
385
- if (![p, `${p}.js`, `${p}.mjs`, `${p}.cjs`, `${p}.ts`, `${p}/index.js`].includes(path)) continue;
386
- out.push(`${how === 'require' ? 'required' : 'imported'} by ${f}`);
387
- break;
388
- }
389
- }
390
- return out;
391
- }
392
-
393
- // The relative specifiers `text` imports or requires, as [how, spec], read left
394
- // to right: comments and the text of strings and templates are skipped whole,
395
- // and a template's ${…} expressions are read as code. `from` counts only as an
396
- // ESM clause, never a call, and nothing whose last code before it is a `.` (a
397
- // method, whatever spaces or comments come between).
398
- const SPEC = /(?<![\w$])(?:(from)\s*|(import)\s*\(?\s*|(require)\s*\(\s*)(['"])(\.{1,2}\/[^'"\n]*)\4/y;
399
- function specifiers(text) {
400
- const out = [];
401
- const braces = []; // for each ${ we are inside, the { depth within it
402
- let inTemplate = false;
403
- let last = ''; // the last character of code read, spaces and comments aside
404
- for (let i = 0; i < text.length;) {
405
- const c = text[i];
406
- const two = text.slice(i, i + 2);
407
- if (inTemplate) {
408
- if (c === '\\') i += 2;
409
- else if (c === '`') { inTemplate = false; last = c; i++; }
410
- else if (two === '${') { braces.push(0); inTemplate = false; last = '{'; i += 2; }
411
- else i++;
412
- continue;
413
- }
414
- SPEC.lastIndex = i;
415
- const m = last !== '.' && 'fir'.includes(c) && SPEC.exec(text);
416
- if (m) { out.push([m[1] ?? m[2] ?? m[3], m[5]]); last = text[SPEC.lastIndex - 1]; i = SPEC.lastIndex; continue; }
417
- if (two === '//') { const n = text.indexOf('\n', i); i = n < 0 ? text.length : n; continue; }
418
- if (two === '/*') { const n = text.indexOf('*/', i + 2); i = n < 0 ? text.length : n + 2; continue; }
419
- if (!/\s/.test(c)) last = c;
420
- if (c === '"' || c === "'") {
421
- let j = i + 1;
422
- while (j < text.length && text[j] !== c && text[j] !== '\n') j += text[j] === '\\' ? 2 : 1;
423
- i = j + 1;
424
- } else if (c === '`') { inTemplate = true; i++; }
425
- else if (c === '{') { if (braces.length) braces[braces.length - 1]++; i++; }
426
- else if (c === '}') {
427
- if (braces.length && braces[braces.length - 1] === 0) { braces.pop(); inTemplate = true; }
428
- else if (braces.length) braces[braces.length - 1]--;
429
- i++;
430
- } else i++;
431
- }
432
- return out;
433
- }