@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/src/audit.js ADDED
@@ -0,0 +1,176 @@
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 ADDED
@@ -0,0 +1,127 @@
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
+ }