@assuredloop/cli 2.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
@@ -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.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": {
package/src/v4/blocks.js CHANGED
@@ -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,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';
@@ -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) {