@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 +2 -0
- package/package.json +1 -1
- package/src/v4/blocks.js +33 -6
- package/src/v4/check.js +9 -8
- package/src/v4/checks.js +22 -6
- package/src/v4/conclude.js +5 -4
- package/src/v4/git.js +21 -7
- package/src/v4/indexer.js +3 -2
- package/src/v4/repos.js +1 -1
- package/src/v4/state.js +3 -1
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": "
|
|
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
|
-
|
|
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,
|
|
208
|
-
|
|
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
|
-
|
|
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
|
|
5
|
-
// HEAD with main; the head is the working tree.
|
|
6
|
-
// when it prints a `not ok`. A finding has the
|
|
7
|
-
// the marker and ID lints are hints for the
|
|
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 {
|
|
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
|
|
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
|
|
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 (
|
|
5
|
-
// read the records of the open
|
|
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)
|
|
394
|
-
|
|
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
|
-
|
|
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)) {
|
package/src/v4/conclude.js
CHANGED
|
@@ -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
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
285
|
-
// link with no binding stays unknown); with no
|
|
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 (
|
|
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
|
-
|
|
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
|
}
|