@assuredloop/cli 1.0.0 → 3.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:`
@@ -209,6 +228,8 @@ ADRs are `specs/adr/NNNN-<slug>.md`, with `Status: proposed`, `accepted` or
209
228
  - A current place that names a superseded ADR also names the ADR that
210
229
  supersedes it, in the same marker or the same paragraph. Current places
211
230
  are the spec docs, the open requests and the proposed and accepted ADRs.
231
+ The text names an ADR by `ADR-40`, by `ADR 0040`, or in a list or a range
232
+ such as `ADRs 0003, 0005 and 0040` or `ADRs 0003 to 0005`.
212
233
  The chain itself and dated records are exempt: an ADR that supersedes it,
213
234
  a superseded ADR, an ADR's `## History`, a request's `## Decisions`, the
214
235
  `origin/` files and the archived requests. Else `al check` gives
@@ -234,8 +255,7 @@ example `PROJ-12`. Each finding is a hint:
234
255
  GitHub reads as that repository's issue. The request's own repository
235
256
  comes from `git remote get-url origin`;
236
257
  - `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>`);
258
+ item that `assuredloop-requests.json` in the folder names (below);
239
259
  - `phrase`: a phrase of `phrases:` matches;
240
260
  - `issue-unread`: a file in the folder is not valid JSON, or holds an entry
241
261
  that is not an item.
@@ -274,6 +294,104 @@ An item of another tracker:
274
294
  "comments": [{"body": "Done.", "isMinimized": false}], "parent": {"ref": "PROJ-1"}}
275
295
  ```
276
296
 
297
+ The folder also says which item is each request's own item, in the file
298
+ `assuredloop-requests.json`. `al` reads the own item from this file only,
299
+ never from an origin source. Without the file, or for a request that it
300
+ does not list, `issue-not-sub` is not checked, and the `Not known` line
301
+ says so once for each request. The file is not read as an item. A file that
302
+ is not valid gets `issue-unread` and is ignored; `al check` goes on.
303
+
304
+ - `format`: the exact text `assuredloop-items/1`;
305
+ - `requests`: each key is a request's name, and its value is that request's
306
+ own item, in a form that a task's `ref` uses: a URL, `owner/repo#n`, or
307
+ another tracker's key, for example `PROJ-1`.
308
+
309
+ ```json
310
+ {
311
+ "format": "assuredloop-items/1",
312
+ "requests": {
313
+ "invoice-exports": "https://github.com/acme/invoicer/issues/30",
314
+ "pay-run": "PROJ-1"
315
+ }
316
+ }
317
+ ```
318
+
319
+ To write it for a GitHub issue:
320
+
321
+ ```sh
322
+ printf '{"format": "assuredloop-items/1", "requests": {"invoice-exports": "%s"}}\n' \
323
+ "$(gh issue view 30 --repo acme/invoicer --json url --jq .url)" \
324
+ > /tmp/items/assuredloop-requests.json
325
+ ```
326
+
327
+ The JSON Schema (draft 2020-12) of each file, for a person or an AI that
328
+ writes them. An item file:
329
+
330
+ ```json
331
+ {
332
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
333
+ "title": "an item file: one item, or a list of items",
334
+ "oneOf": [
335
+ {"$ref": "#/$defs/item"},
336
+ {"type": "array", "items": {"$ref": "#/$defs/item"}}
337
+ ],
338
+ "$defs": {
339
+ "item": {
340
+ "type": "object",
341
+ "required": ["body"],
342
+ "anyOf": [
343
+ {"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]*)?$"}}},
344
+ {"required": ["number", "repository"]},
345
+ {"required": ["ref"]}
346
+ ],
347
+ "properties": {
348
+ "body": {"type": "string"},
349
+ "url": {"type": "string"},
350
+ "number": {"type": "integer"},
351
+ "comments": {
352
+ "type": "array",
353
+ "items": {
354
+ "type": "object",
355
+ "required": ["body"],
356
+ "properties": {"body": {"type": "string"}, "isMinimized": {"type": "boolean"}}
357
+ }
358
+ },
359
+ "parent": {
360
+ "oneOf": [
361
+ {"type": "null"},
362
+ {
363
+ "type": "object",
364
+ "properties": {"url": {"type": "string"}, "number": {"type": "integer"}, "ref": {"type": "string", "minLength": 1}}
365
+ }
366
+ ]
367
+ },
368
+ "ref": {"type": "string", "minLength": 1},
369
+ "repository": {"type": "string", "minLength": 1}
370
+ }
371
+ }
372
+ }
373
+ }
374
+ ```
375
+
376
+ `assuredloop-requests.json`:
377
+
378
+ ```json
379
+ {
380
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
381
+ "title": "assuredloop-requests.json",
382
+ "type": "object",
383
+ "required": ["format", "requests"],
384
+ "additionalProperties": false,
385
+ "properties": {
386
+ "format": {"const": "assuredloop-items/1"},
387
+ "requests": {
388
+ "type": "object",
389
+ "additionalProperties": {"type": "string", "minLength": 1}
390
+ }
391
+ }
392
+ }
393
+ ```
394
+
277
395
  ## Search
