@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.
@@ -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`. --strict raises every marker lint and an invalid
7
- // disposition to `not ok`; the hints that need judgment stay hints. A lost ID
8
- // of an open request's change spec is a hint, also under --strict (#230).
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
- export function check({ top, opts }) {
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, strict: opts.strict });
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(l)} ${l.code} ${l.file}:${l.line} ${l.id ?? '-'} ${l.message}`));
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
- // Under --strict every marker lint is a not ok.
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, severity: 'hint', staysHint: true, message };
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, strict = false }) {
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('not ok', '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`);
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('not ok', 'path-claim', p, c.name, `path 1d, but a ${p.kind} paragraph is ${changeWord(c)}; 1d is for names and wording of design paragraphs`);
156
- else if (DESIGN.includes(p.kind) && (!c.head || !c.base)) add('not ok', 'path-claim', p, c.name, `path 1d, but a ${p.kind} paragraph is ${changeWord(c)}; 1d adds or removes no design paragraph`);
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
- // Requirement words in a non-promise kind (design.md 3).
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('not ok', 'unresolved-link', p, who, `${ref}: no such ID${adr ? ` (in ${adr.id})` : ''}`);
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 (design.md 12).
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(strict ? 'not ok' : 'hint', 'disposition', j.p, j.id, `${j.status} is not valid: ${j.reason}`);
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). Links may be added to the heading marker of an
357
- // accepted ADR: the same kind, each old link kept, the rest unchanged.
358
- const linksAdded = (a, h) => a.status === 'accepted' && a.head && h.head && a.rest === h.rest && a.head.kind === h.head.kind
359
- && Object.entries(a.head.links).every(([k, refs]) => refs.every((x) => h.head.links[k].includes(x)));
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('not ok', 'adr-changed', a.file, id, `${id} was ${a.status} and its file is gone; an ADR is superseded, never edited or deleted`);
364
- else if (h.body !== a.body && !linksAdded(a, h)) add('not ok', 'adr-changed', h.head ?? h.file, id, `the text of ${id} changed after it was accepted; write a new ADR that supersedes it`);
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(strict ? 'not ok' : '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`);
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);