@assuredloop/cli 0.2.1 → 0.2.3
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 +22 -6
- package/bin/al.js +1 -1
- package/package.json +1 -1
- package/skills/assuredloop/SKILL.md +20 -6
- package/src/v4/check.js +37 -6
- package/src/v4/checks.js +74 -10
- package/src/v4/conclude.js +19 -9
- package/src/v4/config.js +2 -1
- package/src/v4/dispositions.js +11 -0
- package/src/v4/export.js +7 -2
- package/src/v4/request-md.js +12 -0
- package/src/v4/scope.js +22 -0
- package/src/v4/state.js +38 -8
- package/src/v4/views.js +24 -5
package/README.md
CHANGED
|
@@ -66,7 +66,9 @@ Search at level 2 needs the library `@huggingface/transformers` 4.3.1 (see
|
|
|
66
66
|
|
|
67
67
|
GitHub does not show the marker. The ID is one flat number per file, and
|
|
68
68
|
it is never used again. `al` computes a display number, for example
|
|
69
|
-
`INV-41 (2.3:4, in Export links)`, which can change when text moves.
|
|
69
|
+
`INV-41 (2.3:4, in Export links)`, which can change when text moves. So
|
|
70
|
+
a heading needs no number of its own: write `## The proxy`, not
|
|
71
|
+
`## 7. The proxy`.
|
|
70
72
|
- **Kinds.** Promise kinds (purpose, scope, rule, limit, definition): a
|
|
71
73
|
change needs a signed requirement that covers it. Design kinds (component,
|
|
72
74
|
interface, data, flow, choice) and `approach` (how one change is built):
|
|
@@ -76,7 +78,15 @@ Search at level 2 needs the library `@huggingface/transformers` 4.3.1 (see
|
|
|
76
78
|
- **A request** is the folder `requests/<name>/`: `origin/` (the owner's words
|
|
77
79
|
and the sign-offs, as snapshots with SHA-256), `request.md` (the dialog, the
|
|
78
80
|
organized requirement R1…, the decisions D1…) and, from path 2, `spec.md`,
|
|
79
|
-
the change spec, with IDs SP-1, SP-2…
|
|
81
|
+
the change spec, with IDs SP-1, SP-2… The order of its paragraphs is
|
|
82
|
+
free. The recommended order is one section per topic. Each section has
|
|
83
|
+
the promise paragraph first, then the design that keeps it, with its
|
|
84
|
+
rationale and examples, then its open points. Cross-cutting design and
|
|
85
|
+
decisions follow. While the request is open, a paragraph of its change
|
|
86
|
+
spec may be deleted: its ID is retired and never used again, and
|
|
87
|
+
`al check` gives a hint. In `specs/`, a removal needs `removes:`. Plan,
|
|
88
|
+
step and migration paragraphs may live in the tracker issue that a task's
|
|
89
|
+
`ref` names, instead of in the change spec.
|
|
80
90
|
- **Records** in `.assuredloop/records/`, committed to git: one per request
|
|
81
91
|
(its sources, requirement versions, sign-offs, decisions, tasks, the
|
|
82
92
|
bindings of its links and the dispositions of its paragraphs) and one per
|
|
@@ -136,8 +146,11 @@ with a baseline effect has no valid disposition for its current version.
|
|
|
136
146
|
request's whole trace; `--at <commit>` any v4 state.
|
|
137
147
|
- `al conclude <name>`: the close rule; with `--yes` it writes the Outcome
|
|
138
148
|
and moves the request to `requests/archive/<name>/`.
|
|
139
|
-
- `al export [--at <commit>] [--out <file>]`: one JSONL row per
|
|
140
|
-
version, with its role, version, commit and links. It is not
|
|
149
|
+
- `al export [--at <commit>] [--out <file>] [--yes]`: one JSONL row per
|
|
150
|
+
paragraph version, with its role, version, commit and links. It is not
|
|
151
|
+
committed. With no `--out`, it writes the rows to standard output. With
|
|
152
|
+
`--out`, it shows the path, the row count and whether it replaces an
|
|
153
|
+
existing file; with `--yes`, it writes the file.
|
|
141
154
|
- `al search <words>`: see "Search".
|
|
142
155
|
|
|
143
156
|
Every command that changes files, except `new` and `index`, shows what it
|
|
@@ -147,7 +160,10 @@ tool cannot know).
|
|
|
147
160
|
|
|
148
161
|
## Settings
|
|
149
162
|
|
|
150
|
-
`.assuredloop/config.yaml`, at the repo's root. Every key is optional.
|
|
163
|
+
`.assuredloop/config.yaml`, at the repo's root. Every key is optional. A
|
|
164
|
+
spec root that is outside the repository, reached through a symlink, or not a
|
|
165
|
+
folder is a `not ok` in `al check`. Only the default root, `specs`, may be
|
|
166
|
+
absent.
|
|
151
167
|
|
|
152
168
|
```yaml
|
|
153
169
|
root: specs # the spec folder; every *.md in it except adr/ is a spec doc
|
|
@@ -260,7 +276,7 @@ those commits. To move:
|
|
|
260
276
|
| The tool checks | The skill asks | The owner decides |
|
|
261
277
|
|---|---|---|
|
|
262
278
|
| `al conclude` refuses on its three conditions (see "The close rule") | write the owner's words and the organized requirement, and ask for the sign-off | what is wanted, by signing the organized requirement |
|
|
263
|
-
| IDs: missing, used twice, lost, used again; kinds and links; links that do not resolve; sign-off coverage of promise changes; path and typo claims; dispositions and exact equality; stale bindings; overlapping changes; ADR text changes; result applicability, as hints | choose the path, and state it in a commit message and the PR | whether a changed requirement is signed again |
|
|
279
|
+
| IDs: missing, used twice, lost, used again; kinds and links; links that do not resolve; sign-off coverage of promise changes; path and typo claims; dispositions and exact equality; stale bindings; overlapping changes; ADR text changes; result applicability; a design paragraph that links to none of its requirement's promise paragraphs in its change spec; a `for:` that names no task, as hints | choose the path, and state it in a commit message and the PR | whether a changed requirement is signed again |
|
|
264
280
|
| `check --strict` exits 1 on a `not ok`, when a project opts in | record each later decision, with its source | decisions whose source is the owner, such as dropping or keeping work |
|
|
265
281
|
|
|
266
282
|
`al` checks that declarations are well formed and that references resolve.
|
package/bin/al.js
CHANGED
|
@@ -29,7 +29,7 @@ const COMMANDS = {
|
|
|
29
29
|
index: { run: index, positionals: 0, options: { align: { type: 'string' } } },
|
|
30
30
|
spec: { run: spec, positionals: (o) => (o['add-ids'] === undefined ? Infinity : 0), options: { 'add-ids': { type: 'string' }, prefix: { type: 'string' }, yes: { type: 'boolean' } } },
|
|
31
31
|
check: { run: check, positionals: 0, options: { strict: { type: 'boolean' } } },
|
|
32
|
-
export: { run: exportCommand, positionals: 0, options: { at: { type: 'string' }, out: { type: 'string' } } },
|
|
32
|
+
export: { run: exportCommand, positionals: 0, options: { at: { type: 'string' }, out: { type: 'string' }, yes: { type: 'boolean' } } },
|
|
33
33
|
search: {
|
|
34
34
|
run: search,
|
|
35
35
|
options: {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@assuredloop/cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
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": {
|
|
@@ -57,8 +57,13 @@ The export link MUST expire 30 minutes after the email is sent.
|
|
|
57
57
|
```
|
|
58
58
|
|
|
59
59
|
- When you edit a marked paragraph, keep its marker line and the blank lines
|
|
60
|
-
around it. Keep the ID when you move the paragraph. Never renumber
|
|
61
|
-
|
|
60
|
+
around it. Keep the ID when you move the paragraph. Never renumber an ID,
|
|
61
|
+
and never use one again.
|
|
62
|
+
- In an open request's change spec, you may delete a paragraph with its
|
|
63
|
+
marker. Its ID is retired and never used again. `al check` gives a hint
|
|
64
|
+
that names the deletion; when it is on purpose, nothing more is needed. In
|
|
65
|
+
`specs/`, a removal needs `removes:<ID>` in a change spec: a lost ID there
|
|
66
|
+
is `not ok`.
|
|
62
67
|
- For a new paragraph, write the text with no marker. Then run
|
|
63
68
|
`al spec --add-ids <file> --yes`: it gives each unmarked paragraph the next
|
|
64
69
|
free ID. Then write the kind and the links into each new marker.
|
|
@@ -131,11 +136,19 @@ The export link MUST expire 30 minutes after the email is sent.
|
|
|
131
136
|
deliver with the owner's sign-off or after it. A change that touches no
|
|
132
137
|
promise kind needs no sign-off.
|
|
133
138
|
2. Write the design in `requests/<name>/spec.md`, the change spec: the
|
|
134
|
-
promises this change adds or changes, its design, its plan.
|
|
139
|
+
promises this change adds or changes, its design, its plan. The order
|
|
140
|
+
of the paragraphs is free. The recommended order is one section per
|
|
141
|
+
topic. Each section has the promise paragraph first, then the design
|
|
142
|
+
that keeps it, with its rationale and examples, then its open points.
|
|
143
|
+
Cross-cutting design and decisions follow. Run
|
|
135
144
|
`al spec --add-ids requests/<name>/spec.md --yes` for the IDs (SP-1,
|
|
136
145
|
SP-2…), then write the kinds and links. A paragraph that relies on a spec
|
|
137
146
|
paragraph says `builds-on:<ID>`. One that replaces it says `changes:<ID>`.
|
|
138
|
-
One that removes it says `removes:<ID>`. Never copy the old text.
|
|
147
|
+
One that removes it says `removes:<ID>`. Never copy the old text. A
|
|
148
|
+
design or approach paragraph that keeps a promise paragraph of this
|
|
149
|
+
change spec says `builds-on:SP-n`. `al check` gives a hint when it serves
|
|
150
|
+
a requirement and links to none of that requirement's promise
|
|
151
|
+
paragraphs here.
|
|
139
152
|
3. Snapshot each later word of the owner that you rely on:
|
|
140
153
|
`al record <name> origin --url <where> --from - --yes`. Record each later
|
|
141
154
|
decision: `al record <name> decision --source <who> --text "<it>" --yes`.
|
|
@@ -178,8 +191,9 @@ As path 2, in parts. Each part consolidates the paragraphs it delivers,
|
|
|
178
191
|
with their dispositions, in its own PR. Tasks are optional: where the
|
|
179
192
|
project uses them, list them in the record as
|
|
180
193
|
`tasks: [{id: T2, ref: "issue #34", delivers: [R3]}]`, and name the task in
|
|
181
|
-
the commit messages of its PRs.
|
|
182
|
-
|
|
194
|
+
the commit messages of its PRs. Plan, step and migration paragraphs may live
|
|
195
|
+
in the tracker issue that the task's `ref` names, instead of in the change
|
|
196
|
+
spec. Run `al conclude <name> --yes` with the last part.
|
|
183
197
|
|
|
184
198
|
## Path S: a spike
|
|
185
199
|
|
package/src/v4/check.js
CHANGED
|
@@ -4,11 +4,14 @@
|
|
|
4
4
|
// checks that read records (T10, checks.js). The base is the merge-base of
|
|
5
5
|
// HEAD with main; the head is the working tree. It exits 0; with --strict, 1
|
|
6
6
|
// when it prints a `not ok`. --strict raises every marker lint and an invalid
|
|
7
|
-
// disposition to `not ok`; the hints that need judgment stay hints.
|
|
7
|
+
// disposition to `not ok`; the hints that need judgment stay hints. A lost ID
|
|
8
|
+
// of an open request's change spec is a hint, also under --strict (#230).
|
|
9
|
+
import { parse as parseYaml } from 'yaml';
|
|
10
|
+
import { read, recordPath } from './base.js';
|
|
8
11
|
import { loadConfig, loadSchema } from './config.js';
|
|
9
12
|
import { mergeBase } from './git.js';
|
|
10
13
|
import { diffParagraphs, idLints } from './ids.js';
|
|
11
|
-
import { docsInScope, idsEverUsed, isShallow, notRead, parseScopes, rootProblem, SPEC } from './scope.js';
|
|
14
|
+
import { docsInScope, idsEverUsed, isShallow, notRead, parseScopes, rootNotOk, rootProblem, SPEC } from './scope.js';
|
|
12
15
|
import { Fail } from './spec.js';
|
|
13
16
|
import { centralLookup } from './repos.js';
|
|
14
17
|
import { checks } from './checks.js';
|
|
@@ -34,7 +37,7 @@ export function check({ top, opts }) {
|
|
|
34
37
|
lints.push(...idLints(head, head));
|
|
35
38
|
continue;
|
|
36
39
|
}
|
|
37
|
-
lints.push(...idLints(before, head, { everUsed: used.get(scope) ?? [], removes: scope === SPEC ? removes : [] }));
|
|
40
|
+
lints.push(...idLints(before, head, { everUsed: used.get(scope) ?? [], removes: scope === SPEC ? removes : [] }).map((l) => deleted(top, l)));
|
|
38
41
|
changes.push(...diffParagraphs(before, head).filter((c) => c.changes.length));
|
|
39
42
|
}
|
|
40
43
|
|
|
@@ -43,14 +46,17 @@ export function check({ top, opts }) {
|
|
|
43
46
|
const shown = lints.filter((l) => !(l.code === 'no-link' && t10.adopted.has(l.id) && !l.file.startsWith('requests/')));
|
|
44
47
|
lints.length = 0;
|
|
45
48
|
lints.push(...shown);
|
|
46
|
-
const severity = (l) => (opts.strict ? 'not ok' : l.severity);
|
|
49
|
+
const severity = (l) => (opts.strict && !l.staysHint ? 'not ok' : l.severity);
|
|
47
50
|
const body = notRead(top, config);
|
|
51
|
+
// A root that config sets but that is not read is a not ok, as in al index (#218).
|
|
52
|
+
const rootLine = rootNotOk(top, config);
|
|
53
|
+
if (rootLine) body.push(rootLine);
|
|
48
54
|
body.push(...lints.map((l) => `${severity(l)} ${l.code} ${l.file}:${l.line} ${l.id ?? '-'} ${l.message}`));
|
|
49
55
|
if (base) body.push(...changes.map((c) => `${c.file} ${c.id} ${c.changes.join('+')}`));
|
|
50
56
|
if (!lints.length) body.push('ok: no marker lint');
|
|
51
57
|
body.push(...t10.findings.map((f) => `${f.severity} ${f.code} ${f.where} ${f.id} ${f.message}`));
|
|
52
58
|
// Under --strict every marker lint is a not ok.
|
|
53
|
-
const notOk = lints.
|
|
59
|
+
const notOk = Boolean(rootLine) || lints.some((l) => severity(l) === 'not ok') || t10.findings.some((f) => f.severity === 'not ok');
|
|
54
60
|
// In an output repo: the central IDs that the branch's changed files cite (T12).
|
|
55
61
|
const central = centralLookup(top);
|
|
56
62
|
body.push(...central.body);
|
|
@@ -61,13 +67,38 @@ export function check({ top, opts }) {
|
|
|
61
67
|
return {
|
|
62
68
|
body,
|
|
63
69
|
read: base ? `working tree · base ${base.slice(0, 7)} (merge-base with main)` : 'working tree · no base',
|
|
64
|
-
next: nextStep([...lints.map(severity), ...t10.findings.map((f) => f.severity),
|
|
70
|
+
next: nextStep([...(rootLine ? ['not ok'] : []), ...lints.map(severity), ...t10.findings.map((f) => f.severity),
|
|
65
71
|
...central.body.map((l) => (/^not ok /.test(l) ? 'not ok' : /^hint /.test(l) ? 'hint' : 'info'))], lints.some((l) => l.code === 'no-id')),
|
|
66
72
|
notKnown,
|
|
67
73
|
exit: opts.strict && notOk ? 1 : 0,
|
|
68
74
|
};
|
|
69
75
|
}
|
|
70
76
|
|
|
77
|
+
// A lost ID of an open request's change spec (its file at the base is
|
|
78
|
+
// requests/<name>/spec.md): deleting a paragraph there is ordinary editing,
|
|
79
|
+
// so it is a hint that names the deletion and each disposition or wording
|
|
80
|
+
// decision of the request's record that names the ID (#230, D1-D4).
|
|
81
|
+
const OPEN_SPEC = /^requests\/(?!archive\/)([^/]+)\/spec\.md$/;
|
|
82
|
+
function deleted(top, l) {
|
|
83
|
+
const name = l.code === 'lost-id' && OPEN_SPEC.exec(l.file)?.[1];
|
|
84
|
+
if (!name) return l;
|
|
85
|
+
const source = `${name}/${l.id}`;
|
|
86
|
+
let rec;
|
|
87
|
+
try {
|
|
88
|
+
rec = parseYaml(read(top, recordPath(name)) ?? 'null');
|
|
89
|
+
} catch {
|
|
90
|
+
rec = null;
|
|
91
|
+
}
|
|
92
|
+
const list = (x) => (Array.isArray(x) ? x : []);
|
|
93
|
+
const says = { incorporated: (d) => `incorporated as ${d.spec}`, removed: (d) => `removed ${d.spec}`, superseded: (d) => `superseded by ${d.by}` };
|
|
94
|
+
const facts = [
|
|
95
|
+
...list(rec?.dispositions).filter((d) => d?.source === source && says[d.disposition]).map((d) => `its disposition says ${says[d.disposition](d)}`),
|
|
96
|
+
...list(rec?.decisions).filter((d) => d?.wording?.source === source).map((d) => `decision ${d.id} names its wording`),
|
|
97
|
+
];
|
|
98
|
+
const message = `${source} is deleted${facts.map((f) => `; ${f}`).join('')}. If that is on purpose, nothing more is needed: the ID is retired and never used again.`;
|
|
99
|
+
return { ...l, severity: 'hint', staysHint: true, message };
|
|
100
|
+
}
|
|
101
|
+
|
|
71
102
|
// The Next line names what to do for what printed: a not ok, a hint, both, or
|
|
72
103
|
// neither (#198).
|
|
73
104
|
function nextStep(severities, noId) {
|
package/src/v4/checks.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { createHash } from 'node:crypto';
|
|
7
7
|
import { parse as parseYaml } from 'yaml';
|
|
8
8
|
import { read } from './base.js';
|
|
9
|
-
import { judgeRequest } from './dispositions.js';
|
|
9
|
+
import { droppedUnsigned, judgeRequest } from './dispositions.js';
|
|
10
10
|
import { commitsOf, git } from './git.js';
|
|
11
11
|
import { diffParagraphs } from './ids.js';
|
|
12
12
|
import { crossResults } from './repos.js';
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
} from './state.js';
|
|
17
17
|
import { NEAR, closeness, diffWords, marks, requirementWords, sentencesOf, showHunk } from './words.js';
|
|
18
18
|
import { groupOf, sameBlocks } from './markers.js';
|
|
19
|
+
import { isCentral } from './indexer.js';
|
|
19
20
|
|
|
20
21
|
const DESIGN = ['component', 'interface', 'data', 'flow', 'choice'];
|
|
21
22
|
const TIER = /^[ \t]*Tier:[ \t]*(0|1d|1|2|3|S)\b[ \t]*(?:—|–|--?)?[ \t]*(.*)$/m;
|
|
@@ -62,13 +63,12 @@ function changesOf(now, was) {
|
|
|
62
63
|
|
|
63
64
|
const changeWord = (c) => (c.changes.includes('New') ? 'new' : c.changes.includes('Removed') ? 'removed' : c.changed ? 'changed' : 'given another kind');
|
|
64
65
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const add = (severity, code, p, id, message) => findings.push({ severity, code, where: typeof p === 'string' ? p : at(p), id, message });
|
|
66
|
+
// The branch against its base, in states `now` and `was` (null with no
|
|
67
|
+
// base): each paragraph's change, adoption aside, the path claim, and the
|
|
68
|
+
// promise changes with no signed requirement that covers them (design.md 6).
|
|
69
|
+
// A change paragraph that is validly abandoned with no kept effect needs none.
|
|
70
|
+
// al check reports each of those, and al conclude refuses on them (DES-37).
|
|
71
|
+
export function branchOf(top, now, was, base) {
|
|
72
72
|
const promise = (k) => isPromise(now, k);
|
|
73
73
|
const open = [...now.requests.values()].filter((r) => r.open);
|
|
74
74
|
// Adoption on the branch (D16, D21): a spec paragraph that is new at the
|
|
@@ -102,9 +102,12 @@ export function checks(top, { base, strict = false }) {
|
|
|
102
102
|
const isPromiseChange = (c) => (c.changed && (promise(c.head?.kind) || promise(c.base?.kind)))
|
|
103
103
|
|| (c.kindChange && (promise(c.head.kind) || promise(c.base.kind)));
|
|
104
104
|
|
|
105
|
-
// Sign-off coverage
|
|
105
|
+
// Sign-off coverage: a promise change with no signed requirement that covers it.
|
|
106
|
+
const uncovered = [];
|
|
106
107
|
for (const c of changes.filter(isPromiseChange)) {
|
|
107
108
|
if (typo.has(c.name)) continue;
|
|
109
|
+
const r = c.request && c.head ? now.requests.get(c.request) : null;
|
|
110
|
+
if (r && !r.spike && droppedUnsigned(now, r, c.head, { typo, baseSpec: was?.spec ?? null })) continue;
|
|
108
111
|
let covered;
|
|
109
112
|
if (!c.head) {
|
|
110
113
|
if (c.request) continue; // a change spec paragraph taken out is no promise change
|
|
@@ -113,9 +116,26 @@ export function checks(top, { base, strict = false }) {
|
|
|
113
116
|
} else {
|
|
114
117
|
covered = servesSigned(now, c.head, c.request);
|
|
115
118
|
}
|
|
116
|
-
if (!covered)
|
|
119
|
+
if (!covered) uncovered.push(c);
|
|
117
120
|
}
|
|
118
121
|
|
|
122
|
+
return { changes, captured, notAdopted, claim, commits, typo, isPromiseChange, uncovered };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function checks(top, { base, strict = false }) {
|
|
126
|
+
const now = loadState(top);
|
|
127
|
+
const was = base ? loadState(top, base) : null;
|
|
128
|
+
const findings = [];
|
|
129
|
+
const notKnown = [];
|
|
130
|
+
const at = (p) => (p ? `${p.file}:${p.line}` : '-');
|
|
131
|
+
const add = (severity, code, p, id, message) => findings.push({ severity, code, where: typeof p === 'string' ? p : at(p), id, message });
|
|
132
|
+
const promise = (k) => isPromise(now, k);
|
|
133
|
+
const open = [...now.requests.values()].filter((r) => r.open);
|
|
134
|
+
const { changes, captured, notAdopted, claim, commits, typo, isPromiseChange, uncovered } = branchOf(top, now, was, base);
|
|
135
|
+
|
|
136
|
+
// Sign-off coverage (design.md 6): a promise change with no signed requirement that covers it.
|
|
137
|
+
for (const c of uncovered) add('not ok', 'signoff-coverage', c.head ?? c.base, c.name, `a ${(c.head ?? c.base).kind} paragraph is ${changeWord(c)} with no signed requirement that covers it`);
|
|
138
|
+
|
|
119
139
|
// The path claim (design.md 7), and the typo claim's words (design.md 6).
|
|
120
140
|
if (!base) notKnown.push('no base, so the path claim was not read');
|
|
121
141
|
else if (!claim) {
|
|
@@ -237,6 +257,50 @@ export function checks(top, { base, strict = false }) {
|
|
|
237
257
|
}
|
|
238
258
|
}
|
|
239
259
|
|
|
260
|
+
// Design links (design.md 3): a design or approach paragraph of a change
|
|
261
|
+
// spec that serves a requirement, where promise paragraphs of the same
|
|
262
|
+
// change spec serve it, and that builds on or changes none of them. In the
|
|
263
|
+
// central repo, `central:X` is its own X (design.md 12).
|
|
264
|
+
const self = (ref) => (isCentral(now.config) && ref.startsWith('central:') ? ref.slice('central:'.length) : ref);
|
|
265
|
+
for (const r of open) {
|
|
266
|
+
const own = new Set(r.paras.keys());
|
|
267
|
+
const key = (ref) => qualify(self(ref), r.name, own).key;
|
|
268
|
+
const promises = new Map();
|
|
269
|
+
for (const p of r.paras.values()) {
|
|
270
|
+
if (!promise(p.kind)) continue;
|
|
271
|
+
for (const k of new Set(p.links.serves.map(key))) {
|
|
272
|
+
if (!promises.has(k)) promises.set(k, []);
|
|
273
|
+
promises.get(k).push(p.id);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
if (!promises.size) continue;
|
|
277
|
+
for (const p of r.paras.values()) {
|
|
278
|
+
if (groupOf(p.kind, now.kinds) !== 'design') continue;
|
|
279
|
+
const linked = new Set([...p.links.buildsOn, ...p.links.changes].map(key));
|
|
280
|
+
for (const k of new Set(p.links.serves.map(key))) {
|
|
281
|
+
const ids = promises.get(k);
|
|
282
|
+
if (!ids || ids.some((id) => linked.has(`${r.name}/${id}`))) continue;
|
|
283
|
+
add('hint', 'design-link', p, `${r.name}/${p.id}`, `serves ${k}, but links to none of its promise paragraphs in this change spec (${ids.join(', ')}): which promise does it keep?`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Task references (design.md 8): where the project uses tasks, a `for:`
|
|
289
|
+
// that names no task in the record. A bare task is the paragraph's own
|
|
290
|
+
// request's. The records are read only when an open change spec has a `for:`.
|
|
291
|
+
const tasksOf = (r) => (Array.isArray(r?.data?.tasks) ? r.data.tasks : []);
|
|
292
|
+
const fors = open.flatMap((r) => [...r.paras.values()].flatMap((p) => p.links.for.map((ref) => ({ r, p, ref }))));
|
|
293
|
+
if (fors.length && [...now.requests.values()].some((r) => tasksOf(r).length)) {
|
|
294
|
+
for (const { r, p, ref } of fors) {
|
|
295
|
+
const t = self(ref);
|
|
296
|
+
if (t.includes(':')) continue;
|
|
297
|
+
const slash = t.indexOf('/');
|
|
298
|
+
const [name, id] = slash > 0 ? [t.slice(0, slash), t.slice(slash + 1)] : [r.name, t];
|
|
299
|
+
if (tasksOf(now.requests.get(name)).some((x) => String(x?.id) === id)) continue;
|
|
300
|
+
add('hint', 'unknown-task', p, `${r.name}/${p.id}`, `for:${ref}: no task ${id} in the record of ${name}`);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
240
304
|
// Bindings whose target changed since they were bound (design.md 4, 6).
|
|
241
305
|
for (const r of open) {
|
|
242
306
|
for (const b of Array.isArray(r.data?.bindings) ? r.data.bindings : []) {
|
package/src/v4/conclude.js
CHANGED
|
@@ -2,13 +2,16 @@
|
|
|
2
2
|
// only command that refuses. It reads the working tree, the closing PR's
|
|
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
|
-
// baseline effect has no valid disposition for its current version.
|
|
5
|
+
// baseline effect has no valid disposition for its current version. A promise
|
|
6
|
+
// change of the branch, against the merge-base with main, counts as al check
|
|
7
|
+
// counts it (branchOf, #217). With
|
|
6
8
|
// --yes it writes the Outcome, sets the status and archives the request.
|
|
7
9
|
import { mkdirSync, renameSync } from 'node:fs';
|
|
8
10
|
import { dirname, join } from 'node:path';
|
|
9
11
|
import { Fail, day, exists, guard, isName, now, write } from './base.js';
|
|
12
|
+
import { branchOf } from './checks.js';
|
|
10
13
|
import { judgeRequest } from './dispositions.js';
|
|
11
|
-
import { git } from './git.js';
|
|
14
|
+
import { git, mergeBase } from './git.js';
|
|
12
15
|
import { openRecord, recordText } from './records.js';
|
|
13
16
|
import { isPromise, loadState, qualify, servesSigned } from './state.js';
|
|
14
17
|
import { signState } from './views.js';
|
|
@@ -17,9 +20,11 @@ const NOT_KNOWN = ['whether the code, tests and documents do what the paragraphs
|
|
|
17
20
|
'whether the work has ended: the tool does not take a merged task or a reconciled spec as evidence of that'];
|
|
18
21
|
|
|
19
22
|
// The reasons the close rule refuses, each naming its IDs; [] when it holds.
|
|
20
|
-
|
|
23
|
+
// `branch` holds the branch's promise changes that no signed requirement covers.
|
|
24
|
+
function reasons(state, r, branch) {
|
|
21
25
|
const out = [];
|
|
22
26
|
if (r.spike) {
|
|
27
|
+
if (branch.length) out.push(`a promise change with no signed requirement that covers it: ${branch.map((c) => c.name).join(', ')}`);
|
|
23
28
|
if (!r.reqs.length) out.push(`the spike has no organized question to sign in ${r.dir}/request.md`);
|
|
24
29
|
const unsigned = r.reqs.filter((q) => !signState(state, r, q).startsWith('signed'));
|
|
25
30
|
if (unsigned.length) out.push(`the spike's current question is not signed: ${unsigned.map((q) => `${r.name}/${q.id} (${signState(state, r, q)})`).join(', ')}`);
|
|
@@ -33,8 +38,9 @@ function reasons(state, r) {
|
|
|
33
38
|
...[...r.paras.values()].filter((p) => isPromise(state, p.kind) && !dropped.has(p.id) && !servesSigned(state, p, r.name)).map((p) => `${r.name}/${p.id}`),
|
|
34
39
|
...[...state.spec.values()].filter((p) => isPromise(state, p.kind) && p.links.serves.some((s) => qualify(s, null).req === r.name)
|
|
35
40
|
&& !servesSigned(state, p, null)).map((p) => p.id),
|
|
41
|
+
...branch.map((c) => c.name),
|
|
36
42
|
];
|
|
37
|
-
if (unsigned.length) out.push(`a promise change with no signed requirement that covers it: ${unsigned.join(', ')}`);
|
|
43
|
+
if (unsigned.length) out.push(`a promise change with no signed requirement that covers it: ${[...new Set(unsigned)].join(', ')}`);
|
|
38
44
|
const open = judged.filter((j) => j.status === 'pending' || !j.valid);
|
|
39
45
|
if (open.length) {
|
|
40
46
|
out.push(`no valid disposition for the current version of ${open.map((j) => `${j.id} (${j.status === 'pending' ? 'none' : `${j.status}, not valid: ${j.reason}`})`).join(', ')}`);
|
|
@@ -89,13 +95,17 @@ export function conclude({ top, args, opts }) {
|
|
|
89
95
|
const r = state.requests.get(name);
|
|
90
96
|
if (!r) throw new Fail(`no request named ${name}`, 'al context lists the open requests');
|
|
91
97
|
if (!r.open) throw new Fail(`${name} is already archived (requests/archive/${name}); an archived request is not concluded again`, `al context ${name}`);
|
|
92
|
-
const
|
|
98
|
+
const base = mergeBase(top);
|
|
99
|
+
const branch = base ? branchOf(top, state, loadState(top, base), base).uncovered : [];
|
|
100
|
+
const read = base ? `working tree · base ${base.slice(0, 7)} (merge-base with main)` : 'working tree · no base';
|
|
101
|
+
const notKnown = base ? NOT_KNOWN : ['the promise changes of the branch: no base (no main branch, or no commit)', ...NOT_KNOWN];
|
|
102
|
+
const { out, judged } = reasons(state, r, branch);
|
|
93
103
|
if (out.length) {
|
|
94
104
|
return {
|
|
95
105
|
body: out.map((x) => `refused: ${x}`),
|
|
96
|
-
read
|
|
106
|
+
read,
|
|
97
107
|
next: `al context ${name}; sign, record the dispositions, or align what is named, then al index`,
|
|
98
|
-
notKnown
|
|
108
|
+
notKnown,
|
|
99
109
|
exit: 1,
|
|
100
110
|
};
|
|
101
111
|
}
|
|
@@ -104,7 +114,7 @@ export function conclude({ top, args, opts }) {
|
|
|
104
114
|
if (exists(top, target)) throw new Fail(`${target} already exists; a request name is never reused`, `al context ${name}`);
|
|
105
115
|
const what = `Status: concluded, the Outcome in ${target}/request.md, status: concluded in the record, and ${r.dir}/ moved to ${target}/`;
|
|
106
116
|
if (!opts.yes) {
|
|
107
|
-
return { body: [`Would conclude ${name}: ${what}`, ...block.slice(2).map((l) => ` ${l}`)], read
|
|
117
|
+
return { body: [`Would conclude ${name}: ${what}`, ...block.slice(2).map((l) => ` ${l}`)], read, next: 'run the same command with --yes to do it', notKnown };
|
|
108
118
|
}
|
|
109
119
|
const rec = openRecord(top, name);
|
|
110
120
|
if (!rec) throw new Fail(`${name} has no record .assuredloop/records/requests/${name}.yaml`, 'a v4 request is made with al new');
|
|
@@ -115,5 +125,5 @@ export function conclude({ top, args, opts }) {
|
|
|
115
125
|
renameSync(join(top, r.dir), join(top, target));
|
|
116
126
|
write(top, `${target}/request.md`, md);
|
|
117
127
|
write(top, rec.path, recordText(rec));
|
|
118
|
-
return { body: [`Concluded ${name}: ${what}`], read
|
|
128
|
+
return { body: [`Concluded ${name}: ${what}`], read, next: 'commit the archive move with the closing PR', notKnown };
|
|
119
129
|
}
|
package/src/v4/config.js
CHANGED
|
@@ -25,7 +25,8 @@ function read(top, name) {
|
|
|
25
25
|
export function loadConfig(top) {
|
|
26
26
|
const c = read(top, 'config.yaml') ?? {};
|
|
27
27
|
if (typeof c !== 'object' || Array.isArray(c)) throw new Error(`${DIR}/config.yaml: give a mapping of settings`);
|
|
28
|
-
|
|
28
|
+
// rootSet: config names the root itself, so al check may hold it to that (#218).
|
|
29
|
+
return { ...c, root: c.root ?? 'specs', rootSet: c.root != null, docs: Array.isArray(c.docs) ? c.docs : [] };
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
export function loadSchema(top) {
|
package/src/v4/dispositions.js
CHANGED
|
@@ -166,6 +166,17 @@ const nearNotes = (state, p) => {
|
|
|
166
166
|
return near ? [`near-match ${near.id}: applied in another form?`] : [];
|
|
167
167
|
};
|
|
168
168
|
|
|
169
|
+
// Whether change paragraph `p` of request `r` is validly abandoned at its
|
|
170
|
+
// current version with no kept effect, so that it needs no sign-off (design.md
|
|
171
|
+
// 6). An archived request is judged here too: on the branch that archives it,
|
|
172
|
+
// al check reads the paragraph as al conclude read it (#226).
|
|
173
|
+
export function droppedUnsigned(state, r, p, opts = {}) {
|
|
174
|
+
const d = dispositionOf(r.data, `${r.name}/${p.id}`, p.sha256);
|
|
175
|
+
if (d?.disposition !== 'abandoned') return false;
|
|
176
|
+
const v = judge(state, r, p, d, opts);
|
|
177
|
+
return v.valid && v.kept === false;
|
|
178
|
+
}
|
|
179
|
+
|
|
169
180
|
// Every change paragraph of request `r` with a baseline effect, with the
|
|
170
181
|
// disposition of its current version and its validity. A spike has none.
|
|
171
182
|
export function judgeRequest(state, r, opts = {}) {
|
package/src/v4/export.js
CHANGED
|
@@ -11,7 +11,7 @@ import { requirements } from './request-md.js';
|
|
|
11
11
|
import { git, readObjects } from './git.js';
|
|
12
12
|
import { findCites, namedIds, outputRepos, repoConfigAt } from './repos.js';
|
|
13
13
|
import { SPEC, scopeOf } from './scope.js';
|
|
14
|
-
import { Fail, guard, write } from './base.js';
|
|
14
|
+
import { Fail, exists, guard, write } from './base.js';
|
|
15
15
|
|
|
16
16
|
const OUTPUT_LINKS = ['implements', 'verifies', 'documents'];
|
|
17
17
|
const sha256 = (text) => createHash('sha256').update(text).digest('hex');
|
|
@@ -449,13 +449,18 @@ export function sectionAt(top, commit, file, id) {
|
|
|
449
449
|
// A row as the export writes it: the design's fields, in a fixed order.
|
|
450
450
|
export const exported = ({ title, ...row }) => row;
|
|
451
451
|
|
|
452
|
-
// al export [--at <commit>] [--out <file>]
|
|
452
|
+
// al export [--at <commit>] [--out <file>] [--yes]: --out shows what it
|
|
453
|
+
// would write, and writes only with --yes (#219).
|
|
453
454
|
export function exportCommand({ top, opts }) {
|
|
454
455
|
const commit = resolveCommit(top, opts.at);
|
|
455
456
|
const { rows, repos } = exportRows(top, commit);
|
|
456
457
|
const text = rows.map((r) => JSON.stringify(exported(r))).join('\n') + (rows.length ? '\n' : '');
|
|
457
458
|
if (opts.out === undefined) return { raw: text };
|
|
458
459
|
guard(top, [opts.out]);
|
|
460
|
+
if (!opts.yes) {
|
|
461
|
+
const replaces = exists(top, opts.out) ? 'replacing the existing file' : 'a new file';
|
|
462
|
+
return { body: [`Would write ${rows.length} rows to ${opts.out}, ${replaces}, at ${commit.slice(0, 12)}`], next: 'run the same command with --yes to write it' };
|
|
463
|
+
}
|
|
459
464
|
write(top, opts.out, text);
|
|
460
465
|
const roles = {};
|
|
461
466
|
for (const r of rows) roles[r.role] = (roles[r.role] ?? 0) + 1;
|
package/src/v4/request-md.js
CHANGED
|
@@ -32,6 +32,18 @@ export function signedText(md) {
|
|
|
32
32
|
return `${kept.join('\n')}\n`;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
// The signed text `text` (as signedText gives it) with no requirement in it:
|
|
36
|
+
// its heading, the intro and the Out: and Assumed: text. Each requirement of
|
|
37
|
+
// the section is signed with it, so a change to it unsigns them all (#216).
|
|
38
|
+
export function frameOf(text) {
|
|
39
|
+
let inReq = false;
|
|
40
|
+
return text.split('\n').filter((l) => {
|
|
41
|
+
if (/^###\s+[RQ]\d+\b/.test(l)) inReq = true;
|
|
42
|
+
else if (/^#{1,6}\s/.test(l) || /^(Out|Assumed|Signed off):/.test(l)) inReq = false;
|
|
43
|
+
return !inReq;
|
|
44
|
+
}).join('\n');
|
|
45
|
+
}
|
|
46
|
+
|
|
35
47
|
// Each `### R<n> <title>` (or `### Q<n>`): { id, title, text, sha256, from }.
|
|
36
48
|
// Its text runs from the heading to the next heading, or an Out:, Assumed:
|
|
37
49
|
// or Signed off: line, with no marker line and no blank line at either end.
|
package/src/v4/scope.js
CHANGED
|
@@ -49,6 +49,28 @@ export const rootProblem = (config) => (!outside(config.root) && ['.', ''].inclu
|
|
|
49
49
|
? `.assuredloop/config.yaml: root: ${JSON.stringify(String(config.root))} is the repository top; give the spec folder, for example specs`
|
|
50
50
|
: null);
|
|
51
51
|
|
|
52
|
+
// The not ok line of al check for a root that cannot be read: outside the
|
|
53
|
+
// repository, through a symlink, or no folder; else null. The default root
|
|
54
|
+
// may be absent, with nothing at its path (#218).
|
|
55
|
+
export function rootNotOk(top, config) {
|
|
56
|
+
const at = join(top, clean(config.root));
|
|
57
|
+
if (!config.rootSet && !lexists(at)) return null;
|
|
58
|
+
const why = outside(config.root) ? 'outside the repository'
|
|
59
|
+
: symlinkOn(top, clean(config.root)) ? 'through a symlink'
|
|
60
|
+
: !existsSync(at) || !lstatSync(at).isDirectory() ? 'no such folder' : null;
|
|
61
|
+
return why && `not ok spec-root .assuredloop/config.yaml - ${String(config.root).replace(/\/+$/, '')}/: ${why}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Whether anything is at `at`, a dangling symlink included.
|
|
65
|
+
function lexists(at) {
|
|
66
|
+
try {
|
|
67
|
+
lstatSync(at);
|
|
68
|
+
return true;
|
|
69
|
+
} catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
52
74
|
// One line for each root or docs entry that is not read, and why.
|
|
53
75
|
export const notRead = (top, config) => [
|
|
54
76
|
...outsideDocs(config).map((f) => `not read: ${f}: outside the repository`),
|
package/src/v4/state.js
CHANGED
|
@@ -4,11 +4,12 @@
|
|
|
4
4
|
import { readdirSync } from 'node:fs';
|
|
5
5
|
import { join, posix } from 'node:path';
|
|
6
6
|
import { parse } from 'yaml';
|
|
7
|
-
import { read, recordPath } from './base.js';
|
|
7
|
+
import { read, recordPath, sha256 } from './base.js';
|
|
8
8
|
import { loadConfig, loadSchema } from './config.js';
|
|
9
9
|
import { fileAt, filesAt, git } from './git.js';
|
|
10
10
|
import { KINDS, groupOf, parseMarkdown } from './markers.js';
|
|
11
|
-
import { requirements } from './request-md.js';
|
|
11
|
+
import { frameOf, requirements, signedText } from './request-md.js';
|
|
12
|
+
import { SIGNED } from './snapshot.js';
|
|
12
13
|
import { SPEC, docsInScope, parseScopes, symlinkOn } from './scope.js';
|
|
13
14
|
|
|
14
15
|
const ADR_FILE = /^(\d{4})-[^/]*\.md$/;
|
|
@@ -92,6 +93,29 @@ export function loadState(top, rev = null) {
|
|
|
92
93
|
const specFile = `${dir}/spec.md`;
|
|
93
94
|
const paras = (scopes.get(`request:${name}`) ?? []).filter((p) => p.file === specFile);
|
|
94
95
|
const tierOf = () => String(dataOf()?.tier ?? /^Tier:\s*(\S+)/m.exec(md)?.[1] ?? '');
|
|
96
|
+
// Whether sign-off `s` signed the intro, Out: and Assumed: text that the
|
|
97
|
+
// section has now (#216): its signed text is the section's, or its
|
|
98
|
+
// snapshot in origin/, checked against its hash, has the same frame.
|
|
99
|
+
const current = signedText(md);
|
|
100
|
+
const framed = new Map();
|
|
101
|
+
const framedBy = (s) => {
|
|
102
|
+
if (current === null) return false;
|
|
103
|
+
if (s.sha256 === sha256(current)) return true;
|
|
104
|
+
const file = typeof s.file === 'string' ? `${dir}/origin/${s.file.replace(/^origin\//, '')}` : null;
|
|
105
|
+
if (!file) return false;
|
|
106
|
+
if (!framed.has(file)) {
|
|
107
|
+
let snap = null;
|
|
108
|
+
try {
|
|
109
|
+
snap = get(file);
|
|
110
|
+
} catch {
|
|
111
|
+
snap = null;
|
|
112
|
+
}
|
|
113
|
+
const at = typeof snap === 'string' ? snap.indexOf(`\n${SIGNED}\n`) : -1;
|
|
114
|
+
const signed = at < 0 ? null : snap.slice(at + SIGNED.length + 2);
|
|
115
|
+
framed.set(file, signed !== null && sha256(signed) === s.sha256 ? frameOf(signed) : null);
|
|
116
|
+
}
|
|
117
|
+
return framed.get(file) === frameOf(current);
|
|
118
|
+
};
|
|
95
119
|
requests.set(name, {
|
|
96
120
|
name, open, dir, md, specFile,
|
|
97
121
|
get data() { return dataOf(); },
|
|
@@ -103,7 +127,7 @@ export function loadState(top, rev = null) {
|
|
|
103
127
|
// double-quoted string, so a record with a backslash always counts.
|
|
104
128
|
mentions: (x) => typeof text === 'string' && (text.includes(x) || text.includes('\\')),
|
|
105
129
|
mayAdopt: typeof text === 'string' && (text.includes('adoption') || text.includes('\\')),
|
|
106
|
-
reqs: requirements(md), paras: new Map(paras.map((p) => [p.id, p])),
|
|
130
|
+
reqs: requirements(md), paras: new Map(paras.map((p) => [p.id, p])), framedBy,
|
|
107
131
|
});
|
|
108
132
|
};
|
|
109
133
|
for (const n of openNames) add(n, true);
|
|
@@ -183,16 +207,22 @@ export function adrParagraph(state, id) {
|
|
|
183
207
|
export const latestVersion = (data, id) => (Array.isArray(data?.requirements) ? data.requirements : []).filter((v) => v?.id === id)
|
|
184
208
|
.reduce((a, v) => (Number(v.version) > Number(a?.version ?? 0) ? v : a), null);
|
|
185
209
|
|
|
186
|
-
//
|
|
187
|
-
// current text:
|
|
188
|
-
|
|
210
|
+
// The latest sign-off that signs requirement (or question) `id` of request
|
|
211
|
+
// `name` at its current text: it covers that id with the current text's hash,
|
|
212
|
+
// and it signed the section's current intro, Out: and Assumed: text. Null
|
|
213
|
+
// when there is none.
|
|
214
|
+
export function signedBy(state, name, id) {
|
|
189
215
|
const r = state.requests.get(name);
|
|
190
216
|
const q = r?.reqs.find((x) => x.id === id);
|
|
191
|
-
if (!q) return
|
|
217
|
+
if (!q) return null;
|
|
192
218
|
return (Array.isArray(r.data?.signoff) ? r.data.signoff : [])
|
|
193
|
-
.
|
|
219
|
+
.filter((s) => (Array.isArray(s?.covers) ? s.covers : []).some((c) => c?.id === id && c.sha256 === q.sha256) && r.framedBy(s))
|
|
220
|
+
.at(-1) ?? null;
|
|
194
221
|
}
|
|
195
222
|
|
|
223
|
+
// Whether requirement (or question) `id` of request `name` is signed at its current text.
|
|
224
|
+
export const signed = (state, name, id) => !!signedBy(state, name, id);
|
|
225
|
+
|
|
196
226
|
// Whether paragraph `p` (of request `request`, or of specs/ when null) serves a
|
|
197
227
|
// requirement that is signed at its current text.
|
|
198
228
|
export function servesSigned(state, p, request) {
|
package/src/v4/views.js
CHANGED
|
@@ -8,7 +8,8 @@ import { git, mergeBase, mergesOf } from './git.js';
|
|
|
8
8
|
import { crossRepo, findCites } from './repos.js';
|
|
9
9
|
import { diffParagraphs } from './ids.js';
|
|
10
10
|
import { loadResults } from './results.js';
|
|
11
|
-
import { latestVersion, loadState, named, qualify, requestOf, resolve,
|
|
11
|
+
import { latestVersion, loadState, named, qualify, requestOf, resolve, signedBy } from './state.js';
|
|
12
|
+
import { isCentral } from './indexer.js';
|
|
12
13
|
|
|
13
14
|
export const line = (label, text) => `${label.length < 10 ? label.padEnd(10) : `${label} `}${text}`;
|
|
14
15
|
const list = (x) => (Array.isArray(x) ? x : []);
|
|
@@ -21,10 +22,8 @@ export const NOT_CHECKED = 'whether each kind is right; whether a requirement is
|
|
|
21
22
|
// The sign-off state of one requirement or question: "signed in S1",
|
|
22
23
|
// "changed since S1" or "not signed".
|
|
23
24
|
export function signState(state, r, q) {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
return `signed in ${s.id}`;
|
|
27
|
-
}
|
|
25
|
+
const s = signedBy(state, r.name, q.id);
|
|
26
|
+
if (s) return `signed in ${s.id}`;
|
|
28
27
|
const before = list(r.data?.signoff).filter((x) => list(x?.covers).some((c) => c?.id === q.id)).at(-1);
|
|
29
28
|
return before ? `changed since ${before.id}` : 'not signed';
|
|
30
29
|
}
|
|
@@ -341,6 +340,25 @@ function checkLines(state, id, local) {
|
|
|
341
340
|
return out;
|
|
342
341
|
}
|
|
343
342
|
|
|
343
|
+
// The paragraphs of specs/ and of the open change specs that build on,
|
|
344
|
+
// explain or illustrate the paragraph named `who`. In the central repo,
|
|
345
|
+
// `central:X` is its own X (design.md 12).
|
|
346
|
+
function linkedTo(state, who) {
|
|
347
|
+
const self = (ref) => (isCentral(state.config) && ref.startsWith('central:') ? ref.slice('central:'.length) : ref);
|
|
348
|
+
const holders = [
|
|
349
|
+
...[...state.spec.values()].map((p) => ({ p, r: null })),
|
|
350
|
+
...[...state.requests.values()].filter((r) => r.open).flatMap((r) => [...r.paras.values()].map((p) => ({ p, r }))),
|
|
351
|
+
];
|
|
352
|
+
const out = [];
|
|
353
|
+
for (const { p, r } of holders) {
|
|
354
|
+
const own = r ? new Set(r.paras.keys()) : new Set();
|
|
355
|
+
for (const [key, verb] of [['buildsOn', 'builds on it'], ['explains', 'explains it'], ['illustrates', 'illustrates it']]) {
|
|
356
|
+
if (p.links[key].some((ref) => qualify(self(ref), r?.name ?? null, own).key === who)) out.push(`${named(r?.name ?? null, p.id)} ${verb}`);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return out.length ? out.join(' · ') : 'no paragraph builds on, explains or illustrates it';
|
|
360
|
+
}
|
|
361
|
+
|
|
344
362
|
function idView(state, id) {
|
|
345
363
|
const t = qualify(id, null);
|
|
346
364
|
const r = t.req ? state.requests.get(t.req) : null;
|
|
@@ -358,6 +376,7 @@ function idView(state, id) {
|
|
|
358
376
|
if (word === 'serves' || !refs.length) continue;
|
|
359
377
|
body.push(line('Links', `${word} ${refs.join(', ')}`));
|
|
360
378
|
}
|
|
379
|
+
body.push(line('Linked', linkedTo(state, who)));
|
|
361
380
|
if (!t.req) {
|
|
362
381
|
const govern = [...state.adrs.values()].filter((a) => (a.head?.links.decides ?? []).includes(p.id) || p.links.governedBy.includes(a.id));
|
|
363
382
|
body.push(line('Governed', govern.length ? `by ${govern.map((a) => `${a.id} (${a.status ?? 'no status'}${a.status === 'superseded' ? ': history' : ''})`).join(' · ')}` : 'by no ADR'));
|