278
396
 
279
397
  `al search` answers at the strongest level that is installed, falls back by
@@ -403,6 +521,13 @@ claim, a link that does not resolve, an ADR's changed text or missing links,
403
521
  an invalid disposition, and the rest. `--strict` changes only the exit code:
404
522
  it is 1 when a `not ok` prints.
405
523
 
524
+ `al check` gives the hint "promise kind?" (`promise-kind`) for a paragraph
525
+ that is not a promise kind and holds a requirement word: MUST, SHALL, SHOULD,
526
+ MAY, never or always. When the paragraph is a promise, change its kind. When
527
+ it applies a promise, for example a flow step that keeps a rule, give it
528
+ `builds-on:` the promise paragraph in its marker. The hint then goes. A
529
+ `builds-on:` to another kind, or to an ID that does not exist, keeps it.
530
+
406
531
  ## What is enforced, and by whom
407
532
 
408
533
  | 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": "3.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
  }
@@ -178,18 +178,44 @@ const ADR_REF = /^ADR-(\d+)(?:-\d+)?$/;
178
178
  const adrOf = (ref) => (ADR_REF.test(ref) ? `ADR-${Number(ADR_REF.exec(ref)[1])}` : null);
179
179
  const MARKER = /^\s*<!--(.*)-->\s*$/;
180
180
 
181
+ // The words that name ADRs in a text (adr-lists R1): `ADR-n`, or the number
182
+ // after `ADR` or `ADRs` and a space, and each item of a list or a range that
183
+ // follows. An item is a number or `ADR-n`; a comma, `and` or both join a
184
+ // list, and `to` a range. A range names its two ends and each ADR in `known`
185
+ // (the numbers of the ADRs that exist) between them, so a long one costs
186
+ // nothing; a reversed range names only its ends.
187
+ const ITEM = String.raw`(?:ADR-\d+(?:-\d+)?|\d+)\b`;
188
+ const NAMING = new RegExp(String.raw`\b(?:ADRs?\s+(?=\d)|(?=ADR-\d))${ITEM}(?:(?:\s*,\s*(?:and\s+)?|\s+(?:and|to)\s+)${ITEM})*`, 'g');
189
+ function adrsNamed(text, known) {
190
+ const out = new Set();
191
+ for (const m of text.matchAll(NAMING)) {
192
+ let last = null;
193
+ let range = false;
194
+ for (const t of m[0].matchAll(/ADR-(\d+)(?:-\d+)?|\d+|\bto\b/g)) {
195
+ if (t[0] === 'to') { range = true; continue; }
196
+ const n = Number(t[1] ?? t[0]);
197
+ if (range && last < n) for (const k of known) if (k > last && k < n) out.add(`ADR-${k}`);
198
+ out.add(`ADR-${n}`);
199
+ last = n;
200
+ range = false;
201
+ }
202
+ }
203
+ return out;
204
+ }
205
+
181
206
  // The places of a Markdown text: each marker line and each run of text lines,
