@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 +136 -11
- package/package.json +1 -1
- package/skills/assuredloop/SKILL.md +5 -3
- package/src/v4/blocks.js +53 -14
- package/src/v4/check.js +9 -8
- package/src/v4/checks.js +49 -13
- package/src/v4/conclude.js +5 -4
- package/src/v4/git.js +21 -7
- package/src/v4/indexer.js +120 -7
- package/src/v4/issues.js +68 -27
- package/src/v4/markers.js +63 -0
- package/src/v4/repos.js +1 -1
- package/src/v4/state.js +11 -5
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,
|
|
143
|
-
|
|
144
|
-
|
|
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.
|
|
196
|
-
|
|
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,
|
|
203
|
-
hint: check that its decision is
|
|
204
|
-
an accepted ADR, with nothing else
|
|
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
|
|
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": "
|
|
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.
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
208
|
-
|
|
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,
|
|
216
|
-
// (text, { skip, exempt }) => [{ line, id, link,
|
|
217
|
-
// --issues reads the issues with the same rule
|
|
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
|
-
|
|
223
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
|
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';
|
|
@@ -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
|
-
//
|
|
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 (
|
|
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
|
|
362
|
-
// the rest unchanged; that gives no finding.
|
|
363
|
-
const linksAdded = (a, h) => a.status === 'accepted' && a.head && h.head && a.rest === h.rest
|
|
364
|
-
&&
|
|
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);
|
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
|
@@ -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(
|
|
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
|
|
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
|
|
212
|
-
// 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.
|
|
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
|
|
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
|
|
68
|
-
//
|
|
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
|
-
|
|
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
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
if (
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
}
|