@assuredloop/cli 1.0.0 → 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 CHANGED
@@ -97,7 +97,15 @@ Search at level 2 needs the library `@huggingface/transformers` 4.3.1 (see
97
97
  - **Bindings.** When a link is first indexed, `al index` records the hash of
98
98
  both ends. Ordinary indexing never moves a binding. When the target
99
99
  changes, the source gets a hint until someone checks it and runs
100
- `al index --align <ID>`.
100
+ `al index --align <ID>`. When the source paragraph's own text changes,
101
+ it gets the hint `holder-changed` until it is aligned in the same way.
102
+ This hint covers the links that a paragraph of a spec doc, an ADR or an
103
+ open change spec still declares, to a target that still resolves, in the
104
+ records of the open requests. A requirement is out, because a changed
105
+ requirement needs a new sign-off. An output file is out. When no current
106
+ marker declares a link any more, `al index` removes its binding, in the
107
+ open and the archived records, and names it in its output. Git history
108
+ keeps it.
101
109
  - **Consolidation is by hand.** A PR copies a change paragraph into `specs/`
102
110
  word for word, and records its disposition: `incorporated`, `removed`,
103
111
  `superseded` or `abandoned`. No command writes the spec.
@@ -139,9 +147,10 @@ with a baseline effect has no valid disposition for its current version.
139
147
  - `al spec`: every doc's IDs, display numbers and kinds.
140
148
  `al spec --add-ids <file> [--prefix <P>]`: give each unmarked paragraph the
141
149
  next free ID.
142
- - `al index`: write the derived facts and the new bindings, and list the AI
143
- hints to refresh. `al index --align <ID>` moves the bindings of one
144
- paragraph to its targets' current text, on purpose.
150
+ - `al index`: write the derived facts and the new bindings, remove the
151
+ bindings of links that no marker declares, and list the AI hints to
152
+ refresh. `al index --align <ID>` moves the bindings of one paragraph to its
153
+ targets' current text and to its own, on purpose.
145
154
  - `al check [--strict] [--issues <folder>]`: the checks of this branch
146
155
  against its base. With `--issues <folder>`, it also checks the work items
147
156
  that the caller saved in the folder (see "Hints from the linked work
@@ -192,16 +201,26 @@ default. A bad entry or an invalid pattern stops `al check` with exit 2.
192
201
  `al spec --add-ids` writes both files when they are absent.
193
202
 
194
203
  ADRs are `specs/adr/NNNN-<slug>.md`, with `Status: proposed`, `accepted` or
195
- `superseded by ADR-<n>` on the first line. The heading's marker holds
196
- `ADR-<n>` and its `decides:`, `source:` and `supersedes:` links.
204
+ `superseded by ADR-<n>` on the first line. A status line may name several
205
+ ADRs that supersede it: `superseded by ADR-11, ADR-12` or
206
+ `ADR-11 and ADR-12`. The heading's marker holds `ADR-<n>` and its
207
+ `decides:`, `source:` and `supersedes:` links.
197
208
 
198
209
  - Any text may be rewritten in other words when its meaning stays the same,
199
210
  an accepted or superseded ADR's text too. A new ADR that supersedes it is
200
211
  needed only when the decision changes, in whole or in part.
201
212
  - The review judges whether a rewrite keeps the decision; `al` does not. So
202
- a changed ADR text, or a changed or removed link in its marker, gives a
203
- hint: check that its decision is unchanged. Links added to the marker of
204
- an accepted ADR, with nothing else changed, give no finding.
213
+ a changed ADR text, a changed or removed link in its marker, or any other
214
+ change of its marker line, gives a hint: check that its decision is
215
+ unchanged. Links added to the marker of an accepted ADR, with nothing else
216
+ changed, give no finding.
217
+ - `al check` gives a hint for a marker line of an ADR that holds anything
218
+ other than its ID, its kind and links: an unknown token, a second `<!--`
219
+ or `-->`, or an empty item in a list of links. A line that starts like a
220
+ marker, but that `al` does not read as one, gets the same hint.
221
+ - `al check` gives a hint for an ADR whose first line is not
222
+ `Status: proposed`, `Status: accepted` or `Status: superseded by ADR-<n>`,
223
+ with one ADR or a list of them.
205
224
  - After a rewrite, `al index --align <ID>` moves the bindings of paragraph
206
225
  `<ID>` to the ADR's new text, for an accepted or a superseded ADR.
207
226
  - `al check` gives a hint for a proposed or accepted ADR with no `source:`
@@ -234,8 +253,7 @@ example `PROJ-12`. Each finding is a hint:
234
253
  GitHub reads as that repository's issue. The request's own repository
235
254
  comes from `git remote get-url origin`;
236
255
  - `issue-not-sub`: the item is not a child of the request's own item, the
237
- item that the `url` of an origin source names (`al record <name> origin
238
- --url <its URL>`);
256
+ item that `assuredloop-requests.json` in the folder names (below);
239
257
  - `phrase`: a phrase of `phrases:` matches;
240
258
  - `issue-unread`: a file in the folder is not valid JSON, or holds an entry
241
259
  that is not an item.
@@ -274,6 +292,104 @@ An item of another tracker:
274
292
  "comments": [{"body": "Done.", "isMinimized": false}], "parent": {"ref": "PROJ-1"}}
275
293
  ```
276
294
 
295
+ The folder also says which item is each request's own item, in the file
296
+ `assuredloop-requests.json`. `al` reads the own item from this file only,
297
+ never from an origin source. Without the file, or for a request that it
298
+ does not list, `issue-not-sub` is not checked, and the `Not known` line
299
+ says so once for each request. The file is not read as an item. A file that
300
+ is not valid gets `issue-unread` and is ignored; `al check` goes on.
301
+
302
+ - `format`: the exact text `assuredloop-items/1`;
303
+ - `requests`: each key is a request's name, and its value is that request's
304
+ own item, in a form that a task's `ref` uses: a URL, `owner/repo#n`, or
305
+ another tracker's key, for example `PROJ-1`.
306
+
307
+ ```json
308
+ {
309
+ "format": "assuredloop-items/1",
310
+ "requests": {
311
+ "invoice-exports": "https://github.com/acme/invoicer/issues/30",
312
+ "pay-run": "PROJ-1"
313
+ }
314
+ }
315
+ ```
316
+
317
+ To write it for a GitHub issue:
318
+
319
+ ```sh
320
+ printf '{"format": "assuredloop-items/1", "requests": {"invoice-exports": "%s"}}\n' \
321
+ "$(gh issue view 30 --repo acme/invoicer --json url --jq .url)" \
322
+ > /tmp/items/assuredloop-requests.json
323
+ ```
324
+
325
+ The JSON Schema (draft 2020-12) of each file, for a person or an AI that
326
+ writes them. An item file:
327
+
328
+ ```json
329
+ {
330
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
331
+ "title": "an item file: one item, or a list of items",
332
+ "oneOf": [
333
+ {"$ref": "#/$defs/item"},
334
+ {"type": "array", "items": {"$ref": "#/$defs/item"}}
335
+ ],
336
+ "$defs": {
337
+ "item": {
338
+ "type": "object",
339
+ "required": ["body"],
340
+ "anyOf": [
341
+ {"required": ["url"], "properties": {"url": {"type": "string", "pattern": "^https?://[^/\\s]+/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+/issues/[0-9]+([^A-Za-z0-9_][\\s\\S]*)?$"}}},
342
+ {"required": ["number", "repository"]},
343
+ {"required": ["ref"]}
344
+ ],
345
+ "properties": {
346
+ "body": {"type": "string"},
347
+ "url": {"type": "string"},
348
+ "number": {"type": "integer"},
349
+ "comments": {
350
+ "type": "array",
351
+ "items": {
352
+ "type": "object",
353
+ "required": ["body"],
354
+ "properties": {"body": {"type": "string"}, "isMinimized": {"type": "boolean"}}
355
+ }
356
+ },
357
+ "parent": {
358
+ "oneOf": [
359
+ {"type": "null"},
360
+ {
361
+ "type": "object",
362
+ "properties": {"url": {"type": "string"}, "number": {"type": "integer"}, "ref": {"type": "string", "minLength": 1}}
363
+ }
364
+ ]
365
+ },
366
+ "ref": {"type": "string", "minLength": 1},
367
+ "repository": {"type": "string", "minLength": 1}
368
+ }
369
+ }
370
+ }
371
+ }
372
+ ```
373
+
374
+ `assuredloop-requests.json`:
375
+
376
+ ```json
377
+ {
378
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
379
+ "title": "assuredloop-requests.json",
380
+ "type": "object",
381
+ "required": ["format", "requests"],
382
+ "additionalProperties": false,
383
+ "properties": {
384
+ "format": {"const": "assuredloop-items/1"},
385
+ "requests": {
386
+ "type": "object",
387
+ "additionalProperties": {"type": "string", "minLength": 1}
388
+ }
389
+ }
390
+ }
391
+ ```
392
+
277
393
  ## Search
278
394
 
279
395
  `al search` answers at the strongest level that is installed, falls back by
@@ -403,6 +519,13 @@ claim, a link that does not resolve, an ADR's changed text or missing links,
403
519
  an invalid disposition, and the rest. `--strict` changes only the exit code:
404
520
  it is 1 when a `not ok` prints.
405
521
 
522
+ `al check` gives the hint "promise kind?" (`promise-kind`) for a paragraph
523
+ that is not a promise kind and holds a requirement word: MUST, SHALL, SHOULD,
524
+ MAY, never or always. When the paragraph is a promise, change its kind. When
525
+ it applies a promise, for example a flow step that keeps a rule, give it
526
+ `builds-on:` the promise paragraph in its marker. The hint then goes. A
527
+ `builds-on:` to another kind, or to an ID that does not exist, keeps it.
528
+
406
529
  ## What is enforced, and by whom
407
530
 
408
531
  | The tool checks | The skill asks | The owner decides |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assuredloop/cli",
3
- "version": "1.0.0",
3
+ "version": "2.0.0",
4
4
  "description": "AssuredLoop keeps an AI focused on one change: the al command gathers and checks a change's requirement, spec, decisions, code and tests.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -92,9 +92,11 @@ The export link MUST expire 30 minutes after the email is sent.
92
92
  rewrite, run `al index --align <ID>` for each paragraph that links to it.
93
93
  - When an ADR is superseded, set its first line to
94
94
  `Status: superseded by ADR-<m>`, and put `supersedes:ADR-<n>` in the marker
95
- of ADR-m. Where a spec paragraph, an open request or a proposed or accepted
96
- ADR names ADR-n, name ADR-m beside it: in the same marker for a link, in
97
- the same paragraph for a word. Else `al check` gives `not ok`.
95
+ of ADR-m. When several ADRs supersede it, name each one:
96
+ `Status: superseded by ADR-11, ADR-12`. Where a spec paragraph, an open
97
+ request or a proposed or accepted ADR names ADR-n, name an ADR that
98
+ supersedes it beside it: in the same marker for a link, in the same
99
+ paragraph for a word. Else `al check` gives `not ok`.
98
100
  - `al` writes every hash and every derived field. Do not write them by hand.
99
101
 
100
102
  ## Path 0: a fix
package/src/v4/blocks.js CHANGED
@@ -163,7 +163,7 @@ function supersedes(state) {
163
163
  const out = new Map([...state.adrs.keys()].map((id) => [id, new Set()]));
164
164
  for (const a of state.adrs.values()) {
165
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);
166
+ for (const b of a.by) if (out.has(b)) out.get(b).add(a.id);
167
167
  }
168
168
  return out;
169
169
  }