182
207
  // with the ADRs it names and the ID of the marker it falls under. A heading
183
208
  // that `skip` matches starts a section that is not read, up to the next
184
209
  // heading of level 1 or 2.
185
- function places(textOf, skip) {
210
+ function places(textOf, skip, known) {
186
211
  const lines = textOf.replace(/\r\n?/g, '\n').split('\n');
187
212
  const out = [];
188
213
  let id = null;
189
214
  let skipping = false;
190
215
  let run = null;
191
216
  const close = () => {
192
- if (run && run.names.size) out.push(run);
217
+ const names = run ? adrsNamed(run.lines.join('\n'), known) : new Set();
218
+ if (names.size) out.push({ line: run.line, id: run.id, link: false, names });
193
219
  run = null;
194
220
  };
195
221
  lines.forEach((l, i) => {
@@ -204,23 +230,34 @@ function places(textOf, skip) {
204
230
  if (names.size) out.push({ line: i + 1, id, link: true, names });
205
231
  return;
206
232
  }
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])}`);
233
+ if (!run) run = { line: i + 1, id, lines: [] };
234
+ run.lines.push(l);
209
235
  });
210
236
  close();
211
237
  return out;
212
238
  }
213
239
 
214
240
  // 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).
241
+ // that supersedes it, directly or through a chain (#1), in the same marker or
242
+ // the same paragraph. Gives (text, { skip, exempt }) => [{ line, id, link,
243
+ // adr, next }]. al check --issues reads the issues with the same rule
244
+ // (issue-hints D7).
218
245
  export function supersededIn(state) {
219
246
  const down = supersedes(state);
220
247
  const up = (id) => [...down].filter(([, xs]) => xs.has(id)).map(([y]) => y);
248
+ // The ADRs that supersede `id`, directly or through a chain; never `id`
249
+ // itself, when the chain is a cycle.
250
+ const above = (id) => {
251
+ const seen = new Set([id]);
252
+ const walk = (x) => { for (const y of up(x)) if (!seen.has(y)) { seen.add(y); walk(y); } };
253
+ walk(id);
254
+ seen.delete(id);
255
+ return seen;
256
+ };
221
257
  const superseded = (id) => state.adrs.get(id)?.status === 'superseded';
222
- return (textOf, { skip = null, exempt = new Set() } = {}) => places(textOf, skip).flatMap((p) => [...p.names]
223
- .filter((x) => superseded(x) && !exempt.has(x) && !up(x).some((y) => p.names.has(y)))
258
+ const known = [...state.adrs.keys()].map((id) => Number(id.slice('ADR-'.length)));
259
+ return (textOf, { skip = null, exempt = new Set() } = {}) => places(textOf, skip, known).flatMap((p) => [...p.names]
260
+ .filter((x) => superseded(x) && !exempt.has(x) && ![...above(x)].some((y) => p.names.has(y)))
224
261
  .map((x) => ({ line: p.line, id: p.id, link: p.link, adr: x, next: up(x) })));
225
262
  }
226
263
 
@@ -259,10 +296,12 @@ export function supersededBlocks(top, state, add) {
259
296
  for (const a of state.adrs.values()) {
260
297
  if (a.status !== 'superseded') continue;
261
298
  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}`);
299
+ 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>"`);
300
+ for (const b of a.by) {
301
+ 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`);
302
+ else if (!(state.adrs.get(b).head?.links.supersedes ?? []).some((x) => adrOf(x) === a.id)) {
303
+ 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}`);
304
+ }
266
305
  }
267
306
  }
268
307
 
package/src/v4/check.js CHANGED
@@ -1,17 +1,17 @@
1
1
  // al check (design.md 3, What the script checks): the lints of every marker in
2
2
  // scope, and per scope the ID lints of the branch against its base (lost, used
3
3
  // again, one ID in two files), then each paragraph's change (T8); then the
