@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.
@@ -0,0 +1,329 @@
1
+ // One central spec repo, and output repos (design.md 12; T12, decision D13).
2
+ // The central repo's config lists its output repos; an output repo's config
3
+ // names the central repo. Another repo is read only through git, at its
4
+ // selected commit: its tree, its blobs and its commit messages, never its
5
+ // working tree, and nothing is fetched. A repo that cannot be read so is
6
+ // unknown, with its reason, and nothing is inferred from it.
7
+ import { existsSync, realpathSync } from 'node:fs';
8
+ import { isAbsolute, posix, resolve } from 'node:path';
9
+ import { parse } from 'yaml';
10
+ import { read, sha256 } from './base.js';
11
+ import { loadConfig } from './config.js';
12
+ import { commitOf, commitsOf, fileAt, filesAt, git, mergeBase, mergesOf } from './git.js';
13
+ import { loadState, qualify, requestNames } from './indexer.js';
14
+ import { latest } from './records.js';
15
+ import { parseResult } from './results.js';
16
+
17
+ const OUTPUT_LINKS = ['implements', 'verifies', 'documents'];
18
+ // A qualified central ID in text: `central:` and an optional `<request>/`,
19
+ // then a spec ID (EXP-4, ADR-3-2) or a request's R, Q, D, S or T number.
20
+ const QID = /(?<![A-Za-z0-9_-])central:((?:[a-z0-9][a-z0-9-]*\/)?(?:[A-Z][A-Z0-9]*-\d+(?:-\d+)?|[RQDST]\d+))(?![A-Za-z0-9_])/g;
21
+ const TASK = /^[a-z0-9][a-z0-9-]*\/T\d+$/;
22
+ const PR = /^([A-Za-z0-9][A-Za-z0-9._-]*)#(\d+)$/;
23
+ const PROVES = {
24
+ cites: 'the file names the ID',
25
+ 'named-by': 'the commit message names the task',
26
+ declared: 'a claim, not proven',
27
+ pr: 'a reference; the merge is found in git',
28
+ };
29
+
30
+ const short = (sha) => sha.slice(0, 7);
31
+ const ids = (text) => [...text.matchAll(QID)].map((m) => m[1]);
32
+
33
+ // An ID in text, qualified (`central:EXP-4`) or bare (`EXP-4`, `invoice-exports/R3`).
34
+ const ANY_ID = /(?<![A-Za-z0-9_\/-])(central:)?((?:[a-z0-9][a-z0-9-]*\/)?(?:[A-Z][A-Z0-9]*-\d+(?:-\d+)?|[RQDST]\d+))(?![A-Za-z0-9_])/g;
35
+
36
+ // The IDs that a line names, as written: with `qualified`, only `central:<ID>`;
37
+ // else also a bare ID, and only those whose ID is in `known`.
38
+ export function namedIds(text, { qualified = true, known = null } = {}) {
39
+ const out = [];
40
+ for (const m of text.matchAll(ANY_ID)) {
41
+ if (qualified ? !m[1] : known && !known.has(m[2])) continue;
42
+ if (!out.includes(m[0])) out.push(m[0]);
43
+ }
44
+ return out;
45
+ }
46
+
47
+ // The one cite finder (design.md 3, 12; D17): each line of the repo at `dir`
48
+ // that names an ID, at commit `rev`, or in the working tree (tracked, and
49
+ // untracked not ignored) when `rev` is null. Text files only; nothing under
50
+ // the `exclude` paths. Options as namedIds. Returns [{ file, line, text, ids }].
51
+ export function findCites(dir, rev, { qualified = true, known = null, exclude = [] } = {}) {
52
+ const pattern = qualified ? 'central:' : '[A-Z][A-Z0-9]*-?[0-9]';
53
+ const args = ['grep', '-I', '-n', '-z', ...(rev ? [] : ['--untracked']), '-E', '-e', pattern, ...(rev ? [rev] : []),
54
+ '--', '.', ...exclude.map((p) => `:(exclude)${p}`)];
55
+ const out = git(dir, args, { allowFail: true }) ?? '';
56
+ const found = [];
57
+ for (const l of out.split('\n')) {
58
+ if (!l) continue;
59
+ const [where, n, ...rest] = l.split('\0');
60
+ const text = rest.join('\0');
61
+ const named = namedIds(text, { qualified, known });
62
+ if (named.length) found.push({ file: rev ? where.slice(rev.length + 1) : where, line: Number(n), text, ids: named });
63
+ }
64
+ return found;
65
+ }
66
+
67
+ // A repo named in config, made readable: { dir, sha } or { unknown }.
68
+ function open(top, { name, path, url, commit }) {
69
+ if (typeof path !== 'string' || !path) return { unknown: url ? `no path in config; al does not fetch ${url}` : 'no path in config' };
70
+ const dir = isAbsolute(path) ? path : resolve(top, path);
71
+ if (!existsSync(dir)) return { unknown: `no clone at ${path}` };
72
+ const toplevel = git(dir, ['rev-parse', '--show-toplevel'], { allowFail: true });
73
+ if (!toplevel || realpathSync(toplevel) !== realpathSync(dir)) return { unknown: `${path} is not a git repository` };
74
+ const sha = commitOf(dir, commit);
75
+ if (!sha) return { unknown: `commit ${commit} is not in the ${name} clone` };
76
+ return { dir, sha };
77
+ }
78
+
79
+ // The output repos of the central repo's config, in config order.
80
+ export function outputRepos(top, config = loadConfig(top)) {
81
+ const seen = new Set();
82
+ return (Array.isArray(config.outputs) ? config.outputs : []).map((o) => {
83
+ const e = o && typeof o === 'object' && !Array.isArray(o) ? o : {};
84
+ const name = typeof e.name === 'string' ? e.name : null;
85
+ const entry = { name, path: e.path ?? null, url: e.url ?? null, commit: e.commit == null ? 'HEAD' : String(e.commit) };
86
+ let r;
87
+ if (!name || !/^[a-z0-9][a-z0-9._-]*$/.test(name)) r = { unknown: 'no valid name in config' };
88
+ else if (seen.has(name)) r = { unknown: `a second outputs entry named ${name}` };
89
+ else r = open(top, entry);
90
+ if (name) seen.add(name);
91
+ return { ...entry, sha: r.sha ?? null, unknown: r.unknown ?? null, dir: r.dir ?? null };
92
+ });
93
+ }
94
+
95
+ // A central ID (with no `central:`), found in a state: { display, kind } or null.
96
+ export function findCentral(state, ref) {
97
+ const t = qualify(ref, null);
98
+ if (t.cross) return null;
99
+ if (!t.req) {
100
+ const p = state.spec.get(t.id);
101
+ return p ? { display: p.displayNumber, kind: p.kind ?? null } : null;
102
+ }
103
+ const r = state.requests.get(t.req);
104
+ if (!r) return null;
105
+ const data = r.data ?? {};
106
+ const listed = (key) => (Array.isArray(data[key]) ? data[key] : []).some((e) => e?.id === t.id);
107
+ let found;
108
+ if (/^[RQ]\d+$/.test(t.id)) found = r.reqs.some((q) => q.id === t.id) || latest(data, t.id) !== null;
109
+ else if (/^D\d+$/.test(t.id)) found = listed('decisions');
110
+ else if (/^S\d+$/.test(t.id)) found = listed('signoff');
111
+ else if (/^T\d+$/.test(t.id)) found = listed('tasks');
112
+ else {
113
+ const p = r.paras.get(t.id);
114
+ return p ? { display: `${t.req}/${p.displayNumber}`, kind: p.kind ?? null } : null;
115
+ }
116
+ return found ? { display: `${t.req}/${t.id}`, kind: null } : null;
117
+ }
118
+
119
+ // The config a state is loaded with: the spec root as a repo path.
120
+ const stateConfig = (c) => ({ ...c, root: posix.normalize(String(c.root ?? 'specs')).replace(/\/+$/, ''), docs: Array.isArray(c.docs) ? c.docs : [] });
121
+
122
+ // The repo's own config at `sha`, or {} when it has none or it does not parse.
123
+ export function repoConfigAt(dir, sha) {
124
+ try {
125
+ const c = parse(fileAt(dir, sha, '.assuredloop/config.yaml') ?? '') ?? {};
126
+ return typeof c === 'object' && !Array.isArray(c) ? c : {};
127
+ } catch {
128
+ return {};
129
+ }
130
+ }
131
+
132
+ // Each line of `dir`'s tree at `sha` that names a central ID: [{ file, line, ids }].
133
+ const citingLines = (dir, sha) => findCites(dir, sha, { exclude: ['.assuredloop'] })
134
+ .map(({ file, line, ids: named }) => ({ file, line, ids: named.map((x) => x.slice('central:'.length)) }));
135
+
136
+ // The first line of each (file, ID) in `lines`, in file and line order.
137
+ function firsts(lines) {
138
+ const seen = new Map();
139
+ for (const { file, line, ids: found } of lines) {
140
+ for (const id of found) if (!seen.has(`${file}\t${id}`)) seen.set(`${file}\t${id}`, { file, line, id });
141
+ }
142
+ return [...seen.values()];
143
+ }
144
+
145
+ // Whether a declared target resolves in the central state: `central:X` and an
146
+ // unqualified X are the central repo's; another qualifier is not (null).
147
+ function resolves(state, target, request) {
148
+ const ref = target.startsWith('central:') ? target.slice('central:'.length) : target;
149
+ if (ref.includes(':')) return null;
150
+ const own = new Set(state.requests.get(request)?.paras.keys() ?? []);
151
+ const t = qualify(ref, request, own);
152
+ return findCentral(state, t.req ? `${t.req}/${t.id}` : t.id) !== null;
153
+ }
154
+
155
+ // Whether a result applies to the repo's tree at `sha` (design.md 9);
156
+ // `bytes` holds the declared inputs there, by path.
157
+ function applies(repo, res, bytes) {
158
+ if (!res.inputs.length) return 'applicability unknown: no declared inputs';
159
+ if (res.commit === 'unknown') return 'applicability unknown: the commit is unknown';
160
+ if (!res.resolved) return `applicability unknown: commit ${res.commit} is not in the ${repo.name} clone`;
161
+ for (const x of res.inputs) {
162
+ if (bytes.get(String(x.file)) === null) return `applicability unknown: the declared input ${x.file} is not there`;
163
+ }
164
+ for (const x of res.inputs) {
165
+ if (sha256(bytes.get(String(x.file))) !== x.sha256) return `does not apply to the current text: ${x.file} changed`;
166
+ }
167
+ return `declared inputs unchanged since ${repo.name}@${short(res.resolved)}`;
168
+ }
169
+
170
+ // The result files of an output repo at its commit, in its own results folder.
171
+ function resultsOf(repo) {
172
+ // As a repo path, the form git gives back: `./ci/results/` is `ci/results`;
173
+ // `.` and `./` are the repo's top, whose files git gives with no prefix.
174
+ const dir = posix.normalize(String(repoConfigAt(repo.dir, repo.sha).results ?? '.assuredloop/results')).replace(/\/+$/, '');
175
+ const prefix = dir === '.' ? '' : `${dir}/`;
176
+ const files = (git(repo.dir, ['ls-tree', '-z', '--name-only', repo.sha, ...(prefix ? ['--', prefix] : [])], { allowFail: true }) ?? '')
177
+ .split('\0').filter((f) => f.startsWith(prefix) && !f.slice(prefix.length).includes('/') && f.endsWith('.yaml')).sort();
178
+ // The result files, then all their declared inputs, each in one batch.
179
+ const texts = filesAt(repo.dir, repo.sha, files);
180
+ const parsed = files.map((file) => [file, parseResult(file, texts.get(file) ?? '')]);
181
+ const bytes = filesAt(repo.dir, repo.sha, [...new Set(parsed.flatMap(([, r]) => (r.inputs ?? []).map((x) => String(x.file))))], null);
182
+ // The commits the results ran at, in one batch.
183
+ const commits = commitsOf(repo.dir, parsed.map(([, r]) => r.commit).filter((c) => typeof c === 'string' && c !== 'unknown'));
184
+ return parsed.map(([file, r]) => {
185
+ const inputs = r.inputs ?? [];
186
+ const resolved = typeof r.commit === 'string' && r.commit !== 'unknown' ? commits.get(r.commit) : null;
187
+ const res = { repo: repo.name, file, check: r.check ?? null, outcome: r.outcome ?? null, commit: r.commit ?? null, resolved, inputs, by: r.by ?? null, source: r.source ?? null, note: r.note ?? null, problems: r.problems };
188
+ return { ...res, applies: applies(repo, res, bytes) };
189
+ });
190
+ }
191
+
192
+ // The commits reachable from `sha` whose message names a central task: [{ sha, task }].
193
+ function taskCommits(dir, sha) {
194
+ const out = git(dir, ['log', '--format=%H%x00%B%x01', sha], { allowFail: true }) ?? '';
195
+ const found = [];
196
+ for (const entry of out.split('\x01')) {
197
+ const [h, message = ''] = entry.replace(/^\s+/, '').split('\0');
198
+ if (!h) continue;
199
+ for (const id of new Set(ids(message))) if (TASK.test(id)) found.push({ sha: h, task: id });
200
+ }
201
+ return found;
202
+ }
203
+
204
+ // The output repos of the central repo at `top` and their results, and
205
+ // nothing else of crossRepo: what al check reads (design.md 9, 12).
206
+ export function crossResults(top) {
207
+ const repos = outputRepos(top, loadConfig(top));
208
+ return { repos: repos.map(({ dir, ...r }) => r), results: repos.filter((r) => r.sha).flatMap((r) => resultsOf(r)) };
209
+ }
210
+
211
+ const link = (fields) => ({ repo: null, holder: null, link: null, target: null, how: null, proves: null, commit: null, unknown: null, resolves: null, file: null, line: null, request: null, ...fields });
212
+
213
+ // The cross-repo links of the central repo at `top` (design.md 12): its output
214
+ // repos, each link from them or to them, and their results.
215
+ export function crossRepo(top) {
216
+ const config = loadConfig(top);
217
+ const repos = outputRepos(top, config);
218
+ if (!repos.length) return { repos: [], links: [], results: [] };
219
+ const byName = new Map(repos.filter((r) => r.name).map((r) => [r.name, r]));
220
+ const self = new Set(['central', ...(typeof config.repo === 'string' ? [config.repo] : [])]);
221
+ const state = loadState(top, null, stateConfig(config), requestNames(top));
222
+ const links = [];
223
+ const results = [];
224
+
225
+ for (const repo of repos) {
226
+ if (!repo.sha) continue;
227
+ for (const { file, line, id } of firsts(citingLines(repo.dir, repo.sha))) {
228
+ links.push(link({ repo: repo.name, holder: `${repo.name}/${file}`, link: 'cites', target: `central:${id}`, how: 'exact', proves: PROVES.cites, commit: repo.sha, resolves: findCentral(state, id) !== null, file, line }));
229
+ }
230
+ for (const { sha, task } of taskCommits(repo.dir, repo.sha)) {
231
+ links.push(link({ repo: repo.name, holder: `${repo.name}@${sha}`, link: 'named-by', target: `central:${task}`, how: 'exact', proves: PROVES['named-by'], commit: sha, resolves: findCentral(state, task) !== null }));
232
+ }
233
+ results.push(...resultsOf(repo));
234
+ }
235
+
236
+ // What the central records declare: outputs in other repos, and the PRs of tasks.
237
+ // The merges of each repo are read once (mergesOf).
238
+ const mergesByRepo = new Map();
239
+ const unknownOf = (name) => (byName.has(name) ? byName.get(name).unknown : 'not an output repo in config');
240
+ // The declared output files of each readable repo, read in one batch per repo.
241
+ const declared = new Map();
242
+ for (const r of state.requests.values()) {
243
+ for (const o of Array.isArray(r.data?.outputs) ? r.data.outputs : []) {
244
+ if (!o?.file || !o.repo || self.has(o.repo) || !byName.get(o.repo)?.sha) continue;
245
+ if (!declared.has(o.repo)) declared.set(o.repo, new Set());
246
+ declared.get(o.repo).add(String(o.file));
247
+ }
248
+ }
249
+ const present = new Map([...declared].map(([name, files]) => [name, filesAt(byName.get(name).dir, byName.get(name).sha, [...files], null)]));
250
+ for (const r of state.requests.values()) {
251
+ const data = r.data ?? {};
252
+ for (const o of Array.isArray(data.outputs) ? data.outputs : []) {
253
+ if (!o?.file || !o.repo || self.has(o.repo)) continue;
254
+ const repo = byName.get(o.repo);
255
+ const known = repo?.sha ? repo : null;
256
+ for (const kind of OUTPUT_LINKS) {
257
+ for (const ref of [].concat(o[kind] ?? [])) {
258
+ links.push({
259
+ ...link({ repo: String(o.repo), holder: `${o.repo}/${o.file}`, link: kind, target: String(ref), how: 'declared', proves: PROVES.declared, commit: known?.sha ?? null, unknown: unknownOf(o.repo), resolves: resolves(state, String(ref), r.name), file: String(o.file), request: r.name }),
260
+ present: known ? present.get(o.repo).get(String(o.file)) !== null : null,
261
+ });
262
+ }
263
+ }
264
+ }
265
+ for (const task of Array.isArray(data.tasks) ? data.tasks : []) {
266
+ for (const ref of [].concat(task?.prs ?? [])) {
267
+ const m = PR.exec(String(ref));
268
+ if (!m || self.has(m[1])) continue;
269
+ const repo = byName.get(m[1]);
270
+ let merge = null;
271
+ let unknown = unknownOf(m[1]);
272
+ if (!unknown) {
273
+ if (!mergesByRepo.has(repo.name)) mergesByRepo.set(repo.name, mergesOf(repo.dir, repo.sha));
274
+ merge = mergesByRepo.get(repo.name)(m[2]);
275
+ if (!merge) unknown = `no merge found at ${repo.name}@${short(repo.sha)}`;
276
+ }
277
+ links.push({
278
+ ...link({ repo: m[1], holder: `${r.name}/${task.id}`, link: 'pr', target: String(ref), how: 'declared', proves: PROVES.pr, commit: merge, unknown, request: r.name }),
279
+ merged: merge ? `merged at ${m[1]}@${short(merge)}` : null,
280
+ });
281
+ }
282
+ }
283
+ }
284
+
285
+ const order = new Map(repos.map((r, i) => [r.name, i]));
286
+ const rank = (name) => order.get(name) ?? repos.length;
287
+ links.sort((a, b) => rank(a.repo) - rank(b.repo) || a.link.localeCompare(b.link) || a.holder.localeCompare(b.holder)
288
+ || a.target.localeCompare(b.target) || (a.line ?? 0) - (b.line ?? 0));
289
+ return { repos: repos.map(({ dir, ...r }) => r), links, results };
290
+ }
291
+
292
+ // In an output repo: the files that the branch changed (merge-base with main
293
+ // to the working tree, and untracked files not ignored), or with no base every
294
+ // file; never under .assuredloop/.
295
+ function changedFiles(top, base) {
296
+ const list = (args) => (git(top, args, { allowFail: true }) ?? '').split('\0').filter(Boolean);
297
+ const files = base
298
+ ? [...list(['diff', '--name-only', '-z', base, '--']), ...list(['ls-files', '-z', '--others', '--exclude-standard'])]
299
+ : list(['ls-files', '-z', '--cached', '--others', '--exclude-standard']);
300
+ return [...new Set(files)].filter((f) => !f.startsWith('.assuredloop/')).sort();
301
+ }
302
+
303
+ // al check in an output repo (design.md 12): each central ID that the branch's
304
+ // changed files cite, looked up in the central repo at its selected commit.
305
+ // Returns { body, notKnown }; both empty when config names no central repo.
306
+ export function centralLookup(top) {
307
+ const config = loadConfig(top);
308
+ const c = config.central;
309
+ if (!c || typeof c !== 'object' || Array.isArray(c)) return { body: [], notKnown: [] };
310
+ const central = { name: 'central', path: c.path ?? null, url: c.url ?? null, commit: c.commit == null ? 'HEAD' : String(c.commit) };
311
+ const r = open(top, central);
312
+ if (r.unknown) return { body: [`unknown: the central repo: ${r.unknown}`], notKnown: [`the central repo (${r.unknown}): no central ID was looked up`] };
313
+ const base = mergeBase(top);
314
+ const lines = [];
315
+ for (const file of changedFiles(top, base)) {
316
+ let text;
317
+ try { text = read(top, file); } catch { continue; }
318
+ if (text === null || text.includes('\0')) continue;
319
+ text.split('\n').forEach((l, i) => { const found = ids(l); if (found.length) lines.push({ file, line: i + 1, ids: found }); });
320
+ }
321
+ const state = loadState(r.dir, r.sha, stateConfig(repoConfigAt(r.dir, r.sha)), requestNames(r.dir, r.sha));
322
+ const body = [`central repo ${c.path} at ${r.sha}`];
323
+ for (const { file, line, id } of firsts(lines)) {
324
+ const found = findCentral(state, id);
325
+ body.push(found ? `info cites ${file}:${line} central:${id} ${found.display} ${found.kind ?? '-'}`
326
+ : `hint unresolved-central ${file}:${line} central:${id} not in the central repo at ${short(r.sha)}`);
327
+ }
328
+ return { body, notKnown: base ? [] : ['no base (no main branch, or no commit): every file was looked at for central IDs'] };
329
+ }
@@ -0,0 +1,108 @@
1
+ // The parts of request.md that v4 reads and writes: the organized
2
+ // requirement (or question), its requirements with their `from` markers, the
3
+ // Signed off line and the decisions.
4
+ import { sha256 } from './base.js';
5
+
6
+ const ORGANIZED = /^##\s+Organized (requirement|question)\s*$/;
7
+ const MARKER = /^\s*<!--.*-->\s*$/;
8
+ const SIGNED_OFF = /^Signed off:/;
9
+ const lf = (md) => md.replace(/\r\n?/g, '\n');
10
+
11
+ // The organized section: its line range in `md`, from its `##` heading to the
12
+ // next `#` or `##` heading. Null when there is none.
13
+ export function organized(md) {
14
+ const lines = lf(md).split('\n');
15
+ const start = lines.findIndex((l) => ORGANIZED.test(l));
16
+ if (start < 0) return null;
17
+ let end = lines.findIndex((l, i) => i > start && /^#{1,2}\s/.test(l));
18
+ if (end < 0) end = lines.length;
19
+ return { lines, start, end };
20
+ }
21
+
22
+ // What a sign-off binds: the organized section with no Signed off line and no
23
+ // marker framing, the marker line and the blank line after it (D22, as the
24
+ // paragraph hash drops it), so adding markers changes no signed byte.
25
+ export function signedText(md) {
26
+ const o = organized(md);
27
+ if (!o) return null;
28
+ const lines = o.lines.slice(o.start, o.end);
29
+ const kept = lines.filter((l, i) => !SIGNED_OFF.test(l) && !MARKER.test(l)
30
+ && !(l === '' && i > 0 && MARKER.test(lines[i - 1])));
31
+ while (kept.length && !kept.at(-1).trim()) kept.pop();
32
+ return `${kept.join('\n')}\n`;
33
+ }
34
+
35
+ // Each `### R<n> <title>` (or `### Q<n>`): { id, title, text, sha256, from }.
36
+ // Its text runs from the heading to the next heading, or an Out:, Assumed:
37
+ // or Signed off: line, with no marker line and no blank line at either end.
38
+ // `from` lists the snapshot files of the `<!-- R<n> from:... -->` markers.
39
+ export function requirements(md) {
40
+ const o = organized(md);
41
+ if (!o) return [];
42
+ const from = new Map();
43
+ const out = [];
44
+ let cur = null;
45
+ const close = () => {
46
+ if (!cur) return;
47
+ const body = cur.lines.filter((l) => !MARKER.test(l));
48
+ while (body.length && !body.at(-1).trim()) body.pop();
49
+ const text = `${body.join('\n')}\n`;
50
+ out.push({ id: cur.id, title: cur.title, text, sha256: sha256(text) });
51
+ cur = null;
52
+ };
53
+ for (const l of o.lines.slice(o.start + 1, o.end)) {
54
+ const m = l.match(/^\s*<!--\s*([RQ]\d+)\b(.*?)-->\s*$/);
55
+ if (m) {
56
+ const files = [...m[2].matchAll(/(?:^|\s)from:(\S+)/g)].flatMap((x) => x[1].split(',').filter(Boolean));
57
+ from.set(m[1], [...(from.get(m[1]) ?? []), ...files]);
58
+ }
59
+ const h = l.match(/^###\s+([RQ]\d+)\b\s*(.*)$/);
60
+ if (h) { close(); cur = { id: h[1], title: h[2].trim(), lines: [l] }; continue; }
61
+ if (/^#{1,6}\s/.test(l) || /^(Out|Assumed|Signed off):/.test(l)) { close(); continue; }
62
+ if (cur) cur.lines.push(l);
63
+ }
64
+ close();
65
+ return out.map((r) => ({ ...r, from: from.get(r.id) ?? [] }));
66
+ }
67
+
68
+ // Sets `Signed off: …` as the last line of the organized section, in place of
69
+ // any earlier one there.
70
+ export function setSignedOff(md, line) {
71
+ const o = organized(md);
72
+ const lines = o.lines.filter((l, i) => !(i > o.start && i < o.end && SIGNED_OFF.test(l)));
73
+ const end = o.end - (o.lines.length - lines.length);
74
+ let last = end - 1;
75
+ while (last > o.start && !lines[last].trim()) last--;
76
+ lines.splice(last + 1, 0, line);
77
+ return lines.join('\n');
78
+ }
79
+
80
+ // The D numbers in `## Decisions`.
81
+ export function decisionNumbers(md) {
82
+ const lines = lf(md).split('\n');
83
+ const at = lines.findIndex((l) => /^##\s+Decisions\s*$/.test(l));
84
+ if (at < 0) return [];
85
+ const out = [];
86
+ for (const l of lines.slice(at + 1)) {
87
+ if (/^#{1,2}\s/.test(l)) break;
88
+ const m = l.match(/^- D(\d+)\b/);
89
+ if (m) out.push(Number(m[1]));
90
+ }
91
+ return out;
92
+ }
93
+
94
+ // `entry` as the last entry of `## Decisions`, the section added at the end
95
+ // when missing.
96
+ export function addDecision(md, entry) {
97
+ const lines = lf(md).split('\n');
98
+ const at = lines.findIndex((l) => /^##\s+Decisions\s*$/.test(l));
99
+ if (at < 0) return `${lf(md).replace(/\n*$/, '\n')}\n## Decisions\n\n${entry}\n`;
100
+ let end = lines.findIndex((l, i) => i > at && /^#{1,2}\s/.test(l));
101
+ if (end < 0) end = lines.length;
102
+ let last = end;
103
+ while (last > at + 1 && !lines[last - 1].trim()) last--;
104
+ // A heading right after the entry gets a blank line before it.
105
+ const after = last === end && end < lines.length ? [entry, ''] : [entry];
106
+ if (last === at + 1) after.unshift('');
107
+ return [...lines.slice(0, last), ...after, ...lines.slice(last)].join('\n');
108
+ }
@@ -0,0 +1,42 @@
1
+ // Result files (design.md 9; the T6 decision): what ran, its outcome, the
2
+ // commit it ran at (or "unknown") and its declared inputs, with optional
3
+ // by, source and note. This reads and checks the format only; whether a
4
+ // result applies to the current text is judged by the checks (T10).
5
+ import { readdirSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ import { parse } from 'yaml';
8
+ import { Fail, read } from './base.js';
9
+ import { loadConfig } from './config.js';
10
+
11
+ const OUTCOMES = ['pass', 'fail', 'not run'];
12
+
13
+ export function loadResults(top) {
14
+ const dir = String(loadConfig(top).results ?? '.assuredloop/results').replace(/\/+$/, '');
15
+ let files = [];
16
+ try { files = readdirSync(join(top, dir)).filter((f) => f.endsWith('.yaml')).sort(); } catch { return []; }
17
+ return files.map((f) => {
18
+ const file = `${dir}/${f}`;
19
+ let text;
20
+ try { text = read(top, file); } catch (e) { return { file, problems: [e instanceof Fail ? e.message : 'not YAML'] }; }
21
+ return parseResult(file, text);
22
+ });
23
+ }
24
+
25
+ // One result file's text, read and checked: its fields and its problems.
26
+ export function parseResult(file, text) {
27
+ let r;
28
+ // The failsafe schema keeps each value as written: a commit such as 0123456 stays text.
29
+ try { r = parse(text, { schema: 'failsafe' }); } catch { return { file, problems: ['not YAML'] }; }
30
+ if (!r || typeof r !== 'object' || Array.isArray(r)) return { file, problems: ['not a YAML map'] };
31
+ const problems = [];
32
+ for (const k of ['check', 'outcome', 'commit']) if (r[k] === undefined || r[k] === null || r[k] === '') problems.push(`no ${k}`);
33
+ if (r.outcome != null && !OUTCOMES.includes(r.outcome)) problems.push(`outcome ${r.outcome}: one of ${OUTCOMES.join(', ')}`);
34
+ if (r.commit != null && r.commit !== 'unknown' && !/^[0-9a-f]{7,40}$/.test(r.commit)) problems.push(`commit ${r.commit}: a 7-40 hex commit, or unknown`);
35
+ const inputs = r.inputs ?? [];
36
+ if (!Array.isArray(inputs)) problems.push('inputs: a list of {file, sha256}');
37
+ else inputs.forEach((x, i) => {
38
+ if (!x?.file) problems.push(`input ${i + 1}: no file`);
39
+ if (!/^[0-9a-f]{64}$/.test(String(x?.sha256 ?? ''))) problems.push(`input ${i + 1}: sha256 is not 64 hex`);
40
+ });
41
+ return { file, check: r.check, outcome: r.outcome, commit: r.commit, inputs: Array.isArray(inputs) ? inputs : [], by: r.by, source: r.source, note: r.note, problems };
42
+ }
@@ -0,0 +1,167 @@
1
+ // The docs whose paragraphs carry markers, in two kinds of scope (architect,
2
+ // 2026-10-10): the spec scope, every *.md under the spec root except its adr/
3
+ // folder plus the files config's docs list names; and one scope per request,
4
+ // its requests/<name>/spec.md (or the archived one), with prefix SP and IDs of
5
+ // its own. Two scopes never share IDs.
6
+ import { existsSync, lstatSync, readdirSync, readFileSync } from 'node:fs';
7
+ import { join, posix, relative } from 'node:path';
8
+ import { git, filesAt, readObjects } from './git.js';
9
+ import { parseMarkdown } from './markers.js';
10
+
11
+ export const SPEC = 'spec';
12
+ const REQUEST = /^requests\/(?:archive\/)?([^/]+)\/spec\.md$/;
13
+
14
+ const clean = (p) => posix.normalize(String(p)).replace(/^\.\//, '').replace(/\/+$/, '');
15
+ const outside = (p) => posix.isAbsolute(String(p)) || /^\.\.(\/|$)/.test(clean(p));
16
+
17
+ // The root and the docs entries of config that are outside the repository,
18
+ // as written (the root with a trailing slash): never walked or read.
19
+ const outsideDocs = (config) => [
20
+ ...(outside(config.root) ? [`${String(config.root).replace(/\/+$/, '')}/`] : []),
21
+ ...config.docs.map((d) => String(d.file)).filter(outside),
22
+ ];
23
+
24
+ // The first part of `path` (from the repo top) that is a symlink, or null.
25
+ export function symlinkOn(top, path) {
26
+ let at = top;
27
+ for (const part of path.split('/')) {
28
+ at = join(at, part);
29
+ let stat;
30
+ try {
31
+ stat = lstatSync(at);
32
+ } catch {
33
+ return null;
34
+ }
35
+ if (stat.isSymbolicLink()) return relative(top, at);
36
+ }
37
+ return null;
38
+ }
39
+
40
+ // The root and the docs entries that are inside the repository but reached
41
+ // through a symlink: never walked or read either.
42
+ const linked = (top, config) => [
43
+ ...(!outside(config.root) && symlinkOn(top, clean(config.root)) ? [`${String(config.root).replace(/\/+$/, '')}/`] : []),
44
+ ...insideDocs(config).map((d) => String(d.file)).filter((f) => symlinkOn(top, clean(f))),
45
+ ];
46
+
47
+ // Why config's root cannot be used, or null: the repository top is no spec root.
48
+ export const rootProblem = (config) => (!outside(config.root) && ['.', ''].includes(clean(config.root))
49
+ ? `.assuredloop/config.yaml: root: ${JSON.stringify(String(config.root))} is the repository top; give the spec folder, for example specs`
50
+ : null);
51
+
52
+ // One line for each root or docs entry that is not read, and why.
53
+ export const notRead = (top, config) => [
54
+ ...outsideDocs(config).map((f) => `not read: ${f}: outside the repository`),
55
+ ...linked(top, config).map((f) => `not read: ${f}: through a symlink`),
56
+ ];
57
+
58
+ const insideDocs = (config) => config.docs.filter((d) => !outside(d.file));
59
+
60
+ // The scope of a repo path, or null when it holds no markers.
61
+ export function scopeOf(path, config) {
62
+ const root = clean(config.root);
63
+ const r = REQUEST.exec(path);
64
+ if (r) return `request:${r[1]}`;
65
+ if (path.startsWith(`${root}/adr/`)) return null;
66
+ if (insideDocs(config).some((d) => clean(d.file) === path)) return SPEC;
67
+ if (path.startsWith(`${root}/`) && path.endsWith('.md')) return SPEC;
68
+ return null;
69
+ }
70
+
71
+ // The prefix of a doc: SP in a request spec, else the one config lists, or null.
72
+ export function prefixOf(path, config) {
73
+ if (REQUEST.test(path)) return 'SP';
74
+ return insideDocs(config).find((d) => clean(d.file) === path)?.prefix ?? null;
75
+ }
76
+
77
+ // Every file of the working tree under `dir`, symlinks not followed.
78
+ function walk(top, dir, out = []) {
79
+ if (!existsSync(join(top, dir)) || !lstatSync(join(top, dir)).isDirectory()) return out;
80
+ for (const e of readdirSync(join(top, dir), { withFileTypes: true })) {
81
+ if (e.name === '.git' || e.name === 'node_modules') continue;
82
+ const p = dir ? `${dir}/${e.name}` : e.name;
83
+ if (e.isDirectory()) walk(top, p, out);
84
+ else if (e.isFile()) out.push(p);
85
+ }
86
+ return out;
87
+ }
88
+
89
+ // The docs in scope: [{ path, scope, text }], from the working tree when
90
+ // `rev` is null, else from that commit.
91
+ export function docsInScope(top, config, rev = null) {
92
+ const root = clean(config.root);
93
+ let paths;
94
+ if (rev === null) {
95
+ const read = (p) => !symlinkOn(top, p);
96
+ paths = [...(outside(config.root) || !read(root) ? [] : walk(top, root)), ...walk(top, 'requests'), ...insideDocs(config).map((d) => clean(d.file)).filter(read)];
97
+ } else {
98
+ paths = (git(top, ['ls-tree', '-r', '-z', '--name-only', rev], { allowFail: true }) ?? '').split('\0').filter(Boolean);
99
+ }
100
+ const inScope = [...new Set(paths)].sort().filter((path) => scopeOf(path, config));
101
+ const atRev = rev === null ? null : filesAt(top, rev, inScope);
102
+ const docs = [];
103
+ for (const path of inScope) {
104
+ const scope = scopeOf(path, config);
105
+ let text;
106
+ if (rev !== null) text = atRev.get(path);
107
+ else if (existsSync(join(top, path)) && lstatSync(join(top, path)).isFile()) text = readFileSync(join(top, path), 'utf8');
108
+ if (text == null) continue;
109
+ docs.push({ path, scope, text });
110
+ }
111
+ return docs;
112
+ }
113
+
114
+ // Parses each doc; returns Map scope → paragraphs, and every lint.
115
+ export function parseScopes(docs, kinds) {
116
+ const scopes = new Map();
117
+ const lints = [];
118
+ for (const d of docs) {
119
+ const r = parseMarkdown(d.text, d.path, { kinds });
120
+ if (!scopes.has(d.scope)) scopes.set(d.scope, []);
121
+ scopes.get(d.scope).push(...r.paragraphs);
122
+ lints.push(...r.lints);
123
+ }
124
+ return { scopes, lints };
125
+ }
126
+
127
+ // Every ID that a marker held in any commit up to `rev` (a commit, or --all
128
+ // for every ref), by scope: each version of each doc in history, from
129
+ // `git log --raw -z` (exact paths), read with `git cat-file --batch` and
130
+ // parsed, so a marker inside fenced code is no ID. Null when `rev` is null.
131
+ export function idsEverUsed(top, config, rev) {
132
+ if (!rev) return null;
133
+ const used = new Map();
134
+ const specs = ['*.md', ...insideDocs(config).map((d) => clean(d.file))];
135
+ const out = git(top, ['log', '--raw', '-z', '--no-abbrev', '--no-renames', '--format=', rev, '--', ...specs], { allowFail: true });
136
+ if (!out) return used;
137
+ // Each changed file is ":<mode> <mode> <old blob> <new blob> <status>" NUL "<path>" NUL.
138
+ const scopes = new Map();
139
+ const fields = out.split('\0');
140
+ for (let i = 0; i < fields.length - 1; i++) {
141
+ const meta = fields[i].replace(/^\n+/, '');
142
+ if (!meta.startsWith(':')) continue;
143
+ const scope = scopeOf(fields[++i], config);
144
+ if (!scope) continue;
145
+ for (const blob of meta.split(' ').slice(2, 4)) {
146
+ if (/^0+$/.test(blob)) continue;
147
+ if (!scopes.has(blob)) scopes.set(blob, new Set());
148
+ scopes.get(blob).add(scope);
149
+ }
150
+ }
151
+ for (const [blob, text] of readBlobs(top, [...scopes.keys()])) {
152
+ for (const p of parseMarkdown(text, '').paragraphs) {
153
+ for (const scope of scopes.get(blob)) {
154
+ if (!used.has(scope)) used.set(scope, new Set());
155
+ used.get(scope).add(p.id);
156
+ }
157
+ }
158
+ }
159
+ return used;
160
+ }
161
+
162
+ // The text of each blob that is there.
163
+ function readBlobs(top, blobs) {
164
+ return new Map([...readObjects(top, blobs)].map(([b, bytes]) => [b, bytes.toString('utf8')]));
165
+ }
166
+
167
+ export const isShallow = (top) => git(top, ['rev-parse', '--is-shallow-repository'], { allowFail: true }) === 'true';