@assuredloop/cli 1.0.0 → 2.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 +134 -11
- package/package.json +1 -1
- package/skills/assuredloop/SKILL.md +5 -3
- package/src/v4/blocks.js +21 -9
- package/src/v4/checks.js +46 -11
- package/src/v4/indexer.js +117 -5
- package/src/v4/issues.js +68 -27
- package/src/v4/markers.js +63 -0
- 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:`
|
|
@@ -234,8 +253,7 @@ example `PROJ-12`. Each finding is a hint:
|
|
|
234
253
|
GitHub reads as that repository's issue. The request's own repository
|
|
235
254
|
comes from `git remote get-url origin`;
|
|
236
255
|
- `issue-not-sub`: the item is not a child of the request's own item, the
|
|
237
|
-
item that
|
|
238
|
-
--url <its URL>`);
|
|
256
|
+
item that `assuredloop-requests.json` in the folder names (below);
|
|
239
257
|
- `phrase`: a phrase of `phrases:` matches;
|
|
240
258
|
- `issue-unread`: a file in the folder is not valid JSON, or holds an entry
|
|
241
259
|
that is not an item.
|
|
@@ -274,6 +292,104 @@ An item of another tracker:
|
|
|
274
292
|
"comments": [{"body": "Done.", "isMinimized": false}], "parent": {"ref": "PROJ-1"}}
|
|
275
293
|
```
|
|
276
294
|
|
|
295
|
+
The folder also says which item is each request's own item, in the file
|
|
296
|
+
`assuredloop-requests.json`. `al` reads the own item from this file only,
|
|
297
|
+
never from an origin source. Without the file, or for a request that it
|
|
298
|
+
does not list, `issue-not-sub` is not checked, and the `Not known` line
|
|
299
|
+
says so once for each request. The file is not read as an item. A file that
|
|
300
|
+
is not valid gets `issue-unread` and is ignored; `al check` goes on.
|
|
301
|
+
|
|
302
|
+
- `format`: the exact text `assuredloop-items/1`;
|
|
303
|
+
- `requests`: each key is a request's name, and its value is that request's
|
|
304
|
+
own item, in a form that a task's `ref` uses: a URL, `owner/repo#n`, or
|
|
305
|
+
another tracker's key, for example `PROJ-1`.
|
|
306
|
+
|
|
307
|
+
```json
|
|
308
|
+
{
|
|
309
|
+
"format": "assuredloop-items/1",
|
|
310
|
+
"requests": {
|
|
311
|
+
"invoice-exports": "https://github.com/acme/invoicer/issues/30",
|
|
312
|
+
"pay-run": "PROJ-1"
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
To write it for a GitHub issue:
|
|
318
|
+
|
|
319
|
+
```sh
|
|
320
|
+
printf '{"format": "assuredloop-items/1", "requests": {"invoice-exports": "%s"}}\n' \
|
|
321
|
+
"$(gh issue view 30 --repo acme/invoicer --json url --jq .url)" \
|
|
322
|
+
> /tmp/items/assuredloop-requests.json
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
The JSON Schema (draft 2020-12) of each file, for a person or an AI that
|
|
326
|
+
writes them. An item file:
|
|
327
|
+
|
|
328
|
+
```json
|
|
329
|
+
{
|
|
330
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
331
|
+
"title": "an item file: one item, or a list of items",
|
|
332
|
+
"oneOf": [
|
|
333
|
+
{"$ref": "#/$defs/item"},
|
|
334
|
+
{"type": "array", "items": {"$ref": "#/$defs/item"}}
|
|
335
|
+
],
|
|
336
|
+
"$defs": {
|
|
337
|
+
"item": {
|
|
338
|
+
"type": "object",
|
|
339
|
+
"required": ["body"],
|
|
340
|
+
"anyOf": [
|
|
341
|
+
{"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]*)?$"}}},
|
|
342
|
+
{"required": ["number", "repository"]},
|
|
343
|
+
{"required": ["ref"]}
|
|
344
|
+
],
|
|
345
|
+
"properties": {
|
|
346
|
+
"body": {"type": "string"},
|
|
347
|
+
"url": {"type": "string"},
|
|
348
|
+
"number": {"type": "integer"},
|
|
349
|
+
"comments": {
|
|
350
|
+
"type": "array",
|
|
351
|
+
"items": {
|
|
352
|
+
"type": "object",
|
|
353
|
+
"required": ["body"],
|
|
354
|
+
"properties": {"body": {"type": "string"}, "isMinimized": {"type": "boolean"}}
|
|
355
|
+
}
|
|
356
|
+
},
|
|
357
|
+
"parent": {
|
|
358
|
+
"oneOf": [
|
|
359
|
+
{"type": "null"},
|
|
360
|
+
{
|
|
361
|
+
"type": "object",
|
|
362
|
+
"properties": {"url": {"type": "string"}, "number": {"type": "integer"}, "ref": {"type": "string", "minLength": 1}}
|
|
363
|
+
}
|
|
364
|
+
]
|
|
365
|
+
},
|
|
366
|
+
"ref": {"type": "string", "minLength": 1},
|
|
367
|
+
"repository": {"type": "string", "minLength": 1}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
`assuredloop-requests.json`:
|
|
375
|
+
|
|
376
|
+
```json
|
|
377
|
+
{
|
|
378
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
379
|
+
"title": "assuredloop-requests.json",
|
|
380
|
+
"type": "object",
|
|
381
|
+
"required": ["format", "requests"],
|
|
382
|
+
"additionalProperties": false,
|
|
383
|
+
"properties": {
|
|
384
|
+
"format": {"const": "assuredloop-items/1"},
|
|
385
|
+
"requests": {
|
|
386
|
+
"type": "object",
|
|
387
|
+
"additionalProperties": {"type": "string", "minLength": 1}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
```
|
|
392
|
+
|
|
277
393
|
## Search
|
|
278
394
|
|
|
279
395
|
`al search` answers at the strongest level that is installed, falls back by
|
|
@@ -403,6 +519,13 @@ claim, a link that does not resolve, an ADR's changed text or missing links,
|
|
|
403
519
|
an invalid disposition, and the rest. `--strict` changes only the exit code:
|
|
404
520
|
it is 1 when a `not ok` prints.
|
|
405
521
|
|
|
522
|
+
`al check` gives the hint "promise kind?" (`promise-kind`) for a paragraph
|
|
523
|
+
that is not a promise kind and holds a requirement word: MUST, SHALL, SHOULD,
|
|
524
|
+
MAY, never or always. When the paragraph is a promise, change its kind. When
|
|
525
|
+
it applies a promise, for example a flow step that keeps a rule, give it
|
|
526
|
+
`builds-on:` the promise paragraph in its marker. The hint then goes. A
|
|
527
|
+
`builds-on:` to another kind, or to an ID that does not exist, keeps it.
|
|
528
|
+
|
|
406
529
|
## What is enforced, and by whom
|
|
407
530
|
|
|
408
531
|
| 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": "2.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
|
}
|
|
@@ -212,15 +212,25 @@ function places(textOf, skip) {
|
|
|
212
212
|
}
|
|
213
213
|
|
|
214
214
|
// 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
|
|
215
|
+
// that supersedes it, directly or through a chain (#1), in the same marker or
|
|
216
|
+
// the same paragraph. Gives (text, { skip, exempt }) => [{ line, id, link,
|
|
217
|
+
// adr, next }]. al check --issues reads the issues with the same rule
|
|
218
|
+
// (issue-hints D7).
|
|
218
219
|
export function supersededIn(state) {
|
|
219
220
|
const down = supersedes(state);
|
|
220
221
|
const up = (id) => [...down].filter(([, xs]) => xs.has(id)).map(([y]) => y);
|
|
222
|
+
// The ADRs that supersede `id`, directly or through a chain; never `id`
|
|
223
|
+
// itself, when the chain is a cycle.
|
|
224
|
+
const above = (id) => {
|
|
225
|
+
const seen = new Set([id]);
|
|
226
|
+
const walk = (x) => { for (const y of up(x)) if (!seen.has(y)) { seen.add(y); walk(y); } };
|
|
227
|
+
walk(id);
|
|
228
|
+
seen.delete(id);
|
|
229
|
+
return seen;
|
|
230
|
+
};
|
|
221
231
|
const superseded = (id) => state.adrs.get(id)?.status === 'superseded';
|
|
222
232
|
return (textOf, { skip = null, exempt = new Set() } = {}) => places(textOf, skip).flatMap((p) => [...p.names]
|
|
223
|
-
.filter((x) => superseded(x) && !exempt.has(x) && !
|
|
233
|
+
.filter((x) => superseded(x) && !exempt.has(x) && ![...above(x)].some((y) => p.names.has(y)))
|
|
224
234
|
.map((x) => ({ line: p.line, id: p.id, link: p.link, adr: x, next: up(x) })));
|
|
225
235
|
}
|
|
226
236
|
|
|
@@ -259,10 +269,12 @@ export function supersededBlocks(top, state, add) {
|
|
|
259
269
|
for (const a of state.adrs.values()) {
|
|
260
270
|
if (a.status !== 'superseded') continue;
|
|
261
271
|
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
|
-
|
|
272
|
+
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>"`);
|
|
273
|
+
for (const b of a.by) {
|
|
274
|
+
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`);
|
|
275
|
+
else if (!(state.adrs.get(b).head?.links.supersedes ?? []).some((x) => adrOf(x) === a.id)) {
|
|
276
|
+
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}`);
|
|
277
|
+
}
|
|
266
278
|
}
|
|
267
279
|
}
|
|
268
280
|
|
package/src/v4/checks.js
CHANGED
|
@@ -12,11 +12,11 @@ import { diffParagraphs } from './ids.js';
|
|
|
12
12
|
import { crossResults } from './repos.js';
|
|
13
13
|
import { loadResults } from './results.js';
|
|
14
14
|
import {
|
|
15
|
-
hasBaselineEffect, isPromise, loadState, named, qualify, requestOf, resolve, servesSigned,
|
|
15
|
+
adrParagraph, hasBaselineEffect, isPromise, loadState, named, qualify, requestOf, resolve, servesSigned,
|
|
16
16
|
} from './state.js';
|
|
17
17
|
import { NEAR, closeness, diffWords, marks, requirementWords, sentencesOf, showHunk } from './words.js';
|
|
18
|
-
import { groupOf, sameBlocks } from './markers.js';
|
|
19
|
-
import { isCentral } from './indexer.js';
|
|
18
|
+
import { groupOf, markerLines, onlyLinksAdded, sameBlocks } from './markers.js';
|
|
19
|
+
import { canAlignTo, declaredNow, isCentral } from './indexer.js';
|
|
20
20
|
import { recordBlocks, supersededBlocks } from './blocks.js';
|
|
21
21
|
import { phraseFindings } from './phrases.js';
|
|
22
22
|
import { issueHints } from './issues.js';
|
|
@@ -185,10 +185,18 @@ export function checks(top, { base, phrases = [], issues = null }) {
|
|
|
185
185
|
}
|
|
186
186
|
}
|
|
187
187
|
|
|
188
|
-
//
|
|
188
|
+
// In the central repo, `central:X` is its own X (design.md 12).
|
|
189
|
+
const self = (ref) => (isCentral(now.config) && ref.startsWith('central:') ? ref.slice('central:'.length) : ref);
|
|
190
|
+
|
|
191
|
+
// Requirement words in a non-promise kind (design.md 3). A paragraph that
|
|
192
|
+
// builds on a promise paragraph that exists applies that promise: no hint.
|
|
193
|
+
const appliesPromise = (p, request) => {
|
|
194
|
+
const own = new Set(now.requests.get(request)?.paras.keys() ?? []);
|
|
195
|
+
return p.links.buildsOn.some((ref) => promise(resolve(now, qualify(self(ref), request, own))?.p?.kind));
|
|
196
|
+
};
|
|
189
197
|
for (const c of changes) {
|
|
190
198
|
const p = c.head;
|
|
191
|
-
if (!p?.kind || promise(p.kind) || !c.changed) continue;
|
|
199
|
+
if (!p?.kind || promise(p.kind) || !c.changed || appliesPromise(p, c.request)) continue;
|
|
192
200
|
const w = requirementWords(p.text);
|
|
193
201
|
if (w.length) add('hint', 'promise-kind', p, c.name, `promise kind? a ${p.kind} paragraph holds ${w.join(', ')}`);
|
|
194
202
|
}
|
|
@@ -263,8 +271,7 @@ export function checks(top, { base, phrases = [], issues = null }) {
|
|
|
263
271
|
// Design links (design.md 3): a design or approach paragraph of a change
|
|
264
272
|
// spec that serves a requirement, where promise paragraphs of the same
|
|
265
273
|
// 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);
|
|
274
|
+
// central repo, `central:X` is its own X (self, above).
|
|
268
275
|
for (const r of open) {
|
|
269
276
|
const own = new Set(r.paras.keys());
|
|
270
277
|
const key = (ref) => qualify(self(ref), r.name, own).key;
|
|
@@ -305,9 +312,24 @@ export function checks(top, { base, phrases = [], issues = null }) {
|
|
|
305
312
|
}
|
|
306
313
|
|
|
307
314
|
// Bindings whose target changed since they were bound (design.md 4, 6).
|
|
315
|
+
let declared = null;
|
|
316
|
+
let alignable = null;
|
|
308
317
|
for (const r of open) {
|
|
309
318
|
for (const b of Array.isArray(r.data?.bindings) ? r.data.bindings : []) {
|
|
310
319
|
if (!b?.target || !b.holder || !b.target_sha256) continue;
|
|
320
|
+
// A holder paragraph whose text changed since the link was bound
|
|
321
|
+
// (removed-links/R2, #11). Output files and requirements are not
|
|
322
|
+
// paragraphs; a link no longer declared is history (#2); the target
|
|
323
|
+
// must be one that al index --align can move the binding to.
|
|
324
|
+
if (b.holder_sha256) {
|
|
325
|
+
const hq = qualify(String(b.holder), null);
|
|
326
|
+
const hp = hq.req ? now.requests.get(hq.req)?.paras.get(hq.id) : now.spec.get(hq.id) ?? adrParagraph(now, hq.id);
|
|
327
|
+
declared ??= declaredNow(top, now);
|
|
328
|
+
alignable ??= canAlignTo(now);
|
|
329
|
+
if (hp && hp.sha256 !== b.holder_sha256 && declared(b) && alignable(b.target)) {
|
|
330
|
+
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}`);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
311
333
|
const t = qualify(String(b.target), null);
|
|
312
334
|
if (t.cross) continue;
|
|
313
335
|
const cur = resolve(now, t);
|
|
@@ -316,6 +338,9 @@ export function checks(top, { base, phrases = [], issues = null }) {
|
|
|
316
338
|
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
339
|
// A superseded ADR's links are history (schema.md 5).
|
|
318
340
|
if (now.adrs.get(String(b.holder))?.status === 'superseded') continue;
|
|
341
|
+
// A link that its holder no longer declares, or whose holder is gone, is history too (#2).
|
|
342
|
+
declared ??= declaredNow(top, now);
|
|
343
|
+
if (!declared(b)) continue;
|
|
319
344
|
const hq = qualify(String(b.holder), null);
|
|
320
345
|
const hp = hq.req ? now.requests.get(hq.req)?.paras.get(hq.id) : now.spec.get(hq.id);
|
|
321
346
|
const msg = /^[RQ]\d+$/.test(t.id ?? '')
|
|
@@ -358,16 +383,26 @@ export function checks(top, { base, phrases = [], issues = null }) {
|
|
|
358
383
|
|
|
359
384
|
// ADRs (schema.md 5). An ADR's text may be rewritten when its decision
|
|
360
385
|
// 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
|
-
&&
|
|
386
|
+
// the heading marker of an accepted ADR, with nothing else in that line
|
|
387
|
+
// changed (#4) and the rest unchanged; that gives no finding.
|
|
388
|
+
const linksAdded = (a, h) => a.status === 'accepted' && a.head && h.head && a.rest === h.rest
|
|
389
|
+
&& onlyLinksAdded(a.marker, h.marker, now.kinds);
|
|
365
390
|
for (const [id, a] of was?.adrs ?? []) {
|
|
366
391
|
if (!['accepted', 'superseded'].includes(a.status)) continue;
|
|
367
392
|
const h = now.adrs.get(id);
|
|
368
393
|
if (!h) add('hint', 'adr-changed', a.file, id, `${id} was ${a.status} and its file is gone: check that its decision is not lost; an ADR that no longer holds is superseded by a new ADR, not deleted`);
|
|
369
394
|
else if (h.body !== a.body && !linksAdded(a, h)) add('hint', 'adr-changed', h.head ?? h.file, id, `${id}'s text changed: check that its decision is unchanged; if the decision changed, write a new ADR that supersedes it`);
|
|
370
395
|
}
|
|
396
|
+
// Every ADR, whatever its status (#4): a first line that is not a known
|
|
397
|
+
// status, and a marker line that holds more than its ID, a kind and links.
|
|
398
|
+
for (const a of now.adrs.values()) {
|
|
399
|
+
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")`);
|
|
400
|
+
for (const m of markerLines(a.text, now.kinds)) {
|
|
401
|
+
const where = `${a.file}:${m.line}`;
|
|
402
|
+
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 -->`);
|
|
403
|
+
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`);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
371
406
|
for (const a of now.adrs.values()) {
|
|
372
407
|
if (!['proposed', 'accepted'].includes(a.status)) continue;
|
|
373
408
|
const missing = ['source', 'decides'].filter((w) => !a.head?.links[w].length);
|
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'); }
|
|
@@ -239,13 +312,25 @@ export function index({ top, opts }) {
|
|
|
239
312
|
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
313
|
}
|
|
241
314
|
|
|
315
|
+
// The records that already hold each binding: a holder whose marker names
|
|
316
|
+
// no request any more, such as a superseded ADR that dropped `source:`,
|
|
317
|
+
// still reaches the bindings it holds (#11).
|
|
318
|
+
const held = new Map();
|
|
319
|
+
for (const [n, rec] of recs) {
|
|
320
|
+
for (const it of rec.doc.get('bindings')?.items ?? []) {
|
|
321
|
+
if (!it?.get) continue;
|
|
322
|
+
const k = tripleKey({ holder: it.get('holder'), link: it.get('link'), target: it.get('target') });
|
|
323
|
+
held.set(k, [...(held.get(k) ?? []), n]);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
242
327
|
let bound = 0;
|
|
243
328
|
const seen = new Set();
|
|
244
329
|
const once = (line) => { if (!seen.has(line)) { seen.add(line); body.push(line); } };
|
|
245
330
|
for (const l of links) {
|
|
246
331
|
const name = `${l.holder} ${l.link} ${l.target}`;
|
|
247
332
|
if (l.t.cross) { once(`not bound: ${name}: cross-repo`); continue; }
|
|
248
|
-
const homes = l.homes.filter((h) => recs.has(h));
|
|
333
|
+
const homes = [...new Set([...l.homes.filter((h) => recs.has(h)), ...(held.get(tripleKey(l)) ?? [])])];
|
|
249
334
|
if (!homes.length) { once(`not bound: ${name}: no request record holds it`); continue; }
|
|
250
335
|
const aligning = align !== undefined && l.holder === align;
|
|
251
336
|
for (const h of homes) {
|
|
@@ -276,6 +361,33 @@ export function index({ top, opts }) {
|
|
|
276
361
|
}
|
|
277
362
|
}
|
|
278
363
|
|
|
364
|
+
// Removed links (removed-links/R1, #11): a binding whose link no current
|
|
365
|
+
// marker, requirement or outputs: entry declares is removed from the records
|
|
366
|
+
// of the open and the archived requests, and named. An archived change spec
|
|
367
|
+
// and request.md still declare their links. While a file that can declare
|
|
368
|
+
// links is not read, nothing is removed: a missing file is not a removed link.
|
|
369
|
+
const archived = new Map();
|
|
370
|
+
for (const n of names.archived) {
|
|
371
|
+
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}`); }
|
|
372
|
+
}
|
|
373
|
+
const unread = [...new Set([
|
|
374
|
+
...notes.filter((l) => /^not (indexed|read): /.test(l)).map((l) => l.replace(/^not (indexed|read): /, '')),
|
|
375
|
+
...notRead(top, root, config),
|
|
376
|
+
])];
|
|
377
|
+
const declared = new Set(linksOf(top, everyDeclaration(now), config, false).map(tripleKey));
|
|
378
|
+
const undeclared = (it) => it?.get && !declared.has(tripleKey({ holder: it.get('holder'), link: it.get('link'), target: it.get('target') }));
|
|
379
|
+
const dead = [...recs, ...archived].flatMap(([n, rec]) => (rec.doc.get('bindings')?.items ?? []).filter(undeclared).map((it) => [n, rec, it]));
|
|
380
|
+
if (unread.length) {
|
|
381
|
+
body.push(`not removed: no binding is removed in this run, as al index could not read ${unread.join('; ')}`);
|
|
382
|
+
} else {
|
|
383
|
+
for (const [n, rec, it] of dead) {
|
|
384
|
+
const items = rec.doc.get('bindings').items;
|
|
385
|
+
items.splice(items.indexOf(it), 1);
|
|
386
|
+
body.push(`removed: ${it.get('holder')} ${it.get('link')} ${it.get('target')} from the record of ${n}: no longer declared`);
|
|
387
|
+
rec.changed = true;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
279
391
|
// Dispositions: the hashes of the source version, and of the spec text it
|
|
280
392
|
// produced, frozen when first indexed.
|
|
281
393
|
for (const [n, rec] of recs) {
|
|
@@ -331,7 +443,7 @@ export function index({ top, opts }) {
|
|
|
331
443
|
const rec = { schema: SCHEMA, file: d.file, ...(status ? { status } : {}), paragraphs };
|
|
332
444
|
outputs.push([path, stringify(rec, YAML_OPTIONS)]);
|
|
333
445
|
}
|
|
334
|
-
for (const [, rec] of recs) if (rec.changed) outputs.push([rec.path, recordText(rec)]);
|
|
446
|
+
for (const [, rec] of [...recs, ...archived]) if (rec.changed) outputs.push([rec.path, recordText(rec)]);
|
|
335
447
|
guard(top, outputs.map(([path]) => path));
|
|
336
448
|
const written = outputs.filter(([path, text]) => write(top, path, text)).map(([path]) => path);
|
|
337
449
|
|
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/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
|
}
|