4
- // checks that read records (T10, checks.js). The base is the merge-base of
5
- // HEAD with main; the head is the working tree. It exits 0; with --strict, 1
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
4
+ // checks that read records (T10, checks.js). The base is baseOf (git.js), the
5
+ // merge-base of HEAD with main or origin/main; the head is the working tree.
6
+ // It exits 0; with --strict, 1 when it prints a `not ok`. A finding has the
7
+ // same severity in both modes: the marker and ID lints are hints for the
8
+ // review, and only the exact rules of checks.js are `not ok` (#238). With --issues <folder> it also checks the
9
9
  // work items that the caller saved there (issue-hints R1).
10
10
  import { relative } from 'node:path';
11
11
  import { parse as parseYaml } from 'yaml';
12
12
  import { read, recordPath } from './base.js';
13
13
  import { loadConfig, loadSchema } from './config.js';
14
- import { mergeBase } from './git.js';
14
+ import { baseOf } from './git.js';
15
15
  import { diffParagraphs, idLints } from './ids.js';
16
16
  import { docsInScope, idsEverUsed, isShallow, notRead, parseScopes, rootNotOk, rootProblem, SPEC } from './scope.js';
17
17
  import { Fail } from './spec.js';
@@ -41,7 +41,8 @@ function checkAll({ top, cwd, opts }) {
41
41
  const phrases = loadPhrases(config);
42
42
  // The work items that the caller saved; a folder that is not there is a refusal.
43
43
  const issues = opts.issues === undefined ? null : loadItems(cwd ?? top, opts.issues);
44
- const base = mergeBase(top);
44
+ const found = baseOf(top);
45
+ const base = found?.commit ?? null;
45
46
  const now = parseScopes(docsInScope(top, config), kinds);
46
47
  const was = base ? parseScopes(docsInScope(top, config, base), kinds) : null;
47
48
  const used = base ? idsEverUsed(top, config, base) : null;
@@ -85,7 +86,7 @@ function checkAll({ top, cwd, opts }) {
85
86
  else if (isShallow(top)) notKnown.unshift('IDs used before the shallow history begins');
86
87
  return {
87
88
  body,
88
- read: base ? `working tree · base ${base.slice(0, 7)} (merge-base with main)` : 'working tree · no base',
89
+ read: base ? `working tree · base ${base.slice(0, 7)} (merge-base with ${found.ref})` : 'working tree · no base',
89
90
  next: nextStep([...(rootLine ? ['not ok'] : []), ...lints.map((l) => l.severity), ...t10.findings.map((f) => f.severity),
90
91
  ...central.body.map((l) => (/^not ok /.test(l) ? 'not ok' : /^hint /.test(l) ? 'hint' : 'info'))], lints.some((l) => l.code === 'no-id'),
91
92
  t10.findings.some((f) => f.code === 'unsigned-requirement')),
package/src/v4/checks.js CHANGED
@@ -1,8 +1,9 @@
1
1
  // The checks that read records (T10; design.md 3-7 and 9, schema.md 5; the
2
2
  // architect's answers for #179, decision D15). Each finding is
3
3
  // { severity, code, file, line, id, message }: `not ok`, `hint` or `info`.
4
- // They compare the working tree with the base (the merge-base with main) and
5
- // read the records of the open requests. No AI, no network.
4
+ // They compare the working tree with the base (baseOf in git.js: the
5
+ // merge-base with main or origin/main) and read the records of the open
6
+ // requests. No AI, no network.
6
7
  import { createHash } from 'node:crypto';
7
8
  import { parse as parseYaml } from 'yaml';
8
9
  import { read } from './base.js';
@@ -12,11 +13,11 @@ import { diffParagraphs } from './ids.js';
12
13
  import { crossResults } from './repos.js';
13
14
  import { loadResults } from './results.js';
14
15
  import {
15
- hasBaselineEffect, isPromise, loadState, named, qualify, requestOf, resolve, servesSigned,
16
+ adrParagraph, hasBaselineEffect, isPromise, loadState, named, qualify, requestOf, resolve, servesSigned,
16
17
  } from './state.js';
17
18
  import { NEAR, closeness, diffWords, marks, requirementWords, sentencesOf, showHunk } from './words.js';
18
- import { groupOf, sameBlocks } from './markers.js';
19
- import { isCentral } from './indexer.js';
19
+ import { groupOf, markerLines, onlyLinksAdded, sameBlocks } from './markers.js';
20
+ import { canAlignTo, declaredNow, isCentral } from './indexer.js';
20
21
  import { recordBlocks, supersededBlocks } from './blocks.js';
21
22
  import { phraseFindings } from './phrases.js';
22
23
  import { issueHints } from './issues.js';
@@ -185,10 +186,18 @@ export function checks(top, { base, phrases = [], issues = null }) {
185
186
  }
186
187
  }
187
188
 
188
- // Requirement words in a non-promise kind (design.md 3).
189
+ // In the central repo, `central:X` is its own X (design.md 12).
190
+ const self = (ref) => (isCentral(now.config) && ref.startsWith('central:') ? ref.slice('central:'.length) : ref);
191
+
192
+ // Requirement words in a non-promise kind (design.md 3). A paragraph that
193
+ // builds on a promise paragraph that exists applies that promise: no hint.
194
+ const appliesPromise = (p, request) => {
195
+ const own = new Set(now.requests.get(request)?.paras.keys() ?? []);
196
+ return p.links.buildsOn.some((ref) => promise(resolve(now, qualify(self(ref), request, own))?.p?.kind));
197
+ };
189
198
  for (const c of changes) {
190
199
  const p = c.head;
191
- if (!p?.kind || promise(p.kind) || !c.changed) continue;
200
+ if (!p?.kind || promise(p.kind) || !c.changed || appliesPromise(p, c.request)) continue;
192
201
  const w = requirementWords(p.text);
193
202
  if (w.length) add('hint', 'promise-kind', p, c.name, `promise kind? a ${p.kind} paragraph holds ${w.join(', ')}`);
194
203
  }
@@ -263,8 +272,7 @@ export function checks(top, { base, phrases = [], issues = null }) {
263
272
  // Design links (design.md 3): a design or approach paragraph of a change
264
273
  // spec that serves a requirement, where promise paragraphs of the same
265
274
  // 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);
275
+ // central repo, `central:X` is its own X (self, above).
268
276
  for (const r of open) {
269
277
  const own = new Set(r.paras.keys());
270
278
  const key = (ref) => qualify(self(ref), r.name, own).key;
@@ -305,9 +313,24 @@ export function checks(top, { base, phrases = [], issues = null }) {
305
313
  }
306
314
 
307
315
  // Bindings whose target changed since they were bound (design.md 4, 6).
316
+ let declared = null;
317
+ let alignable = null;
308
318
  for (const r of open) {
309
319
  for (const b of Array.isArray(r.data?.bindings) ? r.data.bindings : []) {
310
320
  if (!b?.target || !b.holder || !b.target_sha256) continue;
321
+ // A holder paragraph whose text changed since the link was bound
322
+ // (removed-links/R2, #11). Output files and requirements are not
323
+ // paragraphs; a link no longer declared is history (#2); the target
324
+ // must be one that al index --align can move the binding to.
325
+ if (b.holder_sha256) {
326
+ const hq = qualify(String(b.holder), null);
327
+ const hp = hq.req ? now.requests.get(hq.req)?.paras.get(hq.id) : now.spec.get(hq.id) ?? adrParagraph(now, hq.id);
328
+ declared ??= declaredNow(top, now);
329
+ alignable ??= canAlignTo(now);
330
+ if (hp && hp.sha256 !== b.holder_sha256 && declared(b) && alignable(b.target)) {
331
+ 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}`);
332
+ }
333
+ }
311
334
  const t = qualify(String(b.target), null);
312
335
  if (t.cross) continue;
313
336
  const cur = resolve(now, t);
@@ -316,6 +339,9 @@ export function checks(top, { base, phrases = [], issues = null }) {
316
339
  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
340
  // A superseded ADR's links are history (schema.md 5).
318
341
  if (now.adrs.get(String(b.holder))?.status === 'superseded') continue;
342
+ // A link that its holder no longer declares, or whose holder is gone, is history too (#2).
343
+ declared ??= declaredNow(top, now);
344
+ if (!declared(b)) continue;
319
345
  const hq = qualify(String(b.holder), null);
320
346
  const hp = hq.req ? now.requests.get(hq.req)?.paras.get(hq.id) : now.spec.get(hq.id);
321
347
  const msg = /^[RQ]\d+$/.test(t.id ?? '')
@@ -358,16 +384,26 @@ export function checks(top, { base, phrases = [], issues = null }) {
358
384
 
359
385
  // ADRs (schema.md 5). An ADR's text may be rewritten when its decision
360
386
  // 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)));
387
+ // the heading marker of an accepted ADR, with nothing else in that line
388
+ // changed (#4) and the rest unchanged; that gives no finding.
389
+ const linksAdded = (a, h) => a.status === 'accepted' && a.head && h.head && a.rest === h.rest
390
+ && onlyLinksAdded(a.marker, h.marker, now.kinds);
365
391
  for (const [id, a] of was?.adrs ?? []) {
366
392
  if (!['accepted', 'superseded'].includes(a.status)) continue;
367
393
  const h = now.adrs.get(id);
368
394
  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
395
  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
396
  }
397
+ // Every ADR, whatever its status (#4): a first line that is not a known
398
+ // status, and a marker line that holds more than its ID, a kind and links.
399
+ for (const a of now.adrs.values()) {
400
+ 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")`);
401
+ for (const m of markerLines(a.text, now.kinds)) {
402
+ const where = `${a.file}:${m.line}`;
403
+ 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 -->`);
404
+ 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`);
405
+ }
406
+ }
371
407
  for (const a of now.adrs.values()) {
372
408
  if (!['proposed', 'accepted'].includes(a.status)) continue;
373
409
  const missing = ['source', 'decides'].filter((w) => !a.head?.links[w].length);
@@ -3,7 +3,7 @@
3
3
  // state, and refuses while a promise change has no signed requirement, a
4
4
  // spike's current question is not signed, or a change paragraph with a
5
5
  // baseline effect has no valid disposition for its current version. A promise
6
- // change of the branch, against the merge-base with main, counts as al check
6
+ // change of the branch, against its base (baseOf), counts as al check
7
7
  // counts it (branchOf, #217). With
8
8
  // --yes it writes the Outcome, sets the status and archives the request.
9
9
  import { mkdirSync, renameSync, statSync } from 'node:fs';
@@ -11,7 +11,7 @@ import { dirname, join } from 'node:path';
11
11
  import { Fail, day, exists, guard, isName, now, write } from './base.js';
12
12
  import { branchOf } from './checks.js';
13
13
  import { judgeRequest } from './dispositions.js';
14
- import { git, mergeBase } from './git.js';
14
+ import { baseOf, git } from './git.js';
15
15
  import { openRecord, recordText } from './records.js';
16
16
  import { isPromise, loadState, qualify, servesSigned } from './state.js';
17
17
  import { signState } from './views.js';
@@ -95,9 +95,10 @@ export function conclude({ top, args, opts }) {
95
95
  const r = state.requests.get(name);
96
96
  if (!r) throw new Fail(`no request named ${name}`, 'al context lists the open requests');
97
97
  if (!r.open) throw new Fail(`${name} is already archived (requests/archive/${name}); an archived request is not concluded again`, `al context ${name}`);
98
- const base = mergeBase(top);
98
+ const found = baseOf(top);
99
+ const base = found?.commit ?? null;
99
100
  const branch = base ? branchOf(top, state, loadState(top, base), base).uncovered : [];
100
- const read = base ? `working tree · base ${base.slice(0, 7)} (merge-base with main)` : 'working tree · no base';
101
+ const read = base ? `working tree · base ${base.slice(0, 7)} (merge-base with ${found.ref})` : 'working tree · no base';
101
102
  const notKnown = base ? NOT_KNOWN : ['the promise changes of the branch: no base (no main branch, or no commit)', ...NOT_KNOWN];
102
103
  const { out, judged } = reasons(state, r, branch);
103
104
  if (out.length) {
package/src/v4/git.js CHANGED
@@ -12,15 +12,29 @@ export function git(top, args, { allowFail = false } = {}) {
12
12
  }
13
13
  }
14
14
 
15
- // The merge-base of HEAD with main (or origin/main), or null.
16
- export function mergeBase(top) {
17
- for (const main of ['main', 'origin/main']) {
18
- if (git(top, ['rev-parse', '--verify', '--quiet', `${main}^{commit}`], { allowFail: true }) === null) continue;
19
- return git(top, ['merge-base', 'HEAD', main], { allowFail: true }) || null;
20
- }
21
- return null;
15
+ // The base of the branch, { commit, ref }, or null: the merge-base of HEAD
16
+ // with main or with origin/main, and the ref that gave it. With only one of
17
+ // the two, that one. With both, the later merge-base (the other is its
18
+ // ancestor), so a local main that is behind origin/main does not give an old
19
+ // base; when neither is the later, the local main (#22). It reads only the
20
+ // refs in the clone, and never fetches.
21
+ export function baseOf(top) {
22
+ const refs = ['main', 'origin/main'].filter((ref) => git(top, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`], { allowFail: true }) !== null);
23
+ const of = (ref) => {
24
+ const commit = git(top, ['merge-base', 'HEAD', ref], { allowFail: true });
25
+ return commit ? { commit, ref } : null;
26
+ };
27
+ if (!refs.length) return null;
28
+ const local = of(refs[0]);
29
+ if (refs.length === 1) return local;
30
+ const remote = of(refs[1]);
31
+ if (!local || !remote || local.commit === remote.commit) return local;
32
+ return git(top, ['merge-base', '--is-ancestor', local.commit, remote.commit], { allowFail: true }) === null ? local : remote;
22
33
  }
23
34
 
35
+ // The base commit of the branch (baseOf), or null.
36
+ export const mergeBase = (top) => baseOf(top)?.commit ?? null;
37
+
24
38
  // Git objects by name (a blob hash, or `<commit>:<path>`), read with one
25
39
  // `git cat-file --batch` process: Map name -> bytes for each name that is a
26
40
  // blob. A missing, ambiguous or other object is not in the Map. Every reader
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'); }
@@ -208,8 +281,9 @@ export function index({ top, opts }) {
208
281
  if (symlinkOn(top, `${root}/adr`)) notes.push(`not indexed: ${root}/adr: through a symlink`);
209
282
  const names = requestNames(top);
210
283
 
211
- // The base: the merge-base with main; with no main, HEAD (so a committed
212
- // link with no binding stays unknown); with no commit, nothing.
284
+ // The base: mergeBase (the merge-base with main or origin/main); with no
285
+ // main, HEAD (so a committed link with no binding stays unknown); with no
286
+ // commit, nothing.
213
287
  const main = mergeBase(top);
214
288
  const head = git(top, ['rev-parse', '--verify', '--quiet', 'HEAD'], { allowFail: true });
215
289
  const base = main ?? head ?? null;
@@ -239,13 +313,25 @@ export function index({ top, opts }) {
239
313
  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
314
  }
241
315
 
316
+ // The records that already hold each binding: a holder whose marker names
317
+ // no request any more, such as a superseded ADR that dropped `source:`,
318
+ // still reaches the bindings it holds (#11).
319
+ const held = new Map();
320
+ for (const [n, rec] of recs) {
321
+ for (const it of rec.doc.get('bindings')?.items ?? []) {
322
+ if (!it?.get) continue;
323
+ const k = tripleKey({ holder: it.get('holder'), link: it.get('link'), target: it.get('target') });
324
+ held.set(k, [...(held.get(k) ?? []), n]);
325
+ }
326
+ }
327
+
242
328
  let bound = 0;
243
329
  const seen = new Set();
244
330
  const once = (line) => { if (!seen.has(line)) { seen.add(line); body.push(line); } };
245
331
  for (const l of links) {
246
332
  const name = `${l.holder} ${l.link} ${l.target}`;
247
333
  if (l.t.cross) { once(`not bound: ${name}: cross-repo`); continue; }
248
- const homes = l.homes.filter((h) => recs.has(h));
334
+ const homes = [...new Set([...l.homes.filter((h) => recs.has(h)), ...(held.get(tripleKey(l)) ?? [])])];
249
335
  if (!homes.length) { once(`not bound: ${name}: no request record holds it`); continue; }
250
336
  const aligning = align !== undefined && l.holder === align;
251
337
  for (const h of homes) {
@@ -276,6 +362,33 @@ export function index({ top, opts }) {
276
362
  }
277
363
  }
278
364
 
365
+ // Removed links (removed-links/R1, #11): a binding whose link no current
366
+ // marker, requirement or outputs: entry declares is removed from the records
367
+ // of the open and the archived requests, and named. An archived change spec
368
+ // and request.md still declare their links. While a file that can declare
369
+ // links is not read, nothing is removed: a missing file is not a removed link.
370
+ const archived = new Map();
371
+ for (const n of names.archived) {
372
+ 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}`); }
373
+ }
374
+ const unread = [...new Set([
375
+ ...notes.filter((l) => /^not (indexed|read): /.test(l)).map((l) => l.replace(/^not (indexed|read): /, '')),
376
+ ...notRead(top, root, config),
377
+ ])];
378
+ const declared = new Set(linksOf(top, everyDeclaration(now), config, false).map(tripleKey));
379
+ const undeclared = (it) => it?.get && !declared.has(tripleKey({ holder: it.get('holder'), link: it.get('link'), target: it.get('target') }));
380
+ const dead = [...recs, ...archived].flatMap(([n, rec]) => (rec.doc.get('bindings')?.items ?? []).filter(undeclared).map((it) => [n, rec, it]));
381
+ if (unread.length) {
382
+ body.push(`not removed: no binding is removed in this run, as al index could not read ${unread.join('; ')}`);
383
+ } else {
384
+ for (const [n, rec, it] of dead) {
385
+ const items = rec.doc.get('bindings').items;
386
+ items.splice(items.indexOf(it), 1);
387
+ body.push(`removed: ${it.get('holder')} ${it.get('link')} ${it.get('target')} from the record of ${n}: no longer declared`);
388
+ rec.changed = true;
389
+ }
390
+ }
391
+
279
392
  // Dispositions: the hashes of the source version, and of the spec text it
280
393
  // produced, frozen when first indexed.
281
394
  for (const [n, rec] of recs) {
@@ -331,7 +444,7 @@ export function index({ top, opts }) {
331
444
  const rec = { schema: SCHEMA, file: d.file, ...(status ? { status } : {}), paragraphs };
332
445
  outputs.push([path, stringify(rec, YAML_OPTIONS)]);
333
446
  }
334
- for (const [, rec] of recs) if (rec.changed) outputs.push([rec.path, recordText(rec)]);
447
+ for (const [, rec] of [...recs, ...archived]) if (rec.changed) outputs.push([rec.path, recordText(rec)]);
335
448
  guard(top, outputs.map(([path]) => path));
336
449
  const written = outputs.filter(([path, text]) => write(top, path, text)).map(([path]) => path);
337
450
 
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/repos.js CHANGED
@@ -289,7 +289,7 @@ export function crossRepo(top) {
289
289
  return { repos: repos.map(({ dir, ...r }) => r), links, results };
290
290
  }
291
291
 
292
- // In an output repo: the files that the branch changed (merge-base with main
292
+ // In an output repo: the files that the branch changed (the base, mergeBase,
293
293
  // to the working tree, and untracked files not ignored), or with no base every
294
294
  // file; never under .assuredloop/.
295
295
  function changedFiles(top, base) {
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
  }