@@ -212,15 +212,25 @@ function places(textOf, skip) {
212
212
  }
213
213
 
214
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).
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).
218
219
  export function supersededIn(state) {
219
220
  const down = supersedes(state);
220
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
+ };
221
231
  const superseded = (id) => state.adrs.get(id)?.status === 'superseded';
222
232
  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)))
233
+ .filter((x) => superseded(x) && !exempt.has(x) && ![...above(x)].some((y) => p.names.has(y)))
224
234
  .map((x) => ({ line: p.line, id: p.id, link: p.link, adr: x, next: up(x) })));
225
235
  }
226
236
 
@@ -259,10 +269,12 @@ export function supersededBlocks(top, state, add) {
259
269
  for (const a of state.adrs.values()) {
260
270
  if (a.status !== 'superseded') continue;
261
271
  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}`);
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
+ }
266
278
  }
267
279
  }
268
280
 
package/src/v4/checks.js CHANGED
@@ -12,11 +12,11 @@ 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
20
  import { recordBlocks, supersededBlocks } from './blocks.js';
21
21
  import { phraseFindings } from './phrases.js';
22
22
  import { issueHints } from './issues.js';
@@ -185,10 +185,18 @@ export function checks(top, { base, phrases = [], issues = null }) {
185
185
  }
186
186
  }
187
187
 
188
- // 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
+ };
189
197
  for (const c of changes) {
190
198
  const p = c.head;
191
- if (!p?.kind || promise(p.kind) || !c.changed) continue;
199
+ if (!p?.kind || promise(p.kind) || !c.changed || appliesPromise(p, c.request)) continue;
192
200
  const w = requirementWords(p.text);
193
201
  if (w.length) add('hint', 'promise-kind', p, c.name, `promise kind? a ${p.kind} paragraph holds ${w.join(', ')}`);
194
202
  }
@@ -263,8 +271,7 @@ export function checks(top, { base, phrases = [], issues = null }) {
263
271
  // Design links (design.md 3): a design or approach paragraph of a change
264
272
  // spec that serves a requirement, where promise paragraphs of the same
265
273
  // change spec serve it, and that builds on or changes none of them. In the
266
- // central repo, `central:X` is its own X (design.md 12).
267
- 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).
268
275
  for (const r of open) {
269
276
  const own = new Set(r.paras.keys());
270
277
  const key = (ref) => qualify(self(ref), r.name, own).key;
@@ -305,9 +312,24 @@ export function checks(top, { base, phrases = [], issues = null }) {
305
312
  }
306
313
 
307
314
  // Bindings whose target changed since they were bound (design.md 4, 6).
315
+ let declared = null;
316
+ let alignable = null;
308
317
  for (const r of open) {
309
318
  for (const b of Array.isArray(r.data?.bindings) ? r.data.bindings : []) {
310
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
+ }
311
333
  const t = qualify(String(b.target), null);
312
334
  if (t.cross) continue;
313
335
  const cur = resolve(now, t);
@@ -316,6 +338,9 @@ export function checks(top, { base, phrases = [], issues = null }) {
316
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;
317
339
  // A superseded ADR's links are history (schema.md 5).
318
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;
319
344
  const hq = qualify(String(b.holder), null);
320
345
  const hp = hq.req ? now.requests.get(hq.req)?.paras.get(hq.id) : now.spec.get(hq.id);
321
346
  const msg = /^[RQ]\d+$/.test(t.id ?? '')
@@ -358,16 +383,26 @@ export function checks(top, { base, phrases = [], issues = null }) {
358
383
 
359
384
  // ADRs (schema.md 5). An ADR's text may be rewritten when its decision
360
385
  // 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)));
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);
365
390
  for (const [id, a] of was?.adrs ?? []) {
366
391
  if (!['accepted', 'superseded'].includes(a.status)) continue;
367
392
  const h = now.adrs.get(id);
368
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`);
369
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`);
370
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
+ }
405
+ }
371
406
  for (const a of now.adrs.values()) {
372
407
  if (!['proposed', 'accepted'].includes(a.status)) continue;
373
408
  const missing = ['source', 'decides'].filter((w) => !a.head?.links[w].length);
package/src/v4/indexer.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // with the agents' hints kept), appends requirement versions, freezes the
3
3
  // bindings of new links and fills the hashes of new dispositions. Ordinary
4
4
  // indexing never advances a binding; only --align does (design.md 4, 10).
5
- import { readdirSync } from 'node:fs';
5
+ import { accessSync, constants, lstatSync, 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';
@@ -109,6 +109,9 @@ export const isCentral = (config) => Array.isArray(config.outputs) && config.out
109
109
  // An output in another repo (T12): its repo is named, and is not this one.
110
110
  const foreign = (o, config) => Boolean(o.repo) && o.repo !== config.repo && o.repo !== 'central';
111
111
 
112
+ // A record's declared outputs; a value that is not a list declares none, so al check still runs (#2).
113
+ const outputsOf = (r) => (Array.isArray(r.data?.outputs) ? r.data.outputs : []);
114
+
112
115
  // Every declared link of a state: { holder, holderSha, link, target, homes }, with
113
116
  // `homes` the requests whose record holds it. An output in another repo also
114
117
  // has `commit` (<repo>@<sha>), or `unknown` with the reason; `repos` are the
@@ -119,7 +122,7 @@ function linksOf(top, state, config, live, repos = new Map()) {
119
122
  const batches = new Map();
120
123
  const outputFiles = (repo) => {
121
124
  if (!batches.has(repo.name)) {
122
- const files = [...state.requests.values()].filter((r) => r.open).flatMap((r) => r.data?.outputs ?? [])
125
+ const files = [...state.requests.values()].filter((r) => r.open).flatMap(outputsOf)
123
126
  .filter((o) => o?.file && o.repo === repo.name).map((o) => String(o.file));
124
127
  batches.set(repo.name, filesAt(repo.dir, repo.sha, [...new Set(files)], null));
125
128
  }
@@ -139,7 +142,7 @@ function linksOf(top, state, config, live, repos = new Map()) {
139
142
  for (const f of q.from) out.push({ holder: `${r.name}/${q.id}`, holderSha: q.sha256, link: 'from', target: `${r.name}/${f}`, t: { req: r.name, id: f }, homes: [r.name] });
140
143
  }
141
144
  const own = new Set(r.paras.keys());
142
- for (const o of r.data?.outputs ?? []) {
145
+ for (const o of outputsOf(r)) {
143
146
  if (!o?.file) continue;
144
147
  const other = foreign(o, config);
145
148
  const repo = other ? repos.get(o.repo) : null;
@@ -185,6 +188,76 @@ export function targetOf(t, state) {
185
188
  const tripleKey = (l) => `${l.holder}\t${l.link}\t${l.target}`;
186
189
  const short = (h) => (h ? h.slice(0, 12) : 'unknown');
187
190
 
191
+ // Whether a binding's holder declares its link now: the same holder, link
192
+ // word and target (#2). It reads al check's own state (state.js), so each
193
+ // file is read once, by al check's rules.
194
+ export function declaredNow(top, state) {
195
+ const docs = [
196
+ { request: null, ids: new Set(), paragraphs: [...state.spec.values()].filter((p) => p.id) },
197
+ ...[...state.requests.values()].filter((r) => r.open).map((r) => ({ request: r.name, ids: new Set(r.paras.keys()), paragraphs: [...r.paras.values()].filter((p) => p.id) })),
198
+ ...[...state.adrs.values()].map((a) => ({ request: null, ids: new Set(), paragraphs: a.paragraphs.filter((p) => p.id) })),
199
+ ];
200
+ const keys = new Set(linksOf(top, { docs, requests: state.requests }, state.config, false).map(tripleKey));
201
+ return (b) => keys.has(tripleKey(b));
202
+ }
203
+
204
+ // Whether al index --align can move a binding to `target` now, by its own
205
+ // target rules, in al check's state (state.js): holder-changed names --align
206
+ // only where it works (#11).
207
+ export function canAlignTo(state) {
208
+ const adrs = [...state.adrs.values()].flatMap((a) => a.paragraphs.filter((p) => p.id).map((p) => [p.id, p]));
209
+ const view = { spec: new Map([...state.spec, ...adrs]), requests: state.requests };
210
+ const central = isCentral(state.config);
211
+ return (target) => Boolean(targetOf(qualify(String(target), null, new Set(), central), view));
212
+ }
213
+
214
+ // A state whose archived requests declare their links too: their change
215
+ // specs' markers, their requirements' from: and their records' outputs: (#11).
216
+ function everyDeclaration(state) {
217
+ const old = [...state.requests.values()].filter((r) => !r.open);
218
+ return {
219
+ docs: [...state.docs, ...old.map((r) => ({ request: r.name, ids: new Set(r.paras.keys()), paragraphs: [...r.paras.values()] }))],
220
+ requests: new Map([...state.requests].map(([n, r]) => [n, { ...r, open: true }])),
221
+ };
222
+ }
223
+
224
+ // The files and folders that can declare links but are not read, each with
225
+ // why: reached through a symlink, a file that cannot be read, or a folder that
226
+ // cannot be listed. They are the spec root's, the requests' (open and
227
+ // archived) request.md, spec.md and record, whose outputs: declare links, and
228
+ // the docs that config lists (#11).
229
+ function notRead(top, root, config) {
230
+ const out = [];
231
+ const blocked = (p) => {
232
+ let stat;
233
+ try { stat = lstatSync(join(top, p)); } catch { return false; }
234
+ if (stat.isSymbolicLink()) { out.push(`${p}: through a symlink`); return true; }
235
+ try { accessSync(join(top, p), stat.isDirectory() ? constants.R_OK | constants.X_OK : constants.R_OK); } catch { out.push(`${p}: cannot be read`); return true; }
236
+ return false;
237
+ };
238
+ const tree = (dir) => {
239
+ if (blocked(dir)) return;
240
+ for (const e of list(top, dir)) {
241
+ if (e.isDirectory()) tree(`${dir}/${e.name}`);
242
+ else blocked(`${dir}/${e.name}`);
243
+ }
244
+ };
245
+ tree(root);
246
+ const records = !blocked('.assuredloop/records/requests');
247
+ for (const dir of ['requests', 'requests/archive']) {
248
+ if (blocked(dir)) continue;
249
+ for (const e of list(top, dir)) {
250
+ const d = `${dir}/${e.name}`;
251
+ if (d === 'requests/archive' || (!e.isDirectory() && !e.isSymbolicLink()) || blocked(d)) continue;
252
+ blocked(`${d}/request.md`);
253
+ blocked(`${d}/spec.md`);
254
+ if (records) blocked(recordPath(e.name));
255
+ }
256
+ }
257
+ for (const d of config.docs) blocked(posix.normalize(String(d.file)));
258
+ return out;
259
+ }
260
+
188
261
  export function index({ top, opts }) {
189
262
  let loaded;
190
263
  try { loaded = loadConfig(top); } catch (e) { throw new Fail(e.message, 'fix .assuredloop/config.yaml'); }
@@ -239,13 +312,25 @@ export function index({ top, opts }) {
239
312
  if (!froms.has(align)) throw new Fail(`--align ${align}: no paragraph, requirement or output with that ID (a change spec's ID is <request>/<ID>)`, 'al index --align <ID>');
240
313
  }
