@assuredloop/cli 2.0.0 → 3.1.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
@@ -228,6 +228,8 @@ ADRs that supersede it: `superseded by ADR-11, ADR-12` or
228
228
  - A current place that names a superseded ADR also names the ADR that
229
229
  supersedes it, in the same marker or the same paragraph. Current places
230
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`.
231
233
  The chain itself and dated records are exempt: an ADR that supersedes it,
232
234
  a superseded ADR, an ADR's `## History`, a request's `## Decisions`, the
233
235
  `origin/` files and the archived requests. Else `al check` gives
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assuredloop/cli",
3
- "version": "2.0.0",
3
+ "version": "3.1.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": {
package/src/v4/blocks.js CHANGED
@@ -175,21 +175,47 @@ export const citedMessage = (c) => (c.next.length
175
175
 
176
176
  // The ADR that a link value or word names: `ADR-n` and `ADR-n-k` name ADR-n.
177
177
  const ADR_REF = /^ADR-(\d+)(?:-\d+)?$/;
178
- const adrOf = (ref) => (ADR_REF.test(ref) ? `ADR-${Number(ADR_REF.exec(ref)[1])}` : null);
178
+ export 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,8 +230,8 @@ 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;
@@ -229,7 +255,8 @@ export function supersededIn(state) {
229
255
  return seen;
230
256
  };
231
257
  const superseded = (id) => state.adrs.get(id)?.status === 'superseded';
232
- return (textOf, { skip = null, exempt = new Set() } = {}) => places(textOf, skip).flatMap((p) => [...p.names]
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]
233
260
  .filter((x) => superseded(x) && !exempt.has(x) && ![...above(x)].some((y) => p.names.has(y)))
234
261
  .map((x) => ({ line: p.line, id: p.id, link: p.link, adr: x, next: up(x) })));
235
262
  }
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';
@@ -17,7 +18,7 @@ import {
17
18
  import { NEAR, closeness, diffWords, marks, requirementWords, sentencesOf, showHunk } from './words.js';
18
19
  import { groupOf, markerLines, onlyLinksAdded, sameBlocks } from './markers.js';
19
20
  import { canAlignTo, declaredNow, isCentral } from './indexer.js';
20
- import { recordBlocks, supersededBlocks } from './blocks.js';
21
+ import { adrOf, recordBlocks, supersededBlocks } from './blocks.js';
21
22
  import { phraseFindings } from './phrases.js';
22
23
  import { issueHints } from './issues.js';
23
24
 
@@ -387,11 +388,22 @@ export function checks(top, { base, phrases = [], issues = null }) {
387
388
  // changed (#4) and the rest unchanged; that gives no finding.
388
389
  const linksAdded = (a, h) => a.status === 'accepted' && a.head && h.head && a.rest === h.rest
389
390
  && onlyLinksAdded(a.marker, h.marker, now.kinds);
391
+ // A status goes only forward: accepted to superseded, and a superseded-by
392
+ // list only gains ADRs (#31). Any other change of a known status line is a
393
+ // change of the ADR; an unknown one has its own hint, adr-status.
394
+ const statusBack = (a, h) => h.known
395
+ && !(h.status === 'superseded' ? a.by.every((b) => h.by.includes(b)) : h.status === a.status);
390
396
  for (const [id, a] of was?.adrs ?? []) {
391
397
  if (!['accepted', 'superseded'].includes(a.status)) continue;
392
398
  const h = now.adrs.get(id);
393
- if (!h) add('hint', 'adr-changed', a.file, id, `${id} was ${a.status} and its file is gone: check that its decision is not lost; an ADR that no longer holds is superseded by a new ADR, not deleted`);
394
- else if (h.body !== a.body && !linksAdded(a, h)) add('hint', 'adr-changed', h.head ?? h.file, id, `${id}'s text changed: check that its decision is unchanged; if the decision changed, write a new ADR that supersedes it`);
399
+ if (!h) {
400
+ 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`);
401
+ continue;
402
+ }
403
+ const back = statusBack(a, h);
404
+ const changed = h.body !== a.body && !linksAdded(a, h);
405
+ if (back) add('hint', 'adr-changed', h.head ?? h.file, id, `${id}'s status changed from "${a.line}" to "${h.line}"${changed ? ', and its text changed' : ''}, but a status goes only forward: proposed, accepted, superseded by ADR-n; check that its decision is unchanged; if the decision changed, write a new ADR that supersedes it`);
406
+ else if (changed) add('hint', 'adr-changed', h.head ?? h.file, id, `${id}'s text changed: check that its decision is unchanged; if the decision changed, write a new ADR that supersedes it`);
395
407
  }
396
408
  // Every ADR, whatever its status (#4): a first line that is not a known
397
409
  // status, and a marker line that holds more than its ID, a kind and links.
@@ -409,9 +421,13 @@ export function checks(top, { base, phrases = [], issues = null }) {
409
421
  if (missing.length) add('hint', 'adr-links', a.head ?? a.file, a.id, `${a.id} has ${missing.map((w) => `no ${w}:`).join(' and ')}; add the links to its marker`);
410
422
  }
411
423
  for (const a of now.adrs.values()) {
412
- for (const old of a.head?.links.supersedes ?? []) {
424
+ // ADR-n-k, a paragraph of ADR-n, names ADR-n (schema.md 5), once each.
425
+ for (const old of new Set((a.head?.links.supersedes ?? []).map(adrOf).filter(Boolean))) {
413
426
  const o = now.adrs.get(old);
414
427
  if (o && o.status !== 'superseded') add('hint', 'adr-supersede', a.head, a.id, `${a.id} supersedes ${old}, whose status is ${o.status ?? 'missing'}; set it to superseded`);
428
+ // The other direction of superseded-status (#31): the status line of
429
+ // the superseded ADR names each ADR that supersedes it.
430
+ else if (o && !o.by.includes(a.id)) add('hint', 'adr-supersede', a.head, a.id, `${a.id} supersedes ${old}, but the status line of ${old} leaves out ${a.id}: write "Status: superseded by ${[...o.by, a.id].join(', ')}"`);
415
431
  }
416
432
  }
417
433
  for (const c of changes.filter((x) => !x.request)) {
@@ -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
@@ -281,8 +281,9 @@ export function index({ top, opts }) {
281
281
  if (symlinkOn(top, `${root}/adr`)) notes.push(`not indexed: ${root}/adr: through a symlink`);
282
282
  const names = requestNames(top);
283
283
 
284
- // The base: the merge-base with main; with no main, HEAD (so a committed
285
- // 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.
286
287
  const main = mergeBase(top);
287
288
  const head = git(top, ['rev-parse', '--verify', '--quiet', 'HEAD'], { allowFail: true });
288
289
  const base = main ?? head ?? null;
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
@@ -175,7 +175,9 @@ export function loadState(top, rev = null) {
175
175
  const by = [...new Set([...(s?.[2] ?? '').matchAll(/ADR-(\d+)/gi)].map((m) => `ADR-${Number(m[1])}`))];
176
176
  const known = Boolean(s) && !/[\r\n]/.test(s[1]);
177
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 });
178
+ // The words after `Status:`, for a hint that names a changed status (#31).
179
+ const line = /^\s*Status:[ \t]*([^\r\n]*)/i.exec(text)?.[1].trim() ?? null;
180
+ adrs.set(id, { id, file, text, status, by, known, line, body: adrBody(text), rest: head && adrRest(text, head), paragraphs, head, marker });
179
181
  }
180
182
  return { top, rev, config, kinds, root, get, docs, scopes, lints, spec, requests, adrs, unreadableAdrs };
181
183
  }