@assuredloop/cli 0.2.3 → 1.0.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/README.md +147 -14
- package/bin/al.js +8 -2
- package/package.json +1 -1
- package/skills/assuredloop/SKILL.md +39 -13
- package/src/v4/blocks.js +321 -0
- package/src/v4/check.js +37 -16
- package/src/v4/checks.js +33 -9
- package/src/v4/commands.js +3 -1
- package/src/v4/conclude.js +8 -4
- package/src/v4/indexer.js +4 -10
- package/src/v4/issues.js +256 -0
- package/src/v4/phrases.js +78 -0
- package/src/v4/scope.js +13 -1
- package/src/v4/state.js +34 -8
- package/src/v4/views.js +5 -2
package/src/v4/blocks.js
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
// The rules that block (#238, design.md 3, 6 and 10, schema.md 5): the few
|
|
2
|
+
// findings that are `not ok` because an exact check of the records, hashes
|
|
3
|
+
// and links decides them, and breaking them lets an unapproved or wrong
|
|
4
|
+
// decision be built. Every other finding of al check is a hint.
|
|
5
|
+
// - unsigned-requirement: the latest version of each requirement has a valid
|
|
6
|
+
// sign-off (R3);
|
|
7
|
+
// - record-evidence: each file that a record lists with a hash still has it (R4);
|
|
8
|
+
// - superseded-cited and superseded-status: a current place that names a
|
|
9
|
+
// superseded ADR names its successor, and the status line names it (R5);
|
|
10
|
+
// - record-unreadable: a request record that cannot be read or is not a YAML
|
|
11
|
+
// mapping, or whose request has no request.md.
|
|
12
|
+
// Each finding is { severity, code, where, id, message }, as in checks.js.
|
|
13
|
+
import { readdirSync, statSync } from 'node:fs';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { read, recordPath, sha256 } from './base.js';
|
|
16
|
+
import { SIGNED } from './snapshot.js';
|
|
17
|
+
import { SPEC, linkedDocs, rootNotOk } from './scope.js';
|
|
18
|
+
|
|
19
|
+
const isMap = (x) => !!x && typeof x === 'object' && !Array.isArray(x);
|
|
20
|
+
const text = (x) => typeof x === 'string' && x.length > 0;
|
|
21
|
+
const whole = (x) => Number.isInteger(x) && x > 0;
|
|
22
|
+
const REQ = /^[RQ]\d+$/;
|
|
23
|
+
const SYMLINK = 'it is a symlink, and al does not read through a symlink';
|
|
24
|
+
// A symlink that stands for a current place: to a Markdown file, or to a
|
|
25
|
+
// folder (its type is read from the link, nothing through it).
|
|
26
|
+
const placeLink = (top, path) => {
|
|
27
|
+
if (path.endsWith('.md')) return true;
|
|
28
|
+
try {
|
|
29
|
+
return statSync(join(top, path)).isDirectory();
|
|
30
|
+
} catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// The bytes of a snapshot after its separator line, as al new and al record
|
|
36
|
+
// hashed them: the first line that is `---` (or `--- signed text ---`), or
|
|
37
|
+
// only `--- signed text ---` for a sign-off. Null when there is none.
|
|
38
|
+
function snapshotText(bytes, signedOnly) {
|
|
39
|
+
const seps = signedOnly ? [`\n${SIGNED}\n`] : ['\n---\n', `\n${SIGNED}\n`];
|
|
40
|
+
const at = seps.map((s) => [bytes.indexOf(s), s]).filter(([i]) => i >= 0).sort((a, b) => a[0] - b[0])[0];
|
|
41
|
+
return at ? bytes.subarray(at[0] + at[1].length) : null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// What a record's file name in origin/ gives: { hash } of its snapshot text,
|
|
45
|
+
// or { problem } that says why it has none.
|
|
46
|
+
function evidence(top, r, file, signedOnly) {
|
|
47
|
+
const name = String(file).replace(/^origin\//, '');
|
|
48
|
+
if (!/^[^/\\]+$/.test(name) || name === '.' || name === '..') return { problem: `${file} is not a file in ${r.dir}/origin/` };
|
|
49
|
+
let bytes;
|
|
50
|
+
try {
|
|
51
|
+
bytes = read(top, `${r.dir}/origin/${name}`, null);
|
|
52
|
+
} catch (e) {
|
|
53
|
+
return { problem: `${r.dir}/origin/${name} cannot be read: ${e.message}` };
|
|
54
|
+
}
|
|
55
|
+
if (bytes === null) return { problem: `${r.dir}/origin/${name} is missing` };
|
|
56
|
+
const t = snapshotText(bytes, signedOnly);
|
|
57
|
+
if (t === null) return { problem: `${r.dir}/origin/${name} has no ${signedOnly ? `"${SIGNED}"` : '"---"'} line, so its text cannot be read` };
|
|
58
|
+
return { hash: sha256(t) };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// R3, R4 and the unreadable record, for each request with a record, open or archived.
|
|
62
|
+
export function recordBlocks(top, state, add) {
|
|
63
|
+
// A record whose request has no request.md: nothing in it can be checked.
|
|
64
|
+
const folder = '.assuredloop/records/requests';
|
|
65
|
+
let records = [];
|
|
66
|
+
try {
|
|
67
|
+
records = readdirSync(join(top, folder), { withFileTypes: true }).filter((e) => e.name.endsWith('.yaml')).map((e) => e.name.slice(0, -5)).sort();
|
|
68
|
+
} catch (e) {
|
|
69
|
+
records = [];
|
|
70
|
+
if (e.code !== 'ENOENT') add('not ok', 'record-unreadable', folder, '-', `${folder} cannot be read (${e.code ?? e.message}), so no record in it is checked`);
|
|
71
|
+
}
|
|
72
|
+
for (const name of records.filter((n) => !state.requests.has(n))) {
|
|
73
|
+
const at = recordPath(name);
|
|
74
|
+
add('not ok', 'record-unreadable', at, name, `${at} has no request.md in requests/${name}/ or requests/archive/${name}/, so its requirements, sign-offs and evidence are not checked`);
|
|
75
|
+
}
|
|
76
|
+
for (const r of state.requests.values()) {
|
|
77
|
+
const at = recordPath(r.name);
|
|
78
|
+
const data = r.data;
|
|
79
|
+
if (r.recordError) {
|
|
80
|
+
add('not ok', 'record-unreadable', at, r.name, `${at} cannot be read (${r.recordError}), so its requirements, sign-offs and evidence are not checked; fix it by hand`);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (!data) continue;
|
|
84
|
+
const bad = (code, where, id, message) => add('not ok', code, where, `${r.name}/${id}`, message);
|
|
85
|
+
|
|
86
|
+
// R4: each origin file under sources:, and each sign-off file.
|
|
87
|
+
const sources = data.sources ?? [];
|
|
88
|
+
if (!Array.isArray(sources)) bad('record-evidence', at, 'sources', 'sources: is not a list, so its files cannot be checked');
|
|
89
|
+
for (const s of Array.isArray(sources) ? sources : []) {
|
|
90
|
+
if (!isMap(s) || !text(s.file) || !text(s.sha256)) {
|
|
91
|
+
bad('record-evidence', at, isMap(s) && text(s.file) ? s.file : '-', 'a sources entry with no file or no sha256');
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
const e = evidence(top, r, s.file, false);
|
|
95
|
+
if (e.problem) bad('record-evidence', at, s.file, e.problem);
|
|
96
|
+
else if (e.hash !== s.sha256) bad('record-evidence', at, s.file, `${r.dir}/origin/${s.file.replace(/^origin\//, '')} changed: its text no longer has the sha256 that the record lists`);
|
|
97
|
+
}
|
|
98
|
+
const signoffs = data.signoff ?? [];
|
|
99
|
+
if (!Array.isArray(signoffs)) bad('unsigned-requirement', at, 'signoff', 'signoff: is not a list, so no sign-off can be read');
|
|
100
|
+
// Each sign-off: why it is not valid (R3), or null.
|
|
101
|
+
const why = new Map();
|
|
102
|
+
for (const s of Array.isArray(signoffs) ? signoffs : []) {
|
|
103
|
+
const id = isMap(s) && text(s.id) ? s.id : '-';
|
|
104
|
+
if (!isMap(s) || !text(s.id) || !text(s.file) || !text(s.sha256) || !Array.isArray(s.covers)) {
|
|
105
|
+
if (isMap(s) && !(text(s.file) && text(s.sha256))) bad('record-evidence', at, id, 'a sign-off with no file or no sha256');
|
|
106
|
+
bad('unsigned-requirement', at, id, 'a sign-off entry that cannot be read: it needs id, file, sha256 and a covers list');
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
const e = evidence(top, r, s.file, true);
|
|
110
|
+
if (e.problem) bad('record-evidence', at, s.file, e.problem);
|
|
111
|
+
else if (e.hash !== s.sha256) bad('record-evidence', at, s.file, `${r.dir}/origin/${s.file.replace(/^origin\//, '')} changed: its signed text no longer has the sha256 of ${s.id}`);
|
|
112
|
+
const covers = s.covers.filter((c) => isMap(c) && text(c.id) && whole(c.version) && text(c.sha256));
|
|
113
|
+
if (covers.length < s.covers.length) {
|
|
114
|
+
bad('unsigned-requirement', at, s.id, `${s.id} has a covers entry that cannot be read: it needs id, a whole version above 0 and sha256`);
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const problem = e.problem ? `its file: ${e.problem}`
|
|
118
|
+
: e.hash !== s.sha256 ? 'its file\'s signed text does not have its sha256'
|
|
119
|
+
: !(text(s.signed) && !Number.isNaN(Date.parse(s.signed))) ? 'it does not record when it was signed'
|
|
120
|
+
: !text(s.words) ? 'it does not record the signer\'s words' : null;
|
|
121
|
+
why.set(s, problem);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// R3: only a request whose record has requirements:.
|
|
125
|
+
if (!Object.hasOwn(data, 'requirements')) continue;
|
|
126
|
+
if (!Array.isArray(data.requirements)) {
|
|
127
|
+
bad('unsigned-requirement', at, 'requirements', 'requirements: is not a list, so no requirement can be read');
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const latest = new Map();
|
|
131
|
+
for (const v of data.requirements) {
|
|
132
|
+
if (!isMap(v) || !(typeof v.id === 'string' && REQ.test(v.id)) || !whole(v.version) || !text(v.sha256)) {
|
|
133
|
+
bad('unsigned-requirement', at, isMap(v) && text(v.id) ? v.id : '-', 'a requirement entry that cannot be read: it needs an id R<n> or Q<n>, a whole version above 0 and sha256');
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (!latest.has(v.id) || v.version > latest.get(v.id).version) latest.set(v.id, v);
|
|
137
|
+
}
|
|
138
|
+
const md = `${r.dir}/request.md`;
|
|
139
|
+
const next = `run al index, then show it to the owner: al record ${r.name} signoff`;
|
|
140
|
+
const shown = new Set();
|
|
141
|
+
for (const q of r.reqs) {
|
|
142
|
+
const v = latest.get(q.id);
|
|
143
|
+
if (!v) bad('unsigned-requirement', md, q.id, `${q.id} is in request.md, but the record has no version of it, so no sign-off covers it; ${next}`);
|
|
144
|
+
else if (v.sha256 !== q.sha256) bad('unsigned-requirement', md, q.id, `${q.id} in request.md is not its latest recorded version ${v.version}: it is a new version that no sign-off covers; ${next}`);
|
|
145
|
+
else continue;
|
|
146
|
+
shown.add(q.id);
|
|
147
|
+
}
|
|
148
|
+
for (const v of latest.values()) {
|
|
149
|
+
if (shown.has(v.id)) continue;
|
|
150
|
+
const covering = [...why.keys()].filter((s) => s.covers.some((c) => c.id === v.id && c.version === v.version && c.sha256 === v.sha256));
|
|
151
|
+
if (covering.some((s) => why.get(s) === null)) continue;
|
|
152
|
+
const reason = covering.length
|
|
153
|
+
? covering.map((s) => `${s.id} covers it, but is not valid: ${why.get(s)}`).join('; ')
|
|
154
|
+
: `no sign-off covers version ${v.version} with its sha256`;
|
|
155
|
+
bad('unsigned-requirement', md, v.id, `${v.id} version ${v.version} is not signed: ${reason}; ${next}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// The ADRs that each ADR supersedes, directly: those its marker names in
|
|
161
|
+
// supersedes:, and those whose status line names it.
|
|
162
|
+
function supersedes(state) {
|
|
163
|
+
const out = new Map([...state.adrs.keys()].map((id) => [id, new Set()]));
|
|
164
|
+
for (const a of state.adrs.values()) {
|
|
165
|
+
for (const x of (a.head?.links.supersedes ?? []).map(adrOf).filter(Boolean)) out.get(a.id).add(x);
|
|
166
|
+
if (a.by && out.has(a.by)) out.get(a.by).add(a.id);
|
|
167
|
+
}
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// What a superseded-ADR finding says (supersededIn).
|
|
172
|
+
export const citedMessage = (c) => (c.next.length
|
|
173
|
+
? `names ${c.adr}, which is superseded, but not ${c.next.join(' or ')}, which supersedes it, ${c.link ? 'in the same marker' : 'in the same paragraph'}`
|
|
174
|
+
: `names ${c.adr}, which is superseded, and no ADR supersedes it (see superseded-status)`);
|
|
175
|
+
|
|
176
|
+
// The ADR that a link value or word names: `ADR-n` and `ADR-n-k` name ADR-n.
|
|
177
|
+
const ADR_REF = /^ADR-(\d+)(?:-\d+)?$/;
|
|
178
|
+
const adrOf = (ref) => (ADR_REF.test(ref) ? `ADR-${Number(ADR_REF.exec(ref)[1])}` : null);
|
|
179
|
+
const MARKER = /^\s*<!--(.*)-->\s*$/;
|
|
180
|
+
|
|
181
|
+
// The places of a Markdown text: each marker line and each run of text lines,
|
|
182
|
+
// with the ADRs it names and the ID of the marker it falls under. A heading
|
|
183
|
+
// that `skip` matches starts a section that is not read, up to the next
|
|
184
|
+
// heading of level 1 or 2.
|
|
185
|
+
function places(textOf, skip) {
|
|
186
|
+
const lines = textOf.replace(/\r\n?/g, '\n').split('\n');
|
|
187
|
+
const out = [];
|
|
188
|
+
let id = null;
|
|
189
|
+
let skipping = false;
|
|
190
|
+
let run = null;
|
|
191
|
+
const close = () => {
|
|
192
|
+
if (run && run.names.size) out.push(run);
|
|
193
|
+
run = null;
|
|
194
|
+
};
|
|
195
|
+
lines.forEach((l, i) => {
|
|
196
|
+
if (/^#{1,2}\s/.test(l)) skipping = Boolean(skip && skip.test(l));
|
|
197
|
+
if (skipping || !l.trim()) { close(); return; }
|
|
198
|
+
const m = MARKER.exec(l);
|
|
199
|
+
if (m) {
|
|
200
|
+
close();
|
|
201
|
+
const tokens = m[1].trim().split(/\s+/).filter(Boolean);
|
|
202
|
+
if (tokens[0] && !tokens[0].includes(':')) id = tokens[0];
|
|
203
|
+
const names = new Set(tokens.filter((t) => t.indexOf(':') > 0).flatMap((t) => t.slice(t.indexOf(':') + 1).split(',')).map(adrOf).filter(Boolean));
|
|
204
|
+
if (names.size) out.push({ line: i + 1, id, link: true, names });
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (!run) run = { line: i + 1, id, link: false, names: new Set() };
|
|
208
|
+
for (const w of l.matchAll(/\bADR-(\d+)(?:-\d+)?\b/g)) run.names.add(`ADR-${Number(w[1])}`);
|
|
209
|
+
});
|
|
210
|
+
close();
|
|
211
|
+
return out;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// R5's rule on one text: each place that names a superseded ADR, but no ADR
|
|
215
|
+
// that supersedes it, in the same marker or the same paragraph. Gives
|
|
216
|
+
// (text, { skip, exempt }) => [{ line, id, link, adr, next }]. al check
|
|
217
|
+
// --issues reads the issues with the same rule (issue-hints D7).
|
|
218
|
+
export function supersededIn(state) {
|
|
219
|
+
const down = supersedes(state);
|
|
220
|
+
const up = (id) => [...down].filter(([, xs]) => xs.has(id)).map(([y]) => y);
|
|
221
|
+
const superseded = (id) => state.adrs.get(id)?.status === 'superseded';
|
|
222
|
+
return (textOf, { skip = null, exempt = new Set() } = {}) => places(textOf, skip).flatMap((p) => [...p.names]
|
|
223
|
+
.filter((x) => superseded(x) && !exempt.has(x) && !up(x).some((y) => p.names.has(y)))
|
|
224
|
+
.map((x) => ({ line: p.line, id: p.id, link: p.link, adr: x, next: up(x) })));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// The Markdown files of an open request's folder, in its subfolders too, but
|
|
228
|
+
// origin/; a symlink to a Markdown file or a folder, or a subfolder that
|
|
229
|
+
// cannot be read, is given as { unreadable, why }.
|
|
230
|
+
export function requestMarkdown(top, dir, sub = '') {
|
|
231
|
+
let entries = [];
|
|
232
|
+
try {
|
|
233
|
+
entries = readdirSync(join(top, dir, sub), { withFileTypes: true });
|
|
234
|
+
} catch (e) {
|
|
235
|
+
return sub && e.code !== 'ENOENT' ? [{ unreadable: sub, why: e.code ?? e.message }] : [];
|
|
236
|
+
}
|
|
237
|
+
return entries.sort((a, b) => (a.name < b.name ? -1 : 1)).flatMap((e) => {
|
|
238
|
+
const f = sub ? `${sub}/${e.name}` : e.name;
|
|
239
|
+
if (e.isSymbolicLink()) return f !== 'origin' && placeLink(top, `${dir}/${f}`) ? [{ unreadable: f, why: SYMLINK }] : [];
|
|
240
|
+
if (e.isDirectory()) return f === 'origin' ? [] : requestMarkdown(top, dir, f);
|
|
241
|
+
return e.isFile() && e.name.endsWith('.md') ? [f] : [];
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// R5: a current place that names a superseded ADR names an ADR that
|
|
246
|
+
// supersedes it, in the same marker or the same paragraph; and each
|
|
247
|
+
// superseded ADR's status line names an ADR whose marker supersedes it.
|
|
248
|
+
export function supersededBlocks(top, state, add) {
|
|
249
|
+
const down = supersedes(state);
|
|
250
|
+
const cited = supersededIn(state);
|
|
251
|
+
// The ADRs that `id` supersedes, directly or through a chain.
|
|
252
|
+
const chain = (id) => {
|
|
253
|
+
const seen = new Set();
|
|
254
|
+
const walk = (x) => { for (const y of down.get(x) ?? []) if (!seen.has(y)) { seen.add(y); walk(y); } };
|
|
255
|
+
walk(id);
|
|
256
|
+
return seen;
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
for (const a of state.adrs.values()) {
|
|
260
|
+
if (a.status !== 'superseded') continue;
|
|
261
|
+
const where = `${a.file}:1`;
|
|
262
|
+
if (!a.by) add('not ok', 'superseded-status', where, a.id, `${a.id} is superseded, but its status line does not say by which ADR: write "Status: superseded by ADR-<n>"`);
|
|
263
|
+
else if (!state.adrs.has(a.by)) add('not ok', 'superseded-status', where, a.id, `${a.id} says it is superseded by ${a.by}, which does not exist`);
|
|
264
|
+
else if (!(state.adrs.get(a.by).head?.links.supersedes ?? []).some((x) => adrOf(x) === a.id)) {
|
|
265
|
+
add('not ok', 'superseded-status', where, a.id, `${a.id} says it is superseded by ${a.by}, but the marker of ${a.by} has no supersedes:${a.id}`);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const check = (file, textOf, { skip = null, exempt = new Set(), name = null } = {}) => {
|
|
270
|
+
for (const c of cited(textOf, { skip, exempt })) {
|
|
271
|
+
const id = c.id ? (name ? `${name}/${c.id}` : c.id) : '-';
|
|
272
|
+
add('not ok', 'superseded-cited', `${file}:${c.line}`, id, citedMessage(c));
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
// A current place that cannot be read: its citations are not checked, and
|
|
276
|
+
// a read failure never hides a block (SCH-37).
|
|
277
|
+
const unreadable = (path, why) => add('not ok', 'superseded-cited', path, '-', `${path} cannot be read (${why}), so its citations of superseded ADRs are not checked`);
|
|
278
|
+
// A symlinked Markdown file or folder under the spec root (the ADRs too) is not read.
|
|
279
|
+
const linked = (dir) => {
|
|
280
|
+
let entries = [];
|
|
281
|
+
try {
|
|
282
|
+
entries = readdirSync(join(top, dir), { withFileTypes: true });
|
|
283
|
+
} catch {
|
|
284
|
+
entries = [];
|
|
285
|
+
}
|
|
286
|
+
return entries.flatMap((e) => (e.isSymbolicLink() ? (placeLink(top, `${dir}/${e.name}`) ? [`${dir}/${e.name}`] : []) : e.isDirectory() ? linked(`${dir}/${e.name}`) : []));
|
|
287
|
+
};
|
|
288
|
+
// Only a root that is in the repo and not through a symlink is walked
|
|
289
|
+
// (scope.js); the docs that config lists are spec docs too.
|
|
290
|
+
const links = new Set([...(rootNotOk(top, state.config) ? [] : linked(state.root)), ...linkedDocs(top, state.config)]);
|
|
291
|
+
for (const f of [...links].sort()) unreadable(f, SYMLINK);
|
|
292
|
+
// The spec docs, each paragraph with its marker.
|
|
293
|
+
for (const d of state.docs ?? []) if (d.scope === SPEC && typeof d.text === 'string') check(d.path, d.text);
|
|
294
|
+
// The Markdown files of each open request, but origin/ and the recorded decisions.
|
|
295
|
+
for (const r of state.requests.values()) {
|
|
296
|
+
if (!r.open) continue;
|
|
297
|
+
for (const f of requestMarkdown(top, r.dir)) {
|
|
298
|
+
if (f.unreadable) {
|
|
299
|
+
unreadable(`${r.dir}/${f.unreadable}`, f.why);
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
let t;
|
|
303
|
+
let why = 'it is there, but it cannot be read as a file';
|
|
304
|
+
try {
|
|
305
|
+
t = read(top, `${r.dir}/${f}`);
|
|
306
|
+
} catch (e) {
|
|
307
|
+
t = null;
|
|
308
|
+
why = e.message.split('\n')[0];
|
|
309
|
+
}
|
|
310
|
+
if (t === null) unreadable(`${r.dir}/${f}`, why);
|
|
311
|
+
else check(`${r.dir}/${f}`, t, { name: r.name, skip: f === 'request.md' ? /^#{1,2}\s+Decisions\s*$/ : null });
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
// An ADR file that cannot be read: neither its status nor its citations are known.
|
|
315
|
+
for (const file of state.unreadableAdrs ?? []) unreadable(file, 'it is there, but it cannot be read as a file');
|
|
316
|
+
// Each proposed or accepted ADR, but its History section and the ADRs it supersedes.
|
|
317
|
+
for (const a of state.adrs.values()) {
|
|
318
|
+
if (!['proposed', 'accepted'].includes(a.status)) continue;
|
|
319
|
+
check(a.file, a.text, { skip: /^#{1,2}\s+History\s*$/, exempt: chain(a.id) });
|
|
320
|
+
}
|
|
321
|
+
}
|
package/src/v4/check.js
CHANGED
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
// again, one ID in two files), then each paragraph's change (T8); then the
|
|
4
4
|
// checks that read records (T10, checks.js). The base is the merge-base of
|
|
5
5
|
// HEAD with main; the head is the working tree. It exits 0; with --strict, 1
|
|
6
|
-
// when it prints a `not ok`.
|
|
7
|
-
//
|
|
8
|
-
// of
|
|
6
|
+
// when it prints a `not ok`. A finding has the same severity in both modes:
|
|
7
|
+
// the marker and ID lints are hints for the review, and only the exact rules
|
|
8
|
+
// of checks.js are `not ok` (#238). With --issues <folder> it also checks the
|
|
9
|
+
// work items that the caller saved there (issue-hints R1).
|
|
10
|
+
import { relative } from 'node:path';
|
|
9
11
|
import { parse as parseYaml } from 'yaml';
|
|
10
12
|
import { read, recordPath } from './base.js';
|
|
11
13
|
import { loadConfig, loadSchema } from './config.js';
|
|
@@ -15,12 +17,30 @@ import { docsInScope, idsEverUsed, isShallow, notRead, parseScopes, rootNotOk, r
|
|
|
15
17
|
import { Fail } from './spec.js';
|
|
16
18
|
import { centralLookup } from './repos.js';
|
|
17
19
|
import { checks } from './checks.js';
|
|
20
|
+
import { loadPhrases } from './phrases.js';
|
|
21
|
+
import { loadItems } from './issues.js';
|
|
18
22
|
|
|
19
|
-
|
|
23
|
+
// A file or folder that al check cannot read, and that no check reports as
|
|
24
|
+
// not ok, stops it with a refusal: never an internal error, never a pass (#238).
|
|
25
|
+
const UNREADABLE = new Set(['EACCES', 'EPERM', 'EISDIR', 'ENOTDIR', 'ELOOP']);
|
|
26
|
+
export function check(args) {
|
|
27
|
+
try {
|
|
28
|
+
return checkAll(args);
|
|
29
|
+
} catch (e) {
|
|
30
|
+
if (e instanceof Fail || !UNREADABLE.has(e.code) || !e.path) throw e;
|
|
31
|
+
throw new Fail(`${relative(args.top, e.path)} cannot be read`, 'make it readable, then run al check again');
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function checkAll({ top, cwd, opts }) {
|
|
20
36
|
const config = loadConfig(top);
|
|
21
37
|
const root = rootProblem(config);
|
|
22
38
|
if (root) throw new Fail(root, 'fix root: in .assuredloop/config.yaml');
|
|
23
39
|
const { kinds } = loadSchema(top);
|
|
40
|
+
// A bad phrases: list is a refusal before anything is read (issue-hints D6).
|
|
41
|
+
const phrases = loadPhrases(config);
|
|
42
|
+
// The work items that the caller saved; a folder that is not there is a refusal.
|
|
43
|
+
const issues = opts.issues === undefined ? null : loadItems(cwd ?? top, opts.issues);
|
|
24
44
|
const base = mergeBase(top);
|
|
25
45
|
const now = parseScopes(docsInScope(top, config), kinds);
|
|
26
46
|
const was = base ? parseScopes(docsInScope(top, config, base), kinds) : null;
|
|
@@ -28,6 +48,7 @@ export function check({ top, opts }) {
|
|
|
28
48
|
// A change spec's removes: names spec paragraphs.
|
|
29
49
|
const removes = [...now.scopes].filter(([s]) => s !== SPEC).flatMap(([, ps]) => ps.flatMap((p) => p.links.removes));
|
|
30
50
|
|
|
51
|
+
// Each marker and ID lint is a hint for the review, in every mode (#238).
|
|
31
52
|
const lints = [...now.lints];
|
|
32
53
|
const changes = [];
|
|
33
54
|
for (const scope of new Set([...now.scopes.keys(), ...(was?.scopes.keys() ?? [])])) {
|
|
@@ -41,22 +62,20 @@ export function check({ top, opts }) {
|
|
|
41
62
|
changes.push(...diffParagraphs(before, head).filter((c) => c.changes.length));
|
|
42
63
|
}
|
|
43
64
|
|
|
44
|
-
const t10 = checks(top, { base,
|
|
65
|
+
const t10 = checks(top, { base, phrases, issues });
|
|
45
66
|
// An adopted paragraph needs no link while its text is the captured text (D16).
|
|
46
|
-
const shown = lints.filter((l) => !(l.code === 'no-link' && t10.adopted.has(l.id) && !l.file.startsWith('requests/')));
|
|
67
|
+
const shown = lints.filter((l) => !(l.code === 'no-link' && t10.adopted.has(l.id) && !l.file.startsWith('requests/'))).map((l) => ({ ...l, severity: 'hint' }));
|
|
47
68
|
lints.length = 0;
|
|
48
69
|
lints.push(...shown);
|
|
49
|
-
const severity = (l) => (opts.strict && !l.staysHint ? 'not ok' : l.severity);
|
|
50
70
|
const body = notRead(top, config);
|
|
51
71
|
// A root that config sets but that is not read is a not ok, as in al index (#218).
|
|
52
72
|
const rootLine = rootNotOk(top, config);
|
|
53
73
|
if (rootLine) body.push(rootLine);
|
|
54
|
-
body.push(...lints.map((l) => `${severity
|
|
74
|
+
body.push(...lints.map((l) => `${l.severity} ${l.code} ${l.file}:${l.line} ${l.id ?? '-'} ${l.message}`));
|
|
55
75
|
if (base) body.push(...changes.map((c) => `${c.file} ${c.id} ${c.changes.join('+')}`));
|
|
56
76
|
if (!lints.length) body.push('ok: no marker lint');
|
|
57
77
|
body.push(...t10.findings.map((f) => `${f.severity} ${f.code} ${f.where} ${f.id} ${f.message}`));
|
|
58
|
-
|
|
59
|
-
const notOk = Boolean(rootLine) || lints.some((l) => severity(l) === 'not ok') || t10.findings.some((f) => f.severity === 'not ok');
|
|
78
|
+
const notOk = Boolean(rootLine) || lints.some((l) => l.severity === 'not ok') || t10.findings.some((f) => f.severity === 'not ok');
|
|
60
79
|
// In an output repo: the central IDs that the branch's changed files cite (T12).
|
|
61
80
|
const central = centralLookup(top);
|
|
62
81
|
body.push(...central.body);
|
|
@@ -67,8 +86,9 @@ export function check({ top, opts }) {
|
|
|
67
86
|
return {
|
|
68
87
|
body,
|
|
69
88
|
read: base ? `working tree · base ${base.slice(0, 7)} (merge-base with main)` : 'working tree · no base',
|
|
70
|
-
next: nextStep([...(rootLine ? ['not ok'] : []), ...lints.map(severity), ...t10.findings.map((f) => f.severity),
|
|
71
|
-
...central.body.map((l) => (/^not ok /.test(l) ? 'not ok' : /^hint /.test(l) ? 'hint' : 'info'))], lints.some((l) => l.code === 'no-id')
|
|
89
|
+
next: nextStep([...(rootLine ? ['not ok'] : []), ...lints.map((l) => l.severity), ...t10.findings.map((f) => f.severity),
|
|
90
|
+
...central.body.map((l) => (/^not ok /.test(l) ? 'not ok' : /^hint /.test(l) ? 'hint' : 'info'))], lints.some((l) => l.code === 'no-id'),
|
|
91
|
+
t10.findings.some((f) => f.code === 'unsigned-requirement')),
|
|
72
92
|
notKnown,
|
|
73
93
|
exit: opts.strict && notOk ? 1 : 0,
|
|
74
94
|
};
|
|
@@ -96,15 +116,16 @@ function deleted(top, l) {
|
|
|
96
116
|
...list(rec?.decisions).filter((d) => d?.wording?.source === source).map((d) => `decision ${d.id} names its wording`),
|
|
97
117
|
];
|
|
98
118
|
const message = `${source} is deleted${facts.map((f) => `; ${f}`).join('')}. If that is on purpose, nothing more is needed: the ID is retired and never used again.`;
|
|
99
|
-
return { ...l,
|
|
119
|
+
return { ...l, message };
|
|
100
120
|
}
|
|
101
121
|
|
|
102
122
|
// The Next line names what to do for what printed: a not ok, a hint, both, or
|
|
103
|
-
// neither (#198).
|
|
104
|
-
function nextStep(severities, noId) {
|
|
123
|
+
// neither (#198); and for an unsigned requirement, how to get it signed (#238).
|
|
124
|
+
function nextStep(severities, noId, unsigned) {
|
|
105
125
|
const notOk = severities.includes('not ok');
|
|
106
126
|
const hint = severities.includes('hint');
|
|
107
|
-
const addIds = noId ? '; al spec --add-ids <file> marks the paragraphs with no ID' : ''
|
|
127
|
+
const addIds = (noId ? '; al spec --add-ids <file> marks the paragraphs with no ID' : '')
|
|
128
|
+
+ (unsigned ? '; for an unsigned requirement, run al index, then ask the owner to sign it (al record <name> signoff)' : '');
|
|
108
129
|
if (notOk && hint) return `fix each not ok, and deal with each hint or say in the PR why it stays${addIds}`;
|
|
109
130
|
if (notOk) return `fix each not ok${addIds}`;
|
|
110
131
|
if (hint) return 'deal with each hint, or say in the PR why it stays';
|
package/src/v4/checks.js
CHANGED
|
@@ -17,6 +17,9 @@ import {
|
|
|
17
17
|
import { NEAR, closeness, diffWords, marks, requirementWords, sentencesOf, showHunk } from './words.js';
|
|
18
18
|
import { groupOf, sameBlocks } from './markers.js';
|
|
19
19
|
import { isCentral } from './indexer.js';
|
|
20
|
+
import { recordBlocks, supersededBlocks } from './blocks.js';
|
|
21
|
+
import { phraseFindings } from './phrases.js';
|
|
22
|
+
import { issueHints } from './issues.js';
|
|
20
23
|
|
|
21
24
|
const DESIGN = ['component', 'interface', 'data', 'flow', 'choice'];
|
|
22
25
|
const TIER = /^[ \t]*Tier:[ \t]*(0|1d|1|2|3|S)\b[ \t]*(?:—|–|--?)?[ \t]*(.*)$/m;
|
|
@@ -122,7 +125,7 @@ export function branchOf(top, now, was, base) {
|
|
|
122
125
|
return { changes, captured, notAdopted, claim, commits, typo, isPromiseChange, uncovered };
|
|
123
126
|
}
|
|
124
127
|
|
|
125
|
-
export function checks(top, { base,
|
|
128
|
+
export function checks(top, { base, phrases = [], issues = null }) {
|
|
126
129
|
const now = loadState(top);
|
|
127
130
|
const was = base ? loadState(top, base) : null;
|
|
128
131
|
const findings = [];
|
|
@@ -144,7 +147,7 @@ export function checks(top, { base, strict = false }) {
|
|
|
144
147
|
} else if (claim.tier === '0') {
|
|
145
148
|
for (const c of changes) {
|
|
146
149
|
if (typo.has(c.name)) continue;
|
|
147
|
-
if (isPromiseChange(c)) add('
|
|
150
|
+
if (isPromiseChange(c)) add('hint', 'path-claim', c.head ?? c.base, c.name, `path 0, but a ${(c.head ?? c.base).kind} paragraph is ${changeWord(c)} with no typo claim`);
|
|
148
151
|
else if (!c.request && ['design', 'informative'].includes(groupOf((c.head ?? c.base).kind, now.kinds))) {
|
|
149
152
|
add('hint', 'path-claim', c.head ?? c.base, c.name, `path 0, but a ${(c.head ?? c.base).kind} paragraph is ${changeWord(c)}: path 1d or 2?`);
|
|
150
153
|
}
|
|
@@ -152,8 +155,8 @@ export function checks(top, { base, strict = false }) {
|
|
|
152
155
|
} else if (claim.tier === '1d') {
|
|
153
156
|
for (const c of changes) {
|
|
154
157
|
const p = c.head ?? c.base;
|
|
155
|
-
if (isPromiseChange(c)) add('
|
|
156
|
-
else if (DESIGN.includes(p.kind) && (!c.head || !c.base)) add('
|
|
158
|
+
if (isPromiseChange(c)) add('hint', 'path-claim', p, c.name, `path 1d, but a ${p.kind} paragraph is ${changeWord(c)}; 1d is for names and wording of design paragraphs`);
|
|
159
|
+
else if (DESIGN.includes(p.kind) && (!c.head || !c.base)) add('hint', 'path-claim', p, c.name, `path 1d, but a ${p.kind} paragraph is ${changeWord(c)}; 1d adds or removes no design paragraph`);
|
|
157
160
|
}
|
|
158
161
|
}
|
|
159
162
|
const byName = new Map(changes.map((c) => [c.name, c]));
|
|
@@ -252,7 +255,7 @@ export function checks(top, { base, strict = false }) {
|
|
|
252
255
|
const who = named(r?.name ?? null, p.id);
|
|
253
256
|
if (word === 'removes' && !t.req && !now.spec.has(t.id) && r) continue; // its own removal, done
|
|
254
257
|
if (!t.req && removerOf(t.id)) add('hint', 'target-removed', p, who, `${ref}: target removed by ${removerOf(t.id)}; align this link`);
|
|
255
|
-
else add('
|
|
258
|
+
else add('hint', 'unresolved-link', p, who, `${ref}: no such ID${adr ? ` (in ${adr.id})` : ''}`);
|
|
256
259
|
}
|
|
257
260
|
}
|
|
258
261
|
}
|
|
@@ -346,19 +349,29 @@ export function checks(top, { base, strict = false }) {
|
|
|
346
349
|
const judged = judgeRequest(now, r, { typo, baseSpec });
|
|
347
350
|
for (const j of judged) {
|
|
348
351
|
if (j.status === 'pending') continue;
|
|
349
|
-
if (!j.valid) add(
|
|
352
|
+
if (!j.valid) add('hint', 'disposition', j.p, j.id, `${j.status} is not valid: ${j.reason}`);
|
|
350
353
|
for (const n of j.notes) add(n.startsWith('near-match') ? 'hint' : 'info', n.startsWith('near-match') ? 'near-match' : 'disposition', j.p, j.id, n.replace(/^near-match /, ''));
|
|
351
354
|
}
|
|
352
355
|
const pending = judged.filter((j) => j.status === 'pending').map((j) => j.p.id);
|
|
353
356
|
if (pending.length) add('info', 'disposition-pending', r.specFile, r.name, `${pending.length} of ${judged.length} paragraphs with a baseline effect have no disposition for their current version: ${pending.join(', ')}`);
|
|
354
357
|
}
|
|
355
358
|
|
|
356
|
-
// ADRs (schema.md 5).
|
|
359
|
+
// ADRs (schema.md 5). An ADR's text may be rewritten when its decision
|
|
360
|
+
// stays, so a change is a hint for the review (#238). Links may be added to
|
|
361
|
+
// the heading marker of an accepted ADR: the same kind, each old link kept,
|
|
362
|
+
// the rest unchanged; that gives no finding.
|
|
363
|
+
const linksAdded = (a, h) => a.status === 'accepted' && a.head && h.head && a.rest === h.rest && a.head.kind === h.head.kind
|
|
364
|
+
&& Object.entries(a.head.links).every(([k, refs]) => refs.every((x) => h.head.links[k].includes(x)));
|
|
357
365
|
for (const [id, a] of was?.adrs ?? []) {
|
|
358
366
|
if (!['accepted', 'superseded'].includes(a.status)) continue;
|
|
359
367
|
const h = now.adrs.get(id);
|
|
360
|
-
if (!h) add('
|
|
361
|
-
else if (h.body !== a.body) add('
|
|
368
|
+
if (!h) add('hint', 'adr-changed', a.file, id, `${id} was ${a.status} and its file is gone: check that its decision is not lost; an ADR that no longer holds is superseded by a new ADR, not deleted`);
|
|
369
|
+
else if (h.body !== a.body && !linksAdded(a, h)) add('hint', 'adr-changed', h.head ?? h.file, id, `${id}'s text changed: check that its decision is unchanged; if the decision changed, write a new ADR that supersedes it`);
|
|
370
|
+
}
|
|
371
|
+
for (const a of now.adrs.values()) {
|
|
372
|
+
if (!['proposed', 'accepted'].includes(a.status)) continue;
|
|
373
|
+
const missing = ['source', 'decides'].filter((w) => !a.head?.links[w].length);
|
|
374
|
+
if (missing.length) add('hint', 'adr-links', a.head ?? a.file, a.id, `${a.id} has ${missing.map((w) => `no ${w}:`).join(' and ')}; add the links to its marker`);
|
|
362
375
|
}
|
|
363
376
|
for (const a of now.adrs.values()) {
|
|
364
377
|
for (const old of a.head?.links.supersedes ?? []) {
|
|
@@ -373,6 +386,17 @@ export function checks(top, { base, strict = false }) {
|
|
|
373
386
|
for (const a of govern) add('hint', 'adr-governs', p, c.id, `check that ${a.id} still holds`);
|
|
374
387
|
}
|
|
375
388
|
|
|
389
|
+
// The rules that block (#238): unsigned requirements, record evidence,
|
|
390
|
+
// unreadable records and citations of superseded ADRs.
|
|
391
|
+
recordBlocks(top, now, add);
|
|
392
|
+
supersededBlocks(top, now, add);
|
|
393
|
+
|
|
394
|
+
// Phrases for the review (issue-hints R2), and with --issues <folder> the
|
|
395
|
+
// hints from the work items that the caller saved (issue-hints R1); without
|
|
396
|
+
// it, no item is read and nothing is added.
|
|
397
|
+
phraseFindings(top, now, phrases, add);
|
|
398
|
+
if (issues) notKnown.push(...issueHints(top, now, phrases, add, issues));
|
|
399
|
+
|
|
376
400
|
// Results (design.md 9): whether each one applies to the current text. The
|
|
377
401
|
// commits they ran at are looked up in one batch.
|
|
378
402
|
const results = loadResults(top);
|
package/src/v4/commands.js
CHANGED
|
@@ -107,11 +107,13 @@ function recordOrigin({ top, cwd, args, opts }) {
|
|
|
107
107
|
return { body: [`Wrote ${dir}/origin/${file} and source ${file} in ${rec.path}`], next: `name it in a requirement's marker: <!-- R<n> from:${file} -->` };
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
-
// al record <name> signoff --source <where>
|
|
110
|
+
// al record <name> signoff --source <where> --words <quote> [--presented <x>] [--transcribed-by <who>] [--yes]
|
|
111
111
|
function recordSignoff({ top, args, opts }) {
|
|
112
112
|
const [name] = args;
|
|
113
113
|
oneLine(opts, ['source', 'words', 'presented', 'transcribed-by']);
|
|
114
114
|
if (!opts.source) throw new Fail('--source <where the owner signed> is missing', `al record ${name} signoff --source <where> --words <quote>`);
|
|
115
|
+
// A sign-off with no words is not valid in al check (DES-89, D10).
|
|
116
|
+
if (typeof opts.words !== 'string' || !opts.words) throw new Fail('--words <the owner\'s words> is missing', `pass the owner's words: al record ${name} signoff --source <where> --words <quote>`);
|
|
115
117
|
const { dir, rec } = openRequest(top, name);
|
|
116
118
|
const md = read(top, `${dir}/request.md`);
|
|
117
119
|
const text = signedText(md);
|
package/src/v4/conclude.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// change of the branch, against the merge-base with main, counts as al check
|
|
7
7
|
// counts it (branchOf, #217). With
|
|
8
8
|
// --yes it writes the Outcome, sets the status and archives the request.
|
|
9
|
-
import { mkdirSync, renameSync } from 'node:fs';
|
|
9
|
+
import { mkdirSync, renameSync, statSync } from 'node:fs';
|
|
10
10
|
import { dirname, join } from 'node:path';
|
|
11
11
|
import { Fail, day, exists, guard, isName, now, write } from './base.js';
|
|
12
12
|
import { branchOf } from './checks.js';
|
|
@@ -112,13 +112,17 @@ export function conclude({ top, args, opts }) {
|
|
|
112
112
|
const block = outcome(state, r, judged, top);
|
|
113
113
|
const target = `requests/archive/${name}`;
|
|
114
114
|
if (exists(top, target)) throw new Fail(`${target} already exists; a request name is never reused`, `al context ${name}`);
|
|
115
|
+
// The preview refuses for each reason that --yes refuses (#241).
|
|
116
|
+
const rec = openRecord(top, name);
|
|
117
|
+
if (!rec) throw new Fail(`${name} has no record .assuredloop/records/requests/${name}.yaml`, 'a v4 request is made with al new');
|
|
118
|
+
guard(top, [`${r.dir}/request.md`, `${target}/request.md`, rec.path]);
|
|
119
|
+
if (statSync(join(top, dirname(target)), { throwIfNoEntry: false })?.isDirectory() === false) {
|
|
120
|
+
throw new Fail(`${dirname(target)} is not a folder; nothing was written`, `make ${dirname(target)} a folder, then al conclude ${name} again`);
|
|
121
|
+
}
|
|
115
122
|
const what = `Status: concluded, the Outcome in ${target}/request.md, status: concluded in the record, and ${r.dir}/ moved to ${target}/`;
|
|
116
123
|
if (!opts.yes) {
|
|
117
124
|
return { body: [`Would conclude ${name}: ${what}`, ...block.slice(2).map((l) => ` ${l}`)], read, next: 'run the same command with --yes to do it', notKnown };
|
|
118
125
|
}
|
|
119
|
-
const rec = openRecord(top, name);
|
|
120
|
-
if (!rec) throw new Fail(`${name} has no record .assuredloop/records/requests/${name}.yaml`, 'a v4 request is made with al new');
|
|
121
|
-
guard(top, [`${r.dir}/request.md`, `${target}/request.md`, rec.path]);
|
|
122
126
|
const md = withOutcome(withStatus(r.md), block);
|
|
123
127
|
rec.doc.set('status', 'concluded');
|
|
124
128
|
mkdirSync(dirname(join(top, target)), { recursive: true });
|
package/src/v4/indexer.js
CHANGED
|
@@ -6,7 +6,6 @@ import { readdirSync } from 'node:fs';
|
|
|
6
6
|
import { join, posix } from 'node:path';
|
|
7
7
|
import { isMap, parse, stringify } from 'yaml';
|
|
8
8
|
import { parseMarkdown } from './markers.js';
|
|
9
|
-
import { diffParagraphs } from './ids.js';
|
|
10
9
|
import { git, mergeBase, fileAt, filesAt } from './git.js';
|
|
11
10
|
import { loadConfig } from './config.js';
|
|
12
11
|
import { SPEC, docsInScope, symlinkOn } from './scope.js';
|
|
@@ -310,11 +309,8 @@ export function index({ top, opts }) {
|
|
|
310
309
|
}
|
|
311
310
|
|
|
312
311
|
// The per-doc records: regenerable fields, written fresh; hints kept by ID.
|
|
313
|
-
//
|
|
314
|
-
|
|
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]));
|
|
312
|
+
// Nothing in them depends on the branch or its base, so main's index
|
|
313
|
+
// rebuilds a merged record as it is (#245).
|
|
318
314
|
const outputs = [];
|
|
319
315
|
for (const d of now.docs) {
|
|
320
316
|
const path = docRecordPath(d.file);
|
|
@@ -328,13 +324,11 @@ export function index({ top, opts }) {
|
|
|
328
324
|
}
|
|
329
325
|
const status = d.adr ? d.text.split('\n').find((l) => l.trim())?.match(/^Status:\s*([a-z]+)/i)?.[1].toLowerCase() : undefined;
|
|
330
326
|
const paragraphs = d.paragraphs.map((p) => {
|
|
331
|
-
const change = changeOf.get(`${d.file}\t${own(d, p.id)}`) ?? [];
|
|
332
327
|
const hint = hints.get(p.id);
|
|
333
328
|
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, ...(
|
|
329
|
+
return { id: p.id, ...(p.kind ? { kind: p.kind } : {}), text_sha256: p.sha256, display: p.displayNumber, ...(hint ? { hint } : {}) };
|
|
335
330
|
});
|
|
336
|
-
const
|
|
337
|
-
const rec = { schema: SCHEMA, file: d.file, ...(status ? { status } : {}), paragraphs, ...(removed.length ? { removed } : {}) };
|
|
331
|
+
const rec = { schema: SCHEMA, file: d.file, ...(status ? { status } : {}), paragraphs };
|
|
338
332
|
outputs.push([path, stringify(rec, YAML_OPTIONS)]);
|
|
339
333
|
}
|
|
340
334
|
for (const [, rec] of recs) if (rec.changed) outputs.push([rec.path, recordText(rec)]);
|