241
314
 
315
+ // The records that already hold each binding: a holder whose marker names
316
+ // no request any more, such as a superseded ADR that dropped `source:`,
317
+ // still reaches the bindings it holds (#11).
318
+ const held = new Map();
319
+ for (const [n, rec] of recs) {
320
+ for (const it of rec.doc.get('bindings')?.items ?? []) {
321
+ if (!it?.get) continue;
322
+ const k = tripleKey({ holder: it.get('holder'), link: it.get('link'), target: it.get('target') });
323
+ held.set(k, [...(held.get(k) ?? []), n]);
324
+ }
325
+ }
326
+
242
327
  let bound = 0;
243
328
  const seen = new Set();
244
329
  const once = (line) => { if (!seen.has(line)) { seen.add(line); body.push(line); } };
245
330
  for (const l of links) {
246
331
  const name = `${l.holder} ${l.link} ${l.target}`;
247
332
  if (l.t.cross) { once(`not bound: ${name}: cross-repo`); continue; }
248
- const homes = l.homes.filter((h) => recs.has(h));
333
+ const homes = [...new Set([...l.homes.filter((h) => recs.has(h)), ...(held.get(tripleKey(l)) ?? [])])];
249
334
  if (!homes.length) { once(`not bound: ${name}: no request record holds it`); continue; }
250
335
  const aligning = align !== undefined && l.holder === align;
251
336
  for (const h of homes) {
@@ -276,6 +361,33 @@ export function index({ top, opts }) {
276
361
  }
277
362
  }
278
363
 
364
+ // Removed links (removed-links/R1, #11): a binding whose link no current
365
+ // marker, requirement or outputs: entry declares is removed from the records
366
+ // of the open and the archived requests, and named. An archived change spec
367
+ // and request.md still declare their links. While a file that can declare
368
+ // links is not read, nothing is removed: a missing file is not a removed link.
369
+ const archived = new Map();
370
+ for (const n of names.archived) {
371
+ try { const rec = openRecord(top, n); if (rec) archived.set(n, rec); } catch (e) { if (!e.invalid) throw e; notes.push(`not read: ${e.message}`); }
372
+ }
373
+ const unread = [...new Set([
374
+ ...notes.filter((l) => /^not (indexed|read): /.test(l)).map((l) => l.replace(/^not (indexed|read): /, '')),
375
+ ...notRead(top, root, config),
376
+ ])];
377
+ const declared = new Set(linksOf(top, everyDeclaration(now), config, false).map(tripleKey));
378
+ const undeclared = (it) => it?.get && !declared.has(tripleKey({ holder: it.get('holder'), link: it.get('link'), target: it.get('target') }));
379
+ const dead = [...recs, ...archived].flatMap(([n, rec]) => (rec.doc.get('bindings')?.items ?? []).filter(undeclared).map((it) => [n, rec, it]));
380
+ if (unread.length) {
381
+ body.push(`not removed: no binding is removed in this run, as al index could not read ${unread.join('; ')}`);
382
+ } else {
383
+ for (const [n, rec, it] of dead) {
384
+ const items = rec.doc.get('bindings').items;
385
+ items.splice(items.indexOf(it), 1);
386
+ body.push(`removed: ${it.get('holder')} ${it.get('link')} ${it.get('target')} from the record of ${n}: no longer declared`);
387
+ rec.changed = true;
388
+ }
389
+ }
390
+
279
391
  // Dispositions: the hashes of the source version, and of the spec text it
280
392
  // produced, frozen when first indexed.
281
393
  for (const [n, rec] of recs) {
@@ -331,7 +443,7 @@ export function index({ top, opts }) {
331
443
  const rec = { schema: SCHEMA, file: d.file, ...(status ? { status } : {}), paragraphs };
332
444
  outputs.push([path, stringify(rec, YAML_OPTIONS)]);
333
445
  }
334
- for (const [, rec] of recs) if (rec.changed) outputs.push([rec.path, recordText(rec)]);
446
+ for (const [, rec] of [...recs, ...archived]) if (rec.changed) outputs.push([rec.path, recordText(rec)]);
335
447
  guard(top, outputs.map(([path]) => path));
336
448
  const written = outputs.filter(([path, text]) => write(top, path, text)).map(([path]) => path);
337
449
 
package/src/v4/issues.js CHANGED
@@ -9,10 +9,11 @@
9
9
  // - issue-superseded: the item's text names a superseded ADR without its
10
10
  // successor, by R5's own code (supersededIn, D7);
11
11
  // - issue-bare-ref: a bare `#n` in an item of another repository;
12
- // - issue-not-sub: not a child of the request's own item (D3);
12
+ // - issue-not-sub: not a child of the request's own item, which the file
13
+ // assuredloop-requests.json of the folder names (own-item R1);
13
14
  // - phrase: a phrase of the config matches (R2, phrases.js);
14
15
  // - issue-unread: a file of the folder that is not valid JSON, or an entry
15
- // that is not an item.
16
+ // that is not an item, or an assuredloop-requests.json that is not valid.
16
17
  // An item's text is its body and its comments that are not minimized (D4).
17
18
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
18
19
  import { isAbsolute, join, resolve } from 'node:path';
@@ -30,6 +31,9 @@ const BARE = /(?<![\w/#&-])#(\d+)\b/g;
30
31
  const slug = (s) => String(s).toLowerCase();
31
32
  const isMap = (x) => !!x && typeof x === 'object' && !Array.isArray(x);
32
33
  const once = (re) => new RegExp(re.source);
34
+ // The file of the folder that names each request's own item (own-item SP-8).
35
+ const OWN_ITEMS = 'assuredloop-requests.json';
36
+ const FORMAT = 'assuredloop-items/1';
33
37
  // `text` holds `word` as a whole word: no letter, digit, `_` or `-` beside it.
34
38
  const holds = (text, word) => new RegExp(`(?<![\\w-])${word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w-])`).test(text);
35
39
 
@@ -64,8 +68,29 @@ function itemOf(x, file) {
64
68
  return { key, ref, url: typeof x.url === 'string' ? x.url : null, repository, number, body: x.body, comments: x.comments, parent: x.parent, file };
65
69
  }
66
70
 
67
- // The items of the folder that the caller saved (SP-12): { items, unread }.
68
- // A folder that is not there is a refusal.
71
+ // The own items that assuredloop-requests.json names (own-item SP-8, SP-9):
72
+ // { requests } (a Map of name to value), { absent: true }, or { why } when
73
+ // the file is not valid and so is ignored as a whole.
74
+ function ownItems(path) {
75
+ if (!existsSync(path)) return { absent: true };
76
+ let data;
77
+ try {
78
+ data = JSON.parse(readFileSync(path, 'utf8'));
79
+ } catch (e) {
80
+ return { why: e instanceof SyntaxError ? 'it is not valid JSON' : `it cannot be read (${e.code ?? e.message})` };
81
+ }
82
+ if (!isMap(data)) return { why: 'it is not a JSON object' };
83
+ const other = Object.keys(data).filter((k) => k !== 'format' && k !== 'requests');
84
+ if (other.length) return { why: `it has a field other than format and requests: ${other.join(', ')}` };
85
+ if (data.format !== FORMAT) return { why: `its format is not ${FORMAT}` };
86
+ if (!isMap(data.requests)) return { why: 'its requests is not an object' };
87
+ const bad = Object.entries(data.requests).filter(([, v]) => typeof v !== 'string' || !v).map(([k]) => k);
88
+ if (bad.length) return { why: `the value of ${bad.join(', ')} is not a text that is not empty` };
89
+ return { requests: new Map(Object.entries(data.requests)) };
90
+ }
91
+
92
+ // The items of the folder that the caller saved (SP-12): { items, unread,
93
+ // owns }. A folder that is not there is a refusal.
69
94
  export function loadItems(cwd, folder) {
70
95
  const dir = resolve(cwd, folder);
71
96
  let isDir = false;
@@ -78,7 +103,9 @@ export function loadItems(cwd, folder) {
78
103
  const items = [];
79
104
  const unread = [];
80
105
  const shown = (name) => `${folder.replace(/\/+$/, '')}/${name}`;
81
- for (const name of readdirSync(dir).filter((n) => n.endsWith('.json')).sort()) {
106
+ const owns = ownItems(join(dir, OWN_ITEMS));
107
+ if (owns.why) unread.push({ file: shown(OWN_ITEMS), why: owns.why });
108
+ for (const name of readdirSync(dir).filter((n) => n.endsWith('.json') && n !== OWN_ITEMS).sort()) {
82
109
  const file = shown(name);
83
110
  let data;
84
111
  try {
@@ -94,7 +121,7 @@ export function loadItems(cwd, folder) {
94
121
  else unread.push({ file, why: `${Array.isArray(data) ? `its entry ${i + 1}` : 'it'} is not an item: an item needs a body, and a url, a number with a repository, or a ref` });
95
122
  });
96
123
  }
97
- return { folder, items, unread };
124
+ return { folder, items, unread, owns };
98
125
  }
99
126
 
100
127
  // The names of the repos that the config names, each with its folder or why it has none.
@@ -133,7 +160,7 @@ function written(ref, names, own, folders) {
133
160
  return out;
134
161
  }
135
162
 
136
- export function issueHints(top, state, matchers, add, { folder, items, unread }) {
163
+ export function issueHints(top, state, matchers, add, { folder, items, unread, owns }) {
137
164
  const notKnown = [];
138
165
  const names = namedRepos(top, state.config);
139
166
  const cited = supersededIn(state);
@@ -155,10 +182,12 @@ export function issueHints(top, state, matchers, add, { folder, items, unread })
155
182
 
156
183
  // The items that each task of each open request names, in order (SP-8, SP-9).
157
184
  const named = []; // [{ r, task, it }]
185
+ const tasked = []; // the open requests with a task that has a ref
158
186
  for (const r of state.requests.values()) {
159
187
  if (!r.open) continue;
160
188
  for (const t of Array.isArray(r.data?.tasks) ? r.data.tasks : []) {
161
189
  if (!isMap(t) || t.ref == null) continue;
190
+ if (!tasked.includes(r)) tasked.push(r);
162
191
  const task = String(t.id ?? '-');
163
192
  const id = `${r.name}/${task}`;
164
193
  const found = [];
@@ -199,34 +228,41 @@ export function issueHints(top, state, matchers, add, { folder, items, unread })
199
228
  }
200
229
  if (!own.repo) notKnown.push(`the request's own repository, for #n and the issue-bare-ref check: ${own.unknown}`);
201
230
 
202
- // Children (SP-10): once for each request and item.
231
+ // Children (SP-10): once for each request and item. The own item is the one
232
+ // that assuredloop-requests.json names (own-item SP-7); nothing else names it.
203
233
  const ownOf = new Map();
204
234
  const ownItem = (r) => {
205
235
  if (ownOf.has(r.name)) return ownOf.get(r.name);
206
- let ids = null;
207
- for (const s of Array.isArray(r.data?.sources) ? r.data.sources : []) {
208
- const url = isMap(s) && typeof s.url === 'string' ? s.url : null;
209
- if (!url) continue;
210
- const k = keyOf(url);
211
- const it = items.find((x) => (x.url && x.url === url) || (x.ref && holds(url, x.ref)) || (k && slug(x.key) === slug(k)));
212
- if (it) ids = { keys: new Set([it.key, it.ref].filter(Boolean).map(slug)), urls: new Set([url, it.url].filter(Boolean)), ref: it.ref };
213
- else if (k) ids = { keys: new Set([slug(k)]), urls: new Set([url]), ref: null };
214
- if (ids) break;
236
+ let ids;
237
+ const value = owns.requests?.get(r.name);
238
+ if (owns.absent) ids = { unknown: `${folder} has no ${OWN_ITEMS}` };
239
+ else if (!owns.requests) ids = { unknown: `${folder.replace(/\/+$/, '')}/${OWN_ITEMS} was not read` };
240
+ else if (value === undefined) ids = { unknown: `${OWN_ITEMS} names no own item for ${r.name}` };
241
+ else {
242
+ const w = written(value, names, own, folders);
243
+ const lost = w.find((x) => x.unknown);
244
+ if (lost) ids = { unknown: `its own item ${value}: ${lost.unknown}` };
245
+ else if (w.length) {
246
+ const keys = new Set(w.map((x) => slug(x.key)));
247
+ const its = items.filter((x) => keys.has(slug(x.key)));
248
+ ids = { keys: new Set([...keys, ...its.map((x) => x.ref).filter(Boolean).map(slug)]), urls: new Set([value, ...its.map((x) => x.url).filter(Boolean)]), ref: its.find((x) => x.ref)?.ref ?? null };
249
+ } else {
250
+ // Another tracker's key: the item that holds it as its ref, or else the value itself.
251
+ const it = items.find((x) => x.ref && holds(value, x.ref));
252
+ ids = it ? { keys: new Set([it.key, it.ref].map(slug)), urls: new Set([value, it.url].filter(Boolean)), ref: it.ref }
253
+ : { keys: new Set([slug(value)]), urls: new Set([value]), ref: value };
254
+ }
215
255
  }
216
256
  ownOf.set(r.name, ids);
217
257
  return ids;
218
258
  };
219
259
  const seen = new Set();
220
- const noOwn = new Set();
221
260
  for (const n of named) {
222
261
  const pair = `${n.r.name} ${slug(n.it.key)}`;
223
262
  if (seen.has(pair)) continue;
224
263
  seen.add(pair);
225
264
  const mine = ownItem(n.r);
226
- if (!mine) {
227
- noOwn.add(n.r.name);
228
- continue;
229
- }
265
+ if (mine.unknown) continue;
230
266
  const it = n.it;
231
267
  if (mine.keys.has(slug(it.key)) || (it.ref && mine.keys.has(slug(it.ref)))) continue;
232
268
  if (it.parent === undefined) {
@@ -237,17 +273,22 @@ export function issueHints(top, state, matchers, add, { folder, items, unread })
237
273
  // A parent's number is in the item's repository only when no url names its own.
238
274
  const parent = isMap(p) ? [keyOf(p.url), typeof p.ref === 'string' ? p.ref : null,
239
275
  !keyOf(p.url) && Number.isInteger(p.number) && it.repository ? `${it.repository}#${p.number}` : null].filter(Boolean) : [];
240
- if (p !== null && !parent.length) {
276
+ // A parent's url names the own item when it is the own item's url or the value, or holds its ref.
277
+ const url = isMap(p) && typeof p.url === 'string' && p.url ? p.url : null;
278
+ if (p !== null && !parent.length && !url) {
241
279
  notKnown.push(`whether ${it.key} is a child of the own item of ${n.r.name}: its parent names no item`);
242
280
  continue;
243
281
  }
244
- // A parent's url names the own item when it is the own item's url or its source's, or holds its ref.
245
- const url = isMap(p) && typeof p.url === 'string' ? p.url : null;
246
282
  if (url && (mine.urls.has(url) || (mine.ref && holds(url, mine.ref)))) continue;
247
283
  if (parent.some((x) => mine.keys.has(slug(x)))) continue;
248
- add('hint', 'issue-not-sub', it.key, `${n.r.name}/${n.task}`, `is not a child of the request's own item (${[...mine.keys][0]})${parent.length ? `; its parent is ${parent[0]}` : ''}`);
284
+ const named = parent[0] ?? url;
285
+ add('hint', 'issue-not-sub', it.key, `${n.r.name}/${n.task}`, `is not a child of the request's own item (${[...mine.keys][0]})${named ? `; its parent is ${named}` : ''}`);
286
+ }
287
+ // Once for each request with a task that has a ref, whether or not its items are in the folder.
288
+ for (const r of tasked) {
289
+ const why = ownItem(r).unknown;
290
+ if (why) notKnown.push(`whether the items of ${r.name} are children of its own item: ${why}`);
249
291
  }
250
- for (const name of noOwn) notKnown.push(`whether the items of ${name} are children of its own item: no origin source of ${name} names an item`);
251
292
 
252
293
  const others = items.filter((it) => !first.has(it)).length;
253
294
  if (others) notKnown.push(`${others} item${others === 1 ? '' : 's'} in ${folder} that no task names, not checked`);
package/src/v4/markers.js CHANGED
@@ -163,6 +163,69 @@ export function sameBlocks(text, paragraphs, { file = '', living = new Set(parag
163
163
 
164
164
  const emptyLinks = () => Object.fromEntries(Object.values(LINK_WORDS).map((k) => [k, []]));
165
165
 
166
+ // The tokens of a marker line that are not its ID, one kind or a link (#4): a
167
+ // word that is no kind or a second kind, a word that is no link word, a token
168
+ // that holds `<!--` or `-->`, and a link with an empty item. An HTML renderer
169
+ // can show text that such a token hides.
170
+ export function markerExtras(line, kinds = KINDS) {
171
+ const m = MARKER.exec(line);
172
+ if (!m) return [];
173
+ const known = new Set(Object.values(kinds).flat());
174
+ const extras = [];
175
+ let kind = false;
176
+ for (const token of m[2].trim().split(/[ \t]+/).filter(Boolean)) {
177
+ const c = token.indexOf(':');
178
+ if (/<!--|-->/.test(token)) extras.push(token);
179
+ else if (c < 0) {
180
+ if (kind || !known.has(token)) extras.push(token);
181
+ kind = true;
182
+ } else if (!Object.hasOwn(LINK_WORDS, token.slice(0, c)) || token.slice(c + 1).split(',').includes('')) extras.push(token);
183
+ }
184
+ return extras;
185
+ }
186
+
187
+ // The lines of `text`, outside fenced code, that start like a marker: `<!--`
188
+ // and an ID, spaces allowed. Each is { id, line, extras }: the extras of a
189
+ // well-formed marker, or null for a line that MARKER does not read, whose
190
+ // paragraph is then lost (#4).
191
+ export function markerLines(text, kinds = KINDS) {
192
+ const out = [];
193
+ let fence = null;
194
+ lf(text).split('\n').forEach((l, i) => {
195
+ const f = FENCE.exec(l);
196
+ if (fence) {
197
+ if (f && f[1][0] === fence[0] && f[1].length >= fence.length && l.trim() === f[1]) fence = null;
198
+ return;
199
+ }
200
+ if (f) { fence = f[1]; return; }
201
+ const m = /^[ \t]*<!--[ \t]*(ADR-[0-9]+-[0-9]+|[A-Z][A-Z0-9]*-[0-9]+)/.exec(l);
202
+ if (m) out.push({ id: m[1], line: i + 1, extras: MARKER.test(l) ? markerExtras(l, kinds) : null });
203
+ });
204
+ return out;
205
+ }
206
+
207
+ // Whether marker line `now` is marker line `was` with links added and nothing
208
+ // else (schema.md 5, #4): with each link item that `was` does not hold taken
209
+ // out, and each token left with no item, `now` is `was` exactly. A marker
210
+ // with extras never qualifies, so an added item cannot hide text.
211
+ export function onlyLinksAdded(was, now, kinds = KINDS) {
212
+ const w = MARKER.exec(was);
213
+ const n = MARKER.exec(now);
214
+ if (!w || !n || markerExtras(now, kinds).length) return false;
215
+ const old = new Set((w[2].match(/\S+/g) ?? []).filter((t) => t.includes(':')).flatMap((t) => {
216
+ const c = t.indexOf(':');
217
+ return t.slice(c + 1).split(',').map((x) => `${t.slice(0, c)}:${x}`);
218
+ }));
219
+ const kept = (n[2].match(/[ \t]+\S+/g) ?? []).map((piece) => {
220
+ const c = piece.indexOf(':');
221
+ if (c < 0) return piece;
222
+ const items = piece.slice(c + 1).split(',').filter((x) => old.has(`${piece.slice(0, c).trim()}:${x}`));
223
+ return items.length ? `${piece.slice(0, c + 1)}${items.join(',')}` : '';
224
+ }).join('');
225
+ const start = now.indexOf(n[1]) + n[1].length;
226
+ return now.slice(0, start) + kept + now.slice(start + n[2].length) === was;
227
+ }
228
+
166
229
  export function parseMarkdown(text, file, options = {}) {
167
230
  const kinds = options.kinds ?? KINDS;
168
231
  const known = new Set(Object.values(kinds).flat());
package/src/v4/state.js CHANGED
@@ -7,7 +7,7 @@ import { parse } from 'yaml';
7
7
  import { exists, read, recordPath, sha256 } from './base.js';
8
8
  import { loadConfig, loadSchema } from './config.js';
9
9
  import { fileAt, filesAt, git } from './git.js';
10
- import { KINDS, groupOf, parseMarkdown } from './markers.js';
10
+ import { KINDS, groupOf, lf, parseMarkdown } from './markers.js';
11
11
  import { frameOf, requirements, signedText } from './request-md.js';
12
12
  import { SIGNED } from './snapshot.js';
13
13
  import { SPEC, docsInScope, parseScopes, symlinkOn } from './scope.js';
@@ -166,10 +166,16 @@ export function loadState(top, rev = null) {
166
166
  const id = `ADR-${Number(m[1])}`;
167
167
  const status = /^\s*Status:\s*([a-z]+)/i.exec(text)?.[1].toLowerCase() ?? null;
168
168
  const head = paragraphs.find((p) => p.id === id) ?? null;
169
- // The ADR that its status line names: `Status: superseded by ADR-n` (#238).
170
- const n = /^\s*Status:[ \t]*superseded[ \t]+by[ \t]+ADR-(\d+)[ \t]*\r?(?:\n|$)/i.exec(text)?.[1];
171
- const by = n === undefined ? null : `ADR-${Number(n)}`;
172
- adrs.set(id, { id, file, text, status, by, body: adrBody(text), rest: head && adrRest(text, head), paragraphs, head });
169
+ // The status line that al knows: `Status: proposed`, `accepted` or
170
+ // `superseded by ADR-n` (#238), or by a list joined by commas and `and`,
171
+ // `ADR-11, ADR-12 and ADR-13` (#1). `by` is the ADRs that it names;
172
+ // `known` is whether it is the file's first line (#4). One parse gives
173
+ // both, so superseded-status and adr-status agree.
174
+ const s = /^(\s*)Status:[ \t]*(?:proposed|accepted|superseded[ \t]+by[ \t]+(ADR-\d+(?:(?:[ \t]*,[ \t]*(?:and[ \t]+)?|[ \t]+and[ \t]+)ADR-\d+)*))[ \t]*\r?(?:\n|$)/i.exec(text);
175
+ const by = [...new Set([...(s?.[2] ?? '').matchAll(/ADR-(\d+)/gi)].map((m) => `ADR-${Number(m[1])}`))];
176
+ const known = Boolean(s) && !/[\r\n]/.test(s[1]);
177
+ const marker = head && lf(text).split('\n')[head.line - 1];
178
+ adrs.set(id, { id, file, text, status, by, known, body: adrBody(text), rest: head && adrRest(text, head), paragraphs, head, marker });
173
179
  }
174
180
  return { top, rev, config, kinds, root, get, docs, scopes, lints, spec, requests, adrs, unreadableAdrs };
175
181
  }