@assuredloop/cli 0.2.4 → 2.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 +274 -21
- package/bin/al.js +8 -2
- package/package.json +1 -1
- package/skills/assuredloop/SKILL.md +40 -17
- package/src/v4/blocks.js +333 -0
- package/src/v4/check.js +37 -16
- package/src/v4/checks.js +71 -20
- package/src/v4/commands.js +3 -1
- package/src/v4/conclude.js +8 -4
- package/src/v4/indexer.js +121 -15
- package/src/v4/issues.js +297 -0
- package/src/v4/markers.js +63 -0
- package/src/v4/phrases.js +78 -0
- package/src/v4/scope.js +13 -1
- package/src/v4/state.js +36 -9
package/src/v4/blocks.js
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
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
|
+
for (const b of a.by) if (out.has(b)) out.get(b).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, directly or through a chain (#1), in the same marker or
|
|
216
|
+
// the same paragraph. Gives (text, { skip, exempt }) => [{ line, id, link,
|
|
217
|
+
// adr, next }]. al check --issues reads the issues with the same rule
|
|
218
|
+
// (issue-hints D7).
|
|
219
|
+
export function supersededIn(state) {
|
|
220
|
+
const down = supersedes(state);
|
|
221
|
+
const up = (id) => [...down].filter(([, xs]) => xs.has(id)).map(([y]) => y);
|
|
222
|
+
// The ADRs that supersede `id`, directly or through a chain; never `id`
|
|
223
|
+
// itself, when the chain is a cycle.
|
|
224
|
+
const above = (id) => {
|
|
225
|
+
const seen = new Set([id]);
|
|
226
|
+
const walk = (x) => { for (const y of up(x)) if (!seen.has(y)) { seen.add(y); walk(y); } };
|
|
227
|
+
walk(id);
|
|
228
|
+
seen.delete(id);
|
|
229
|
+
return seen;
|
|
230
|
+
};
|
|
231
|
+
const superseded = (id) => state.adrs.get(id)?.status === 'superseded';
|
|
232
|
+
return (textOf, { skip = null, exempt = new Set() } = {}) => places(textOf, skip).flatMap((p) => [...p.names]
|
|
233
|
+
.filter((x) => superseded(x) && !exempt.has(x) && ![...above(x)].some((y) => p.names.has(y)))
|
|
234
|
+
.map((x) => ({ line: p.line, id: p.id, link: p.link, adr: x, next: up(x) })));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// The Markdown files of an open request's folder, in its subfolders too, but
|
|
238
|
+
// origin/; a symlink to a Markdown file or a folder, or a subfolder that
|
|
239
|
+
// cannot be read, is given as { unreadable, why }.
|
|
240
|
+
export function requestMarkdown(top, dir, sub = '') {
|
|
241
|
+
let entries = [];
|
|
242
|
+
try {
|
|
243
|
+
entries = readdirSync(join(top, dir, sub), { withFileTypes: true });
|
|
244
|
+
} catch (e) {
|
|
245
|
+
return sub && e.code !== 'ENOENT' ? [{ unreadable: sub, why: e.code ?? e.message }] : [];
|
|
246
|
+
}
|
|
247
|
+
return entries.sort((a, b) => (a.name < b.name ? -1 : 1)).flatMap((e) => {
|
|
248
|
+
const f = sub ? `${sub}/${e.name}` : e.name;
|
|
249
|
+
if (e.isSymbolicLink()) return f !== 'origin' && placeLink(top, `${dir}/${f}`) ? [{ unreadable: f, why: SYMLINK }] : [];
|
|
250
|
+
if (e.isDirectory()) return f === 'origin' ? [] : requestMarkdown(top, dir, f);
|
|
251
|
+
return e.isFile() && e.name.endsWith('.md') ? [f] : [];
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// R5: a current place that names a superseded ADR names an ADR that
|
|
256
|
+
// supersedes it, in the same marker or the same paragraph; and each
|
|
257
|
+
// superseded ADR's status line names an ADR whose marker supersedes it.
|
|
258
|
+
export function supersededBlocks(top, state, add) {
|
|
259
|
+
const down = supersedes(state);
|
|
260
|
+
const cited = supersededIn(state);
|
|
261
|
+
// The ADRs that `id` supersedes, directly or through a chain.
|
|
262
|
+
const chain = (id) => {
|
|
263
|
+
const seen = new Set();
|
|
264
|
+
const walk = (x) => { for (const y of down.get(x) ?? []) if (!seen.has(y)) { seen.add(y); walk(y); } };
|
|
265
|
+
walk(id);
|
|
266
|
+
return seen;
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
for (const a of state.adrs.values()) {
|
|
270
|
+
if (a.status !== 'superseded') continue;
|
|
271
|
+
const where = `${a.file}:1`;
|
|
272
|
+
if (!a.by.length) 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>"`);
|
|
273
|
+
for (const b of a.by) {
|
|
274
|
+
if (!state.adrs.has(b)) add('not ok', 'superseded-status', where, a.id, `${a.id} says it is superseded by ${b}, which does not exist`);
|
|
275
|
+
else if (!(state.adrs.get(b).head?.links.supersedes ?? []).some((x) => adrOf(x) === a.id)) {
|
|
276
|
+
add('not ok', 'superseded-status', where, a.id, `${a.id} says it is superseded by ${b}, but the marker of ${b} has no supersedes:${a.id}`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const check = (file, textOf, { skip = null, exempt = new Set(), name = null } = {}) => {
|
|
282
|
+
for (const c of cited(textOf, { skip, exempt })) {
|
|
283
|
+
const id = c.id ? (name ? `${name}/${c.id}` : c.id) : '-';
|
|
284
|
+
add('not ok', 'superseded-cited', `${file}:${c.line}`, id, citedMessage(c));
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
// A current place that cannot be read: its citations are not checked, and
|
|
288
|
+
// a read failure never hides a block (SCH-37).
|
|
289
|
+
const unreadable = (path, why) => add('not ok', 'superseded-cited', path, '-', `${path} cannot be read (${why}), so its citations of superseded ADRs are not checked`);
|
|
290
|
+
// A symlinked Markdown file or folder under the spec root (the ADRs too) is not read.
|
|
291
|
+
const linked = (dir) => {
|
|
292
|
+
let entries = [];
|
|
293
|
+
try {
|
|
294
|
+
entries = readdirSync(join(top, dir), { withFileTypes: true });
|
|
295
|
+
} catch {
|
|
296
|
+
entries = [];
|
|
297
|
+
}
|
|
298
|
+
return entries.flatMap((e) => (e.isSymbolicLink() ? (placeLink(top, `${dir}/${e.name}`) ? [`${dir}/${e.name}`] : []) : e.isDirectory() ? linked(`${dir}/${e.name}`) : []));
|
|
299
|
+
};
|
|
300
|
+
// Only a root that is in the repo and not through a symlink is walked
|
|
301
|
+
// (scope.js); the docs that config lists are spec docs too.
|
|
302
|
+
const links = new Set([...(rootNotOk(top, state.config) ? [] : linked(state.root)), ...linkedDocs(top, state.config)]);
|
|
303
|
+
for (const f of [...links].sort()) unreadable(f, SYMLINK);
|
|
304
|
+
// The spec docs, each paragraph with its marker.
|
|
305
|
+
for (const d of state.docs ?? []) if (d.scope === SPEC && typeof d.text === 'string') check(d.path, d.text);
|
|
306
|
+
// The Markdown files of each open request, but origin/ and the recorded decisions.
|
|
307
|
+
for (const r of state.requests.values()) {
|
|
308
|
+
if (!r.open) continue;
|
|
309
|
+
for (const f of requestMarkdown(top, r.dir)) {
|
|
310
|
+
if (f.unreadable) {
|
|
311
|
+
unreadable(`${r.dir}/${f.unreadable}`, f.why);
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
let t;
|
|
315
|
+
let why = 'it is there, but it cannot be read as a file';
|
|
316
|
+
try {
|
|
317
|
+
t = read(top, `${r.dir}/${f}`);
|
|
318
|
+
} catch (e) {
|
|
319
|
+
t = null;
|
|
320
|
+
why = e.message.split('\n')[0];
|
|
321
|
+
}
|
|
322
|
+
if (t === null) unreadable(`${r.dir}/${f}`, why);
|
|
323
|
+
else check(`${r.dir}/${f}`, t, { name: r.name, skip: f === 'request.md' ? /^#{1,2}\s+Decisions\s*$/ : null });
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
// An ADR file that cannot be read: neither its status nor its citations are known.
|
|
327
|
+
for (const file of state.unreadableAdrs ?? []) unreadable(file, 'it is there, but it cannot be read as a file');
|
|
328
|
+
// Each proposed or accepted ADR, but its History section and the ADRs it supersedes.
|
|
329
|
+
for (const a of state.adrs.values()) {
|
|
330
|
+
if (!['proposed', 'accepted'].includes(a.status)) continue;
|
|
331
|
+
check(a.file, a.text, { skip: /^#{1,2}\s+History\s*$/, exempt: chain(a.id) });
|
|
332
|
+
}
|
|
333
|
+
}
|
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
|
@@ -12,11 +12,14 @@ import { diffParagraphs } from './ids.js';
|
|
|
12
12
|
import { crossResults } from './repos.js';
|
|
13
13
|
import { loadResults } from './results.js';
|
|
14
14
|
import {
|
|
15
|
-
hasBaselineEffect, isPromise, loadState, named, qualify, requestOf, resolve, servesSigned,
|
|
15
|
+
adrParagraph, hasBaselineEffect, isPromise, loadState, named, qualify, requestOf, resolve, servesSigned,
|
|
16
16
|
} from './state.js';
|
|
17
17
|
import { NEAR, closeness, diffWords, marks, requirementWords, sentencesOf, showHunk } from './words.js';
|
|
18
|
-
import { groupOf, sameBlocks } from './markers.js';
|
|
19
|
-
import { isCentral } from './indexer.js';
|
|
18
|
+
import { groupOf, markerLines, onlyLinksAdded, sameBlocks } from './markers.js';
|
|
19
|
+
import { canAlignTo, declaredNow, 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]));
|
|
@@ -182,10 +185,18 @@ export function checks(top, { base, strict = false }) {
|
|
|
182
185
|
}
|
|
183
186
|
}
|
|
184
187
|
|
|
185
|
-
//
|
|
188
|
+
// In the central repo, `central:X` is its own X (design.md 12).
|
|
189
|
+
const self = (ref) => (isCentral(now.config) && ref.startsWith('central:') ? ref.slice('central:'.length) : ref);
|
|
190
|
+
|
|
191
|
+
// Requirement words in a non-promise kind (design.md 3). A paragraph that
|
|
192
|
+
// builds on a promise paragraph that exists applies that promise: no hint.
|
|
193
|
+
const appliesPromise = (p, request) => {
|
|
194
|
+
const own = new Set(now.requests.get(request)?.paras.keys() ?? []);
|
|
195
|
+
return p.links.buildsOn.some((ref) => promise(resolve(now, qualify(self(ref), request, own))?.p?.kind));
|
|
196
|
+
};
|
|
186
197
|
for (const c of changes) {
|
|
187
198
|
const p = c.head;
|
|
188
|
-
if (!p?.kind || promise(p.kind) || !c.changed) continue;
|
|
199
|
+
if (!p?.kind || promise(p.kind) || !c.changed || appliesPromise(p, c.request)) continue;
|
|
189
200
|
const w = requirementWords(p.text);
|
|
190
201
|
if (w.length) add('hint', 'promise-kind', p, c.name, `promise kind? a ${p.kind} paragraph holds ${w.join(', ')}`);
|
|
191
202
|
}
|
|
@@ -252,7 +263,7 @@ export function checks(top, { base, strict = false }) {
|
|
|
252
263
|
const who = named(r?.name ?? null, p.id);
|
|
253
264
|
if (word === 'removes' && !t.req && !now.spec.has(t.id) && r) continue; // its own removal, done
|
|
254
265
|
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('
|
|
266
|
+
else add('hint', 'unresolved-link', p, who, `${ref}: no such ID${adr ? ` (in ${adr.id})` : ''}`);
|
|
256
267
|
}
|
|
257
268
|
}
|
|
258
269
|
}
|
|
@@ -260,8 +271,7 @@ export function checks(top, { base, strict = false }) {
|
|
|
260
271
|
// Design links (design.md 3): a design or approach paragraph of a change
|
|
261
272
|
// spec that serves a requirement, where promise paragraphs of the same
|
|
262
273
|
// change spec serve it, and that builds on or changes none of them. In the
|
|
263
|
-
// central repo, `central:X` is its own X (
|
|
264
|
-
const self = (ref) => (isCentral(now.config) && ref.startsWith('central:') ? ref.slice('central:'.length) : ref);
|
|
274
|
+
// central repo, `central:X` is its own X (self, above).
|
|
265
275
|
for (const r of open) {
|
|
266
276
|
const own = new Set(r.paras.keys());
|
|
267
277
|
const key = (ref) => qualify(self(ref), r.name, own).key;
|
|
@@ -302,9 +312,24 @@ export function checks(top, { base, strict = false }) {
|
|
|
302
312
|
}
|
|
303
313
|
|
|
304
314
|
// Bindings whose target changed since they were bound (design.md 4, 6).
|
|
315
|
+
let declared = null;
|
|
316
|
+
let alignable = null;
|
|
305
317
|
for (const r of open) {
|
|
306
318
|
for (const b of Array.isArray(r.data?.bindings) ? r.data.bindings : []) {
|
|
307
319
|
if (!b?.target || !b.holder || !b.target_sha256) continue;
|
|
320
|
+
// A holder paragraph whose text changed since the link was bound
|
|
321
|
+
// (removed-links/R2, #11). Output files and requirements are not
|
|
322
|
+
// paragraphs; a link no longer declared is history (#2); the target
|
|
323
|
+
// must be one that al index --align can move the binding to.
|
|
324
|
+
if (b.holder_sha256) {
|
|
325
|
+
const hq = qualify(String(b.holder), null);
|
|
326
|
+
const hp = hq.req ? now.requests.get(hq.req)?.paras.get(hq.id) : now.spec.get(hq.id) ?? adrParagraph(now, hq.id);
|
|
327
|
+
declared ??= declaredNow(top, now);
|
|
328
|
+
alignable ??= canAlignTo(now);
|
|
329
|
+
if (hp && hp.sha256 !== b.holder_sha256 && declared(b) && alignable(b.target)) {
|
|
330
|
+
add('hint', 'holder-changed', hp, b.holder, `${b.link} ${b.target}: this paragraph changed after the link was bound. Check that it still ${b.link} ${b.target}, then al index --align ${b.holder}`);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
308
333
|
const t = qualify(String(b.target), null);
|
|
309
334
|
if (t.cross) continue;
|
|
310
335
|
const cur = resolve(now, t);
|
|
@@ -313,6 +338,9 @@ export function checks(top, { base, strict = false }) {
|
|
|
313
338
|
if ((Array.isArray(r.data?.dispositions) ? r.data.dispositions : []).some((d) => d?.source === b.holder && d.spec === b.target && d.spec_sha256 === cur.sha)) continue;
|
|
314
339
|
// A superseded ADR's links are history (schema.md 5).
|
|
315
340
|
if (now.adrs.get(String(b.holder))?.status === 'superseded') continue;
|
|
341
|
+
// A link that its holder no longer declares, or whose holder is gone, is history too (#2).
|
|
342
|
+
declared ??= declaredNow(top, now);
|
|
343
|
+
if (!declared(b)) continue;
|
|
316
344
|
const hq = qualify(String(b.holder), null);
|
|
317
345
|
const hp = hq.req ? now.requests.get(hq.req)?.paras.get(hq.id) : now.spec.get(hq.id);
|
|
318
346
|
const msg = /^[RQ]\d+$/.test(t.id ?? '')
|
|
@@ -346,27 +374,39 @@ export function checks(top, { base, strict = false }) {
|
|
|
346
374
|
const judged = judgeRequest(now, r, { typo, baseSpec });
|
|
347
375
|
for (const j of judged) {
|
|
348
376
|
if (j.status === 'pending') continue;
|
|
349
|
-
if (!j.valid) add(
|
|
377
|
+
if (!j.valid) add('hint', 'disposition', j.p, j.id, `${j.status} is not valid: ${j.reason}`);
|
|
350
378
|
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
379
|
}
|
|
352
380
|
const pending = judged.filter((j) => j.status === 'pending').map((j) => j.p.id);
|
|
353
381
|
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
382
|
}
|
|
355
383
|
|
|
356
|
-
// ADRs (schema.md 5).
|
|
357
|
-
//
|
|
358
|
-
|
|
359
|
-
|
|
384
|
+
// ADRs (schema.md 5). An ADR's text may be rewritten when its decision
|
|
385
|
+
// stays, so a change is a hint for the review (#238). Links may be added to
|
|
386
|
+
// the heading marker of an accepted ADR, with nothing else in that line
|
|
387
|
+
// changed (#4) and the rest unchanged; that gives no finding.
|
|
388
|
+
const linksAdded = (a, h) => a.status === 'accepted' && a.head && h.head && a.rest === h.rest
|
|
389
|
+
&& onlyLinksAdded(a.marker, h.marker, now.kinds);
|
|
360
390
|
for (const [id, a] of was?.adrs ?? []) {
|
|
361
391
|
if (!['accepted', 'superseded'].includes(a.status)) continue;
|
|
362
392
|
const h = now.adrs.get(id);
|
|
363
|
-
if (!h) add('
|
|
364
|
-
else if (h.body !== a.body && !linksAdded(a, h)) add('
|
|
393
|
+
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`);
|
|
394
|
+
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`);
|
|
395
|
+
}
|
|
396
|
+
// Every ADR, whatever its status (#4): a first line that is not a known
|
|
397
|
+
// status, and a marker line that holds more than its ID, a kind and links.
|
|
398
|
+
for (const a of now.adrs.values()) {
|
|
399
|
+
if (!a.known) add('hint', 'adr-status', `${a.file}:1`, a.id, `${a.id}'s first line is not a status that al knows: write "Status: proposed", "Status: accepted" or "Status: superseded by ADR-<n>" (or by a list, "ADR-11, ADR-12")`);
|
|
400
|
+
for (const m of markerLines(a.text, now.kinds)) {
|
|
401
|
+
const where = `${a.file}:${m.line}`;
|
|
402
|
+
if (!m.extras) add('hint', 'adr-marker', where, m.id, `the marker line of ${m.id} is not a well-formed marker, so al does not read it: write "<!-- ${m.id} <kind> <links> -->" alone on the line; a page can show text after its -->`);
|
|
403
|
+
else if (m.extras.length) add('hint', 'adr-marker', where, m.id, `the marker of ${m.id} holds more than its ID, a kind and links: ${m.extras.map((x) => `"${x}"`).join(', ')}; a page can show text that it hides`);
|
|
404
|
+
}
|
|
365
405
|
}
|
|
366
406
|
for (const a of now.adrs.values()) {
|
|
367
407
|
if (!['proposed', 'accepted'].includes(a.status)) continue;
|
|
368
408
|
const missing = ['source', 'decides'].filter((w) => !a.head?.links[w].length);
|
|
369
|
-
if (missing.length) add(
|
|
409
|
+
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`);
|
|
370
410
|
}
|
|
371
411
|
for (const a of now.adrs.values()) {
|
|
372
412
|
for (const old of a.head?.links.supersedes ?? []) {
|
|
@@ -381,6 +421,17 @@ export function checks(top, { base, strict = false }) {
|
|
|
381
421
|
for (const a of govern) add('hint', 'adr-governs', p, c.id, `check that ${a.id} still holds`);
|
|
382
422
|
}
|
|
383
423
|
|
|
424
|
+
// The rules that block (#238): unsigned requirements, record evidence,
|
|
425
|
+
// unreadable records and citations of superseded ADRs.
|
|
426
|
+
recordBlocks(top, now, add);
|
|
427
|
+
supersededBlocks(top, now, add);
|
|
428
|
+
|
|
429
|
+
// Phrases for the review (issue-hints R2), and with --issues <folder> the
|
|
430
|
+
// hints from the work items that the caller saved (issue-hints R1); without
|
|
431
|
+
// it, no item is read and nothing is added.
|
|
432
|
+
phraseFindings(top, now, phrases, add);
|
|
433
|
+
if (issues) notKnown.push(...issueHints(top, now, phrases, add, issues));
|
|
434
|
+
|
|
384
435
|
// Results (design.md 9): whether each one applies to the current text. The
|
|
385
436
|
// commits they ran at are looked up in one batch.
|
|
386
437
|
const results = loadResults(top);
|