@assuredloop/cli 0.1.0 → 0.2.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/v4/ids.js ADDED
@@ -0,0 +1,87 @@
1
+ // v4 IDs between a base and a head (design.md 3, What the script checks): the
2
+ // per-paragraph hash list with each paragraph's change (Changed, Moved, New,
3
+ // Removed), and the ID lints of a PR (IDs lost since the base, IDs used again
4
+ // after removal, one ID in two files).
5
+
6
+ const byId = (paragraphs) => {
7
+ const m = new Map();
8
+ for (const p of paragraphs) if (!m.has(p.id)) m.set(p.id, p);
9
+ return m;
10
+ };
11
+
12
+ // The kept IDs that stay in place: per file, the longest run of IDs whose base
13
+ // order is kept (IDs are unique, so this is the longest common subsequence).
14
+ // Every other kept ID moved: the minimum set.
15
+ function inOrder(was, now) {
16
+ const keep = new Set();
17
+ const files = new Map();
18
+ for (const [id, p] of now) {
19
+ const b = was.get(id);
20
+ if (!b || b.file !== p.file) continue;
21
+ if (!files.has(p.file)) files.set(p.file, []);
22
+ files.get(p.file).push(id);
23
+ }
24
+ const at = new Map([...was.keys()].map((id, i) => [id, i]));
25
+ for (const ids of files.values()) {
26
+ // Patience: tails[k] ends the best run of length k + 1; prev links the run.
27
+ const tails = [];
28
+ const prev = new Map();
29
+ for (const id of ids) {
30
+ let lo = 0;
31
+ let hi = tails.length;
32
+ while (lo < hi) {
33
+ const mid = (lo + hi) >> 1;
34
+ if (at.get(tails[mid]) < at.get(id)) lo = mid + 1; else hi = mid;
35
+ }
36
+ prev.set(id, lo > 0 ? tails[lo - 1] : null);
37
+ tails[lo] = id;
38
+ }
39
+ for (let id = tails.at(-1); id != null; id = prev.get(id)) keep.add(id);
40
+ }
41
+ return keep;
42
+ }
43
+
44
+ export function diffParagraphs(base, head) {
45
+ const was = byId(base);
46
+ const now = byId(head);
47
+ const stays = inOrder(was, now);
48
+ const out = [];
49
+ for (const [id, p] of now) {
50
+ const b = was.get(id);
51
+ if (!b) {
52
+ out.push({ id, file: p.file, sha256: p.sha256, baseSha256: null, changes: ['New'] });
53
+ continue;
54
+ }
55
+ const changes = [];
56
+ if (b.sha256 !== p.sha256) changes.push('Changed');
57
+ if (b.file !== p.file || JSON.stringify(b.headingPath) !== JSON.stringify(p.headingPath) || !stays.has(id)) changes.push('Moved');
58
+ out.push({ id, file: p.file, sha256: p.sha256, baseSha256: b.sha256, changes });
59
+ }
60
+ for (const [id, b] of was) {
61
+ if (!now.has(id)) out.push({ id, file: b.file, sha256: null, baseSha256: b.sha256, changes: ['Removed'] });
62
+ }
63
+ return out;
64
+ }
65
+
66
+ export function idLints(base, head, { everUsed = [], removes = [] } = {}) {
67
+ const was = byId(base);
68
+ const now = byId(head);
69
+ const used = new Set(everUsed);
70
+ const removed = new Set(removes);
71
+ const lints = [];
72
+ const lint = (code, p, message) => lints.push({ code, severity: 'not ok', id: p.id, file: p.file, line: p.line, message });
73
+ for (const [id, p] of was) {
74
+ if (!now.has(id) && !removed.has(id)) lint('lost-id', p, `${id} was in ${p.file} at the base and is gone; declare removes:${id} in a change spec, or put the marker back`);
75
+ }
76
+ for (const [id, p] of now) {
77
+ if (!was.has(id) && used.has(id)) lint('used-again', p, `${id} was used before and removed; an ID is never used again`);
78
+ }
79
+ const files = new Map();
80
+ for (const p of head) {
81
+ const seen = files.get(p.id) ?? new Set();
82
+ if (seen.size && !seen.has(p.file)) lint('duplicate-id', p, `${p.id} is also used in ${[...seen][0]}`);
83
+ seen.add(p.file);
84
+ files.set(p.id, seen);
85
+ }
86
+ return lints;
87
+ }
@@ -0,0 +1,361 @@
1
+ // al index [--align <ID>]: writes the per-doc records (the regenerable fields,
2
+ // with the agents' hints kept), appends requirement versions, freezes the
3
+ // bindings of new links and fills the hashes of new dispositions. Ordinary
4
+ // indexing never advances a binding; only --align does (design.md 4, 10).
5
+ import { readdirSync } from 'node:fs';
6
+ import { join, posix } from 'node:path';
7
+ import { isMap, parse, stringify } from 'yaml';
8
+ import { parseMarkdown } from './markers.js';
9
+ import { diffParagraphs } from './ids.js';
10
+ import { git, mergeBase, fileAt, filesAt } from './git.js';
11
+ import { loadConfig } from './config.js';
12
+ import { SPEC, docsInScope, symlinkOn } from './scope.js';
13
+ import { Fail, SCHEMA, docRecordPath, guard, inside, read, recordPath, sha256, write } from './base.js';
14
+ import { requirements } from './request-md.js';
15
+ import { outputRepos } from './repos.js';
16
+ import { YAML_OPTIONS, addVersions, append, latest, openRecord, recordText, seq } from './records.js';
17
+
18
+ // The marker links that get a binding, by the parser's key; `for` (a task) has no text to bind.
19
+ const LINK_WORDS = {
20
+ serves: 'serves', buildsOn: 'builds-on', changes: 'changes', removes: 'removes', explains: 'explains',
21
+ illustrates: 'illustrates', resolvedBy: 'resolved-by', governedBy: 'governed-by', decides: 'decides',
22
+ supersedes: 'supersedes', source: 'source',
23
+ };
24
+ const OUTPUT_LINKS = ['implements', 'verifies', 'documents'];
25
+ const CONFLICT = /^(<{7}|={7}|>{7})( |$)/m;
26
+
27
+ const list = (top, dir) => {
28
+ try { return readdirSync(join(top, dir), { withFileTypes: true }); } catch { return []; }
29
+ };
30
+
31
+ // The names of the open requests (requests/<name>/request.md) and of the
32
+ // archived ones, in the working tree, or at commit `at`.
33
+ export function requestNames(top, at = null) {
34
+ if (at) {
35
+ const dirs = (path) => (git(top, ['ls-tree', '-d', '--name-only', at, `${path}/`], { allowFail: true }) ?? '').split('\n').filter(Boolean).map((p) => p.slice(path.length + 1));
36
+ const candidates = dirs('requests').filter((n) => n !== 'archive');
37
+ const mds = filesAt(top, at, candidates.map((n) => `requests/${n}/request.md`));
38
+ const open = candidates.filter((n) => mds.get(`requests/${n}/request.md`) !== null).sort();
39
+ return { open, archived: dirs('requests/archive').sort() };
40
+ }
41
+ const open = list(top, 'requests').filter((e) => e.isDirectory() && e.name !== 'archive' && read(top, `requests/${e.name}/request.md`) !== null).map((e) => e.name).sort();
42
+ const archived = list(top, 'requests/archive').filter((e) => e.isDirectory()).map((e) => e.name).sort();
43
+ return { open, archived };
44
+ }
45
+
46
+ // One state of the project, the working tree (at null) or a commit: its
47
+ // docs, parsed, and its requests, each with request.md's requirements and
48
+ // its record's data.
49
+ export function loadState(top, at, config, names) {
50
+ // The spec docs and the open change specs (docsInScope), and the ADRs in <root>/adr/.
51
+ const adr = `${config.root}/adr`;
52
+ const adrs = (at
53
+ ? (git(top, ['ls-tree', '--name-only', at, `${adr}/`], { allowFail: true }) ?? '').split('\n')
54
+ : symlinkOn(top, adr) ? [] : list(top, adr).filter((e) => e.isFile()).map((e) => `${adr}/${e.name}`))
55
+ .filter((f) => f.startsWith(`${adr}/`) && /^\d{4}-[^/]*\.md$/.test(f.slice(adr.length + 1))).sort();
56
+ // At a commit, every file below is read in one batch.
57
+ const atCommit = at ? filesAt(top, at, [...adrs,
58
+ ...names.open.flatMap((n) => [`requests/${n}/request.md`, recordPath(n)]),
59
+ ...names.archived.flatMap((n) => [`requests/archive/${n}/request.md`, recordPath(n), `requests/archive/${n}/spec.md`])]) : null;
60
+ const get = at ? (p) => (atCommit.has(p) ? atCommit.get(p) : fileAt(top, at, p)) : (p) => read(top, p);
61
+ const open = new Set(names.open);
62
+ const files = [
63
+ ...docsInScope(top, config, at).flatMap((d) => {
64
+ if (d.scope === SPEC) return [{ file: d.path, request: null, text: d.text }];
65
+ const n = d.scope.slice('request:'.length);
66
+ return d.path === `requests/${n}/spec.md` && open.has(n) ? [{ file: d.path, request: n, text: d.text }] : [];
67
+ }),
68
+ ...adrs.map((file) => ({ file, request: null, text: get(file), adr: true })),
69
+ ];
70
+ const docs = [];
71
+ for (const { file, request, text, adr: isAdr } of files) {
72
+ if (text === null || text === undefined) continue;
73
+ const paragraphs = parseMarkdown(text, file).paragraphs.filter((p) => p.id);
74
+ docs.push({ file, request, text, adr: Boolean(isAdr), paragraphs, ids: new Set(paragraphs.map((p) => p.id)) });
75
+ }
76
+ const requests = new Map();
77
+ for (const [n, open] of [...names.open.map((n) => [n, true]), ...names.archived.map((n) => [n, false])]) {
78
+ const md = get(open ? `requests/${n}/request.md` : `requests/archive/${n}/request.md`) ?? '';
79
+ let data = null;
80
+ // A record that is not YAML reads as none; a refused read (outside, or through a symlink) stays a refusal.
81
+ const text = get(recordPath(n));
82
+ try { data = parse(text ?? 'null'); } catch { data = null; }
83
+ const spec = open ? docs.find((d) => d.request === n) : null;
84
+ let paras = spec?.paragraphs;
85
+ if (!open) {
86
+ const text = get(`requests/archive/${n}/spec.md`);
87
+ paras = text === null ? [] : parseMarkdown(text, `requests/archive/${n}/spec.md`).paragraphs.filter((p) => p.id);
88
+ }
89
+ requests.set(n, { name: n, open, md, reqs: requirements(md), data: data && typeof data === 'object' ? data : null, paras: new Map((paras ?? []).map((p) => [p.id, p])) });
90
+ }
91
+ return { docs, requests, spec: new Map(docs.filter((d) => !d.request).flatMap((d) => d.paragraphs.map((p) => [p.id, p]))) };
92
+ }
93
+
94
+ // A link target as written, made exact: `name:ID` is another repo's;
95
+ // `<request>/<ID>` a request's; in a request's own text, R/Q/D/S numbers, a
96
+ // snapshot file and an ID of its own change spec are its own; else a spec ID.
97
+ export function qualify(ref, request, own = new Set(), central = false) {
98
+ // In the central repo, `central:X` is its own X (T12); the key stays as written.
99
+ if (central && ref.startsWith('central:')) return { ...qualify(ref.slice('central:'.length), request, own), key: ref };
100
+ if (ref.includes(':')) return { key: ref, cross: true };
101
+ const slash = ref.indexOf('/');
102
+ if (slash > 0) return { key: ref, req: ref.slice(0, slash), id: ref.slice(slash + 1) };
103
+ if (request && (/^[RQDS]\d+$/.test(ref) || /\.md$/.test(ref) || own.has(ref))) return { key: `${request}/${ref}`, req: request, id: ref };
104
+ return { key: ref, req: null, id: ref };
105
+ }
106
+
107
+ // The central repo is the one whose config lists output repos (T12).
108
+ export const isCentral = (config) => Array.isArray(config.outputs) && config.outputs.length > 0;
109
+
110
+ // An output in another repo (T12): its repo is named, and is not this one.
111
+ const foreign = (o, config) => Boolean(o.repo) && o.repo !== config.repo && o.repo !== 'central';
112
+
113
+ // Every declared link of a state: { holder, holderSha, link, target, homes }, with
114
+ // `homes` the requests whose record holds it. An output in another repo also
115
+ // has `commit` (<repo>@<sha>), or `unknown` with the reason; `repos` are the
116
+ // output repos of config, by name (live only).
117
+ function linksOf(top, state, config, live, repos = new Map()) {
118
+ const out = [];
119
+ // The declared output files in another repo, read in one batch per repo.
120
+ const batches = new Map();
121
+ const outputFiles = (repo) => {
122
+ if (!batches.has(repo.name)) {
123
+ const files = [...state.requests.values()].filter((r) => r.open).flatMap((r) => r.data?.outputs ?? [])
124
+ .filter((o) => o?.file && o.repo === repo.name).map((o) => String(o.file));
125
+ batches.set(repo.name, filesAt(repo.dir, repo.sha, [...new Set(files)], null));
126
+ }
127
+ return batches.get(repo.name);
128
+ };
129
+ for (const d of state.docs) {
130
+ for (const p of d.paragraphs) {
131
+ const holder = d.request ? `${d.request}/${p.id}` : p.id;
132
+ const ls = Object.entries(LINK_WORDS).flatMap(([k, link]) => (p.links?.[k] ?? []).map((ref) => ({ link, t: qualify(ref, d.request, d.ids, isCentral(config)) })));
133
+ const homes = d.request ? [d.request] : [...new Set(ls.map((l) => l.t.req).filter(Boolean))];
134
+ for (const { link, t } of ls) out.push({ holder, holderSha: p.sha256, link, target: t.key, t, homes });
135
+ }
136
+ }
137
+ for (const r of state.requests.values()) {
138
+ if (!r.open) continue;
139
+ for (const q of r.reqs) {
140
+ for (const f of q.from) out.push({ holder: `${r.name}/${q.id}`, holderSha: q.sha256, link: 'from', target: `${r.name}/${f}`, t: { req: r.name, id: f }, homes: [r.name] });
141
+ }
142
+ const own = new Set(r.paras.keys());
143
+ for (const o of r.data?.outputs ?? []) {
144
+ if (!o?.file) continue;
145
+ const other = foreign(o, config);
146
+ const repo = other ? repos.get(o.repo) : null;
147
+ const unknown = !other || !live ? null : !repo ? 'not an output repo in config' : repo.unknown;
148
+ let bytes = null;
149
+ if (live && !other) bytes = read(top, o.file, null);
150
+ else if (live && !unknown) bytes = outputFiles(repo).get(String(o.file));
151
+ const at = other && live && !unknown ? { commit: `${o.repo}@${repo.sha}` } : {};
152
+ for (const link of OUTPUT_LINKS) {
153
+ for (const ref of [].concat(o[link] ?? [])) {
154
+ out.push({ holder: other ? `${o.repo}/${o.file}` : o.file, holderSha: bytes === null ? null : sha256(bytes), link, target: qualify(String(ref), r.name, own, isCentral(config)).key, t: qualify(String(ref), r.name, own, isCentral(config)), homes: [r.name], output: true, ...(unknown ? { unknown } : {}), ...at });
155
+ }
156
+ }
157
+ }
158
+ }
159
+ return out;
160
+ }
161
+
162
+ // The text hash of a target in `state`, with its version for a requirement; null when it does not resolve.
163
+ export function targetOf(t, state) {
164
+ if (!t.req) {
165
+ const p = state.spec.get(t.id);
166
+ return p ? { sha: p.sha256 } : null;
167
+ }
168
+ const r = state.requests.get(t.req);
169
+ if (!r) return null;
170
+ const data = r.data ?? {};
171
+ const find = (key) => (data[key] ?? []).find((e) => e?.id === t.id);
172
+ if (/^[RQ]\d+$/.test(t.id)) {
173
+ const v = latest(data, t.id);
174
+ return v ? { sha: v.sha256, version: v.version } : null;
175
+ }
176
+ if (/^D\d+$/.test(t.id)) return find('decisions')?.sha256 ? { sha: find('decisions').sha256 } : null;
177
+ if (/^S\d+$/.test(t.id)) return find('signoff')?.sha256 ? { sha: find('signoff').sha256 } : null;
178
+ if (/\.md$/.test(t.id)) {
179
+ const s = (data.sources ?? []).find((e) => e?.file === t.id);
180
+ return s?.sha256 ? { sha: s.sha256 } : null;
181
+ }
182
+ const p = r.paras.get(t.id);
183
+ return p ? { sha: p.sha256 } : null;
184
+ }
185
+
186
+ const tripleKey = (l) => `${l.holder}\t${l.link}\t${l.target}`;
187
+ const short = (h) => (h ? h.slice(0, 12) : 'unknown');
188
+
189
+ export function index({ top, opts }) {
190
+ let loaded;
191
+ try { loaded = loadConfig(top); } catch (e) { throw new Fail(e.message, 'fix .assuredloop/config.yaml'); }
192
+ const body = [];
193
+ const notes = [];
194
+ // The spec root as a repo path, once (`./specs/` is `specs`), for the tree and for past commits alike.
195
+ const root = posix.normalize(String(loaded.root)).replace(/\/+$/, '');
196
+ if (!inside(String(loaded.root)) || !inside(root) || root === '.') {
197
+ throw new Fail(`config root ${loaded.root} is not a folder inside the repository; nothing was read or written`, 'set root: in .assuredloop/config.yaml to a folder in this repo, such as specs');
198
+ }
199
+ // Nothing is read through a symlink, which can lead out of the repo: read() refuses for each
200
+ // file, and the folders that are walked or listed are refused here, empty or not
201
+ // (the check of requests/archive looks at requests/ on its way).
202
+ for (const dir of [root, 'requests/archive']) {
203
+ if (symlinkOn(top, dir)) throw new Fail(`${dir} is reached through the symlink ${symlinkOn(top, dir)}; nothing was read or written`, `make ${symlinkOn(top, dir)} a real folder in this repo`);
204
+ }
205
+ // A doc that config names outside the repo, or through a symlink, is neither read nor indexed.
206
+ const why = (file) => (!inside(file) ? 'outside the repository' : symlinkOn(top, posix.normalize(file)) ? 'through a symlink' : null);
207
+ const config = { ...loaded, root, docs: loaded.docs.filter((d) => !why(String(d.file ?? ''))) };
208
+ for (const d of loaded.docs) if (why(String(d.file ?? ''))) notes.push(`not indexed: ${d.file}: ${why(String(d.file ?? ''))}`);
209
+ if (symlinkOn(top, `${root}/adr`)) notes.push(`not indexed: ${root}/adr: through a symlink`);
210
+ const names = requestNames(top);
211
+
212
+ // The base: the merge-base with main; with no main, HEAD (so a committed
213
+ // link with no binding stays unknown); with no commit, nothing.
214
+ const main = mergeBase(top);
215
+ const head = git(top, ['rev-parse', '--verify', '--quiet', 'HEAD'], { allowFail: true });
216
+ const base = main ?? head ?? null;
217
+ if (!main) notes.push(`base unknown: no main branch${head ? '; links already committed with no binding stay unknown' : ' and no commit; every link is new'}`);
218
+
219
+ // The records of the open requests, and the requirement versions first, so bindings read them.
220
+ const recs = new Map();
221
+ for (const n of names.open) {
222
+ let rec = null;
223
+ try { rec = openRecord(top, n); } catch (e) { if (!e.invalid) throw e; notes.push(`not read: ${e.message}`); continue; }
224
+ if (!rec) { notes.push(`no record: requests/${n} has no ${recordPath(n)}`); continue; }
225
+ rec.changed = addVersions(rec, requirements(read(top, `requests/${n}/request.md`) ?? '')).length > 0;
226
+ recs.set(n, rec);
227
+ }
228
+
229
+ const now = loadState(top, null, config, names);
230
+ for (const [n, rec] of recs) now.requests.get(n).data = rec.data;
231
+ const repos = new Map(outputRepos(top, loaded).filter((r) => r.name).map((r) => [r.name, r]));
232
+ const links = linksOf(top, now, config, true, repos);
233
+ const atBase = new Set(base ? linksOf(top, loadState(top, base, config, names), config, false).map(tripleKey) : []);
234
+
235
+ // --align <ID>: the ID must be something that holds links now.
236
+ const align = opts.align;
237
+ if (align !== undefined) {
238
+ const froms = new Set([...now.docs.flatMap((d) => d.paragraphs.map((p) => (d.request ? `${d.request}/${p.id}` : p.id))),
239
+ ...[...now.requests.values()].filter((r) => r.open).flatMap((r) => [...r.reqs.map((q) => `${r.name}/${q.id}`), ...(r.data?.outputs ?? []).map((o) => (o && foreign(o, config) ? `${o.repo}/${o.file}` : o?.file))])]);
240
+ if (!froms.has(align)) throw new Fail(`--align ${align}: no paragraph, requirement or output with that ID (a change spec's ID is <request>/<ID>)`, 'al index --align <ID>');
241
+ }
242
+
243
+ let bound = 0;
244
+ const seen = new Set();
245
+ const once = (line) => { if (!seen.has(line)) { seen.add(line); body.push(line); } };
246
+ for (const l of links) {
247
+ const name = `${l.holder} ${l.link} ${l.target}`;
248
+ if (l.t.cross) { once(`not bound: ${name}: cross-repo`); continue; }
249
+ const homes = l.homes.filter((h) => recs.has(h));
250
+ if (!homes.length) { once(`not bound: ${name}: no request record holds it`); continue; }
251
+ const aligning = align !== undefined && l.holder === align;
252
+ for (const h of homes) {
253
+ const rec = recs.get(h);
254
+ const items = seq(rec, 'bindings').items;
255
+ const at = items.findIndex((it) => it?.get?.('holder') === l.holder && it.get('link') === l.link && it.get('target') === l.target);
256
+ if (at >= 0 && !aligning) continue;
257
+ if (at < 0 && !aligning && atBase.has(tripleKey(l))) { once(`binding unknown: ${name}`); continue; }
258
+ if (l.unknown) { once(`unknown: ${name}: ${l.unknown}`); continue; }
259
+ const target = targetOf(l.t, now);
260
+ if (!target) { once(`not bound: ${name}: target not found`); continue; }
261
+ if (l.holderSha === null) { once(`not bound: ${name}: output file not found`); continue; }
262
+ const entry = { holder: l.holder, link: l.link, target: l.target, holder_sha256: l.holderSha, target_sha256: target.sha };
263
+ if (target.version !== undefined) entry.target_version = target.version;
264
+ if (l.commit) entry.commit = l.commit;
265
+ const node = rec.doc.createNode(entry);
266
+ node.flow = true;
267
+ if (at >= 0) {
268
+ const old = items[at].toJSON();
269
+ items[at] = node;
270
+ body.push(`aligned: ${name}: ${short(old.holder_sha256)} -> ${short(entry.holder_sha256)}, ${short(old.target_sha256)} -> ${short(entry.target_sha256)}`);
271
+ } else {
272
+ append(rec, 'bindings', entry, true);
273
+ if (aligning) body.push(`aligned: ${name}: unknown -> ${short(entry.holder_sha256)}, ${short(entry.target_sha256)}`);
274
+ else bound += 1;
275
+ }
276
+ rec.changed = true;
277
+ }
278
+ }
279
+
280
+ // Dispositions: the hashes of the source version, and of the spec text it
281
+ // produced, frozen when first indexed.
282
+ for (const [n, rec] of recs) {
283
+ const own = new Set(now.requests.get(n).paras.keys());
284
+ for (const it of seq(rec, 'dispositions').items) {
285
+ if (!it?.get) continue;
286
+ const source = it.get('source');
287
+ // `adoption` names the captured baseline, which has no hash here (T17).
288
+ if (source && source !== 'adoption' && !it.has('source_sha256')) {
289
+ const t = targetOf(qualify(String(source), n, own, isCentral(config)), now);
290
+ if (t) { it.set('source_sha256', t.sha); rec.changed = true; } else body.push(`disposition: ${source} not found, so its hash is not filled`);
291
+ }
292
+ const spec = it.get('spec');
293
+ if (it.get('disposition') === 'incorporated' && spec && !it.has('spec_sha256')) {
294
+ const t = targetOf(qualify(String(spec), null, new Set(), isCentral(config)), now);
295
+ if (t) { it.set('spec_sha256', t.sha); rec.changed = true; } else body.push(`disposition: ${spec} not found, so its spec hash is not filled`);
296
+ }
297
+ }
298
+ // A wording decision names its pair, {source, spec}; its hashes are filled
299
+ // once, as a disposition's are (the architect, #179, D15).
300
+ for (const it of seq(rec, 'decisions').items) {
301
+ const w = it?.get?.('wording');
302
+ if (!isMap(w)) continue;
303
+ for (const [name, key, request] of [['source', 'source_sha256', n], ['spec', 'target_sha256', null]]) {
304
+ const ref = w.get(name);
305
+ if (!ref || w.has(key)) continue;
306
+ const t = targetOf(qualify(String(ref), request, request ? own : new Set()), now);
307
+ if (t) { w.set(key, t.sha); rec.changed = true; } else body.push(`decision ${it.get('id')}: ${ref} not found, so its wording hash is not filled`);
308
+ }
309
+ }
310
+ }
311
+
312
+ // The per-doc records: regenerable fields, written fresh; hints kept by ID.
313
+ // A change spec's IDs are its request's own, so they are compared as <request>/<ID>.
314
+ const qualified = (docs) => docs.flatMap((d) => d.paragraphs.map((p) => (d.request ? { ...p, id: `${d.request}/${p.id}` } : p)));
315
+ const diffs = main ? diffParagraphs(qualified(loadState(top, main, config, names).docs), qualified(now.docs)) : null;
316
+ const own = (d, id) => (d.request ? `${d.request}/${id}` : id);
317
+ const changeOf = new Map((diffs ?? []).filter((x) => x.sha256 !== null).map((x) => [`${x.file}\t${x.id}`, x.changes]));
318
+ const outputs = [];
319
+ for (const d of now.docs) {
320
+ const path = docRecordPath(d.file);
321
+ const old = read(top, path);
322
+ let hints = new Map();
323
+ if (old !== null) {
324
+ let data = null;
325
+ try { data = CONFLICT.test(old) ? null : parse(old); } catch { data = null; }
326
+ if (data && typeof data === 'object') hints = new Map((data.paragraphs ?? []).filter((p) => p?.id && p.hint).map((p) => [p.id, p.hint]));
327
+ else body.push(`hints dropped: ${path} could not be read, so it was rebuilt with no hints`);
328
+ }
329
+ const status = d.adr ? d.text.split('\n').find((l) => l.trim())?.match(/^Status:\s*([a-z]+)/i)?.[1].toLowerCase() : undefined;
330
+ const paragraphs = d.paragraphs.map((p) => {
331
+ const change = changeOf.get(`${d.file}\t${own(d, p.id)}`) ?? [];
332
+ const hint = hints.get(p.id);
333
+ if (hint && stale(hint, p)) body.push(`refresh hint: ${d.file} ${p.id}`);
334
+ return { id: p.id, ...(p.kind ? { kind: p.kind } : {}), text_sha256: p.sha256, display: p.displayNumber, ...(change.length ? { change } : {}), ...(hint ? { hint } : {}) };
335
+ });
336
+ const removed = (diffs ?? []).filter((x) => x.sha256 === null && x.file === d.file).map((x) => (d.request ? x.id.slice(d.request.length + 1) : x.id));
337
+ const rec = { schema: SCHEMA, file: d.file, ...(status ? { status } : {}), paragraphs, ...(removed.length ? { removed } : {}) };
338
+ outputs.push([path, stringify(rec, YAML_OPTIONS)]);
339
+ }
340
+ for (const [, rec] of recs) if (rec.changed) outputs.push([rec.path, recordText(rec)]);
341
+ guard(top, outputs.map(([path]) => path));
342
+ const written = outputs.filter(([path, text]) => write(top, path, text)).map(([path]) => path);
343
+
344
+ return {
345
+ body: [`Indexed ${now.docs.length} docs and ${recs.size} request records; bound ${bound} new links`, ...(written.length ? [`Wrote ${written.join(', ')}`] : ['No record changed']), ...notes, ...body],
346
+ next: body.some((l) => l.startsWith('refresh hint:')) ? 'refresh the hints listed, then al index'
347
+ : body.some((l) => l.startsWith('binding unknown:')) ? 'a binding unknown was in the marker at the base and its record is gone: check the link, then al index --align <its holder ID>' : 'al check',
348
+ };
349
+ }
350
+
351
+ // A hint is stale when its paragraph changed since it was made, or its quote
352
+ // no longer points at one place in the paragraph.
353
+ function stale(hint, p) {
354
+ if (hint.basis_sha256 !== p.sha256) return true;
355
+ const q = hint.quote;
356
+ if (!q || q.exact === undefined) return false;
357
+ const exact = String(q.exact);
358
+ const count = (s) => (s ? p.text.split(s).length - 1 : 0);
359
+ if (q.prefix !== undefined || q.suffix !== undefined) return count(`${q.prefix ?? ''}${exact}${q.suffix ?? ''}`) !== 1;
360
+ return count(exact) !== 1;
361
+ }