@assuredloop/cli 0.2.0 → 0.2.2
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 +24 -18
- package/bin/al.js +1 -1
- package/package.json +9 -1
- package/src/v4/check.js +6 -3
- package/src/v4/checks.js +29 -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/level2.js +154 -0
- package/src/v4/request-md.js +12 -0
- package/src/v4/scope.js +22 -0
- package/src/v4/search.js +9 -21
- package/src/v4/state.js +38 -8
- package/src/v4/views.js +3 -5
package/README.md
CHANGED
|
@@ -28,13 +28,13 @@ It needs Node 24 or newer and git 2.31 or newer. Install the npm package
|
|
|
28
28
|
|
|
29
29
|
npm install --global @assuredloop/cli
|
|
30
30
|
|
|
31
|
-
The recommended install adds the
|
|
32
|
-
|
|
31
|
+
The recommended install adds the embedding model's library beside it, for
|
|
32
|
+
search at level 2 (see "Search"). `al` names the library as an optional
|
|
33
|
+
dependency, so npm installs it only when you ask:
|
|
33
34
|
|
|
34
|
-
npm install --global @assuredloop/cli @
|
|
35
|
+
npm install --global @assuredloop/cli @huggingface/transformers@4.3.1
|
|
35
36
|
|
|
36
37
|
To pin a version, name it: `npm install --global @assuredloop/cli@<version>`.
|
|
37
|
-
Give both packages the same version.
|
|
38
38
|
|
|
39
39
|
The package carries the skill, at
|
|
40
40
|
`$(npm root -g)/@assuredloop/cli/skills/assuredloop/SKILL.md`. Add one line to
|
|
@@ -49,7 +49,7 @@ the skill at the pinned version into the project, for example as
|
|
|
49
49
|
`.claude/skills/assuredloop/SKILL.md`. Name that path in the AGENTS.md line,
|
|
50
50
|
and say in the commit which version it came from.
|
|
51
51
|
|
|
52
|
-
Search at level 2 needs the
|
|
52
|
+
Search at level 2 needs the library `@huggingface/transformers` 4.3.1 (see
|
|
53
53
|
"Search"). Install it in the project, or beside `al`.
|
|
54
54
|
|
|
55
55
|
## The model in one page
|
|
@@ -136,8 +136,11 @@ with a baseline effect has no valid disposition for its current version.
|
|
|
136
136
|
request's whole trace; `--at <commit>` any v4 state.
|
|
137
137
|
- `al conclude <name>`: the close rule; with `--yes` it writes the Outcome
|
|
138
138
|
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
|
|
139
|
+
- `al export [--at <commit>] [--out <file>] [--yes]`: one JSONL row per
|
|
140
|
+
paragraph version, with its role, version, commit and links. It is not
|
|
141
|
+
committed. With no `--out`, it writes the rows to standard output. With
|
|
142
|
+
`--out`, it shows the path, the row count and whether it replaces an
|
|
143
|
+
existing file; with `--yes`, it writes the file.
|
|
141
144
|
- `al search <words>`: see "Search".
|
|
142
145
|
|
|
143
146
|
Every command that changes files, except `new` and `index`, shows what it
|
|
@@ -147,7 +150,10 @@ tool cannot know).
|
|
|
147
150
|
|
|
148
151
|
## Settings
|
|
149
152
|
|
|
150
|
-
`.assuredloop/config.yaml`, at the repo's root. Every key is optional.
|
|
153
|
+
`.assuredloop/config.yaml`, at the repo's root. Every key is optional. A
|
|
154
|
+
spec root that is outside the repository, reached through a symlink, or not a
|
|
155
|
+
folder is a `not ok` in `al check`. Only the default root, `specs`, may be
|
|
156
|
+
absent.
|
|
151
157
|
|
|
152
158
|
```yaml
|
|
153
159
|
root: specs # the spec folder; every *.md in it except adr/ is a spec doc
|
|
@@ -176,7 +182,7 @@ itself (2, then 1, then 0), and says which level answered and why.
|
|
|
176
182
|
|---|---|---|
|
|
177
183
|
| 0 | a scan of the export | nothing |
|
|
178
184
|
| 1 | a local full-text index (SQLite FTS5, BM25), with the ID in its own exact column | Node 24 or newer (`node:sqlite`) |
|
|
179
|
-
| 2 | level 1, plus a small local embedding model; word and vector results are fused | the
|
|
185
|
+
| 2 | level 1, plus a small local embedding model; word and vector results are fused | the library `@huggingface/transformers` 4.3.1, in the project or beside `al` |
|
|
180
186
|
|
|
181
187
|
- The default query is the current system: the spec text at the selected
|
|
182
188
|
commit. `--change <name>` adds that request's open change spec, its
|
|
@@ -199,7 +205,9 @@ itself (2, then 1, then 0), and says which level answered and why.
|
|
|
199
205
|
brings it up to date before it answers; `--rebuild` builds it again.
|
|
200
206
|
- Level 2 runs a small local model (bge-small-en-v1.5, pinned by its
|
|
201
207
|
revision). The model download is the only data that leaves the machine.
|
|
202
|
-
`al` itself makes no network call.
|
|
208
|
+
`al` itself makes no network call. Without the library, `al search`
|
|
209
|
+
answers at level 1, and a `Not known` line says how to add level 2. Only
|
|
210
|
+
`al search` loads the library.
|
|
203
211
|
|
|
204
212
|
## One central repo, and output repos
|
|
205
213
|
|
|
@@ -299,15 +307,13 @@ in this repository.
|
|
|
299
307
|
|
|
300
308
|
## Releasing
|
|
301
309
|
|
|
302
|
-
1. Bump `version` in `package.json` and
|
|
303
|
-
the same version in a PR, and merge it.
|
|
310
|
+
1. Bump `version` in `package.json` in a PR, and merge it.
|
|
304
311
|
2. Publish a GitHub Release tagged `v<version>` on that commit. Mark it a
|
|
305
312
|
pre-release to publish under npm's `next` tag.
|
|
306
313
|
|
|
307
314
|
`.github/workflows/publish.yml` does the rest: it runs the tests and
|
|
308
|
-
`al check --strict`, checks that the tag matches
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
for each package before its first publish.
|
|
315
|
+
`al check --strict`, checks that the tag matches `package.json`, and
|
|
316
|
+
publishes @assuredloop/cli to npm. It skips a version that is already on npm,
|
|
317
|
+
so a failed run can run again. It stores no npm token: npm trusts the
|
|
318
|
+
workflow by name, and adds a provenance statement saying which commit built
|
|
319
|
+
the package.
|
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.2",
|
|
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": {
|
|
@@ -33,5 +33,13 @@
|
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
35
|
"yaml": "2.9.1"
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"@huggingface/transformers": "4.3.1"
|
|
39
|
+
},
|
|
40
|
+
"peerDependenciesMeta": {
|
|
41
|
+
"@huggingface/transformers": {
|
|
42
|
+
"optional": true
|
|
43
|
+
}
|
|
36
44
|
}
|
|
37
45
|
}
|
package/src/v4/check.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { loadConfig, loadSchema } from './config.js';
|
|
9
9
|
import { mergeBase } from './git.js';
|
|
10
10
|
import { diffParagraphs, idLints } from './ids.js';
|
|
11
|
-
import { docsInScope, idsEverUsed, isShallow, notRead, parseScopes, rootProblem, SPEC } from './scope.js';
|
|
11
|
+
import { docsInScope, idsEverUsed, isShallow, notRead, parseScopes, rootNotOk, rootProblem, SPEC } from './scope.js';
|
|
12
12
|
import { Fail } from './spec.js';
|
|
13
13
|
import { centralLookup } from './repos.js';
|
|
14
14
|
import { checks } from './checks.js';
|
|
@@ -45,12 +45,15 @@ export function check({ top, opts }) {
|
|
|
45
45
|
lints.push(...shown);
|
|
46
46
|
const severity = (l) => (opts.strict ? 'not ok' : l.severity);
|
|
47
47
|
const body = notRead(top, config);
|
|
48
|
+
// A root that config sets but that is not read is a not ok, as in al index (#218).
|
|
49
|
+
const rootLine = rootNotOk(top, config);
|
|
50
|
+
if (rootLine) body.push(rootLine);
|
|
48
51
|
body.push(...lints.map((l) => `${severity(l)} ${l.code} ${l.file}:${l.line} ${l.id ?? '-'} ${l.message}`));
|
|
49
52
|
if (base) body.push(...changes.map((c) => `${c.file} ${c.id} ${c.changes.join('+')}`));
|
|
50
53
|
if (!lints.length) body.push('ok: no marker lint');
|
|
51
54
|
body.push(...t10.findings.map((f) => `${f.severity} ${f.code} ${f.where} ${f.id} ${f.message}`));
|
|
52
55
|
// Under --strict every marker lint is a not ok.
|
|
53
|
-
const notOk = lints.length > 0 || t10.findings.some((f) => f.severity === 'not ok');
|
|
56
|
+
const notOk = Boolean(rootLine) || lints.length > 0 || t10.findings.some((f) => f.severity === 'not ok');
|
|
54
57
|
// In an output repo: the central IDs that the branch's changed files cite (T12).
|
|
55
58
|
const central = centralLookup(top);
|
|
56
59
|
body.push(...central.body);
|
|
@@ -61,7 +64,7 @@ export function check({ top, opts }) {
|
|
|
61
64
|
return {
|
|
62
65
|
body,
|
|
63
66
|
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),
|
|
67
|
+
next: nextStep([...(rootLine ? ['not ok'] : []), ...lints.map(severity), ...t10.findings.map((f) => f.severity),
|
|
65
68
|
...central.body.map((l) => (/^not ok /.test(l) ? 'not ok' : /^hint /.test(l) ? 'hint' : 'info'))], lints.some((l) => l.code === 'no-id')),
|
|
66
69
|
notKnown,
|
|
67
70
|
exit: opts.strict && notOk ? 1 : 0,
|
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';
|
|
@@ -62,13 +62,12 @@ function changesOf(now, was) {
|
|
|
62
62
|
|
|
63
63
|
const changeWord = (c) => (c.changes.includes('New') ? 'new' : c.changes.includes('Removed') ? 'removed' : c.changed ? 'changed' : 'given another kind');
|
|
64
64
|
|
|
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 });
|
|
65
|
+
// The branch against its base, in states `now` and `was` (null with no
|
|
66
|
+
// base): each paragraph's change, adoption aside, the path claim, and the
|
|
67
|
+
// promise changes with no signed requirement that covers them (design.md 6).
|
|
68
|
+
// A change paragraph that is validly abandoned with no kept effect needs none.
|
|
69
|
+
// al check reports each of those, and al conclude refuses on them (DES-37).
|
|
70
|
+
export function branchOf(top, now, was, base) {
|
|
72
71
|
const promise = (k) => isPromise(now, k);
|
|
73
72
|
const open = [...now.requests.values()].filter((r) => r.open);
|
|
74
73
|
// Adoption on the branch (D16, D21): a spec paragraph that is new at the
|
|
@@ -102,9 +101,12 @@ export function checks(top, { base, strict = false }) {
|
|
|
102
101
|
const isPromiseChange = (c) => (c.changed && (promise(c.head?.kind) || promise(c.base?.kind)))
|
|
103
102
|
|| (c.kindChange && (promise(c.head.kind) || promise(c.base.kind)));
|
|
104
103
|
|
|
105
|
-
// Sign-off coverage
|
|
104
|
+
// Sign-off coverage: a promise change with no signed requirement that covers it.
|
|
105
|
+
const uncovered = [];
|
|
106
106
|
for (const c of changes.filter(isPromiseChange)) {
|
|
107
107
|
if (typo.has(c.name)) continue;
|
|
108
|
+
const r = c.request && c.head ? now.requests.get(c.request) : null;
|
|
109
|
+
if (r && !r.spike && droppedUnsigned(now, r, c.head, { typo, baseSpec: was?.spec ?? null })) continue;
|
|
108
110
|
let covered;
|
|
109
111
|
if (!c.head) {
|
|
110
112
|
if (c.request) continue; // a change spec paragraph taken out is no promise change
|
|
@@ -113,9 +115,26 @@ export function checks(top, { base, strict = false }) {
|
|
|
113
115
|
} else {
|
|
114
116
|
covered = servesSigned(now, c.head, c.request);
|
|
115
117
|
}
|
|
116
|
-
if (!covered)
|
|
118
|
+
if (!covered) uncovered.push(c);
|
|
117
119
|
}
|
|
118
120
|
|
|
121
|
+
return { changes, captured, notAdopted, claim, commits, typo, isPromiseChange, uncovered };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function checks(top, { base, strict = false }) {
|
|
125
|
+
const now = loadState(top);
|
|
126
|
+
const was = base ? loadState(top, base) : null;
|
|
127
|
+
const findings = [];
|
|
128
|
+
const notKnown = [];
|
|
129
|
+
const at = (p) => (p ? `${p.file}:${p.line}` : '-');
|
|
130
|
+
const add = (severity, code, p, id, message) => findings.push({ severity, code, where: typeof p === 'string' ? p : at(p), id, message });
|
|
131
|
+
const promise = (k) => isPromise(now, k);
|
|
132
|
+
const open = [...now.requests.values()].filter((r) => r.open);
|
|
133
|
+
const { changes, captured, notAdopted, claim, commits, typo, isPromiseChange, uncovered } = branchOf(top, now, was, base);
|
|
134
|
+
|
|
135
|
+
// Sign-off coverage (design.md 6): a promise change with no signed requirement that covers it.
|
|
136
|
+
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`);
|
|
137
|
+
|
|
119
138
|
// The path claim (design.md 7), and the typo claim's words (design.md 6).
|
|
120
139
|
if (!base) notKnown.push('no base, so the path claim was not read');
|
|
121
140
|
else if (!claim) {
|
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/level2.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// Level 2 of `al search` (design.md 11): the embedding model and the vectors.
|
|
2
|
+
// `al search` gives it the rows to embed and a query, and it answers with the
|
|
3
|
+
// row keys ranked by similarity; search.js fuses that list with its full-text
|
|
4
|
+
// list.
|
|
5
|
+
//
|
|
6
|
+
// The vectors live beside al's index, in <dir>/vectors.sqlite. Only new or
|
|
7
|
+
// changed rows are embedded; a change of model embeds everything again.
|
|
8
|
+
// The model's library, @huggingface/transformers, is an optional peer
|
|
9
|
+
// dependency: it is found by normal Node resolution (the project first, then
|
|
10
|
+
// beside al) and imported only at the first embed, so no other command and no
|
|
11
|
+
// search without words loads it.
|
|
12
|
+
import { mkdirSync } from 'node:fs';
|
|
13
|
+
import { createRequire } from 'node:module';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { pathToFileURL } from 'node:url';
|
|
16
|
+
|
|
17
|
+
const BATCH = 32;
|
|
18
|
+
|
|
19
|
+
// node:sqlite is imported here, not at load, so a Node without it still
|
|
20
|
+
// loads this file and falls back by search.js's own check.
|
|
21
|
+
async function open(dir) {
|
|
22
|
+
const { DatabaseSync } = await import('node:sqlite');
|
|
23
|
+
mkdirSync(dir, { recursive: true });
|
|
24
|
+
const db = new DatabaseSync(join(dir, 'vectors.sqlite'));
|
|
25
|
+
db.exec(`
|
|
26
|
+
CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v TEXT);
|
|
27
|
+
CREATE TABLE IF NOT EXISTS vectors (key TEXT PRIMARY KEY, sha256 TEXT NOT NULL, vec BLOB NOT NULL);
|
|
28
|
+
`);
|
|
29
|
+
return db;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// A vector as unit length, so a dot product is the cosine.
|
|
33
|
+
function unit(v) {
|
|
34
|
+
const a = Float32Array.from(v);
|
|
35
|
+
let n = 0;
|
|
36
|
+
for (const x of a) n += x * x;
|
|
37
|
+
n = Math.sqrt(n) || 1;
|
|
38
|
+
for (let i = 0; i < a.length; i++) a[i] /= n;
|
|
39
|
+
return a;
|
|
40
|
+
}
|
|
41
|
+
const blob = (a) => Buffer.from(a.buffer, a.byteOffset, a.byteLength);
|
|
42
|
+
const vector = (b) => new Float32Array(b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength));
|
|
43
|
+
|
|
44
|
+
async function embedAll(embed, texts) {
|
|
45
|
+
const out = [];
|
|
46
|
+
for (let i = 0; i < texts.length; i += BATCH) {
|
|
47
|
+
const part = texts.slice(i, i + BATCH);
|
|
48
|
+
const vs = await embed(part);
|
|
49
|
+
if (!Array.isArray(vs) || vs.length !== part.length) throw new Error(`the model gave ${vs?.length ?? 'no'} vectors for ${part.length} texts`);
|
|
50
|
+
out.push(...vs.map(unit));
|
|
51
|
+
}
|
|
52
|
+
if (out.some((v) => v.length !== out[0].length)) throw new Error('the model gave vectors of different lengths');
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// A level-2 object: `model` names it in al's output; `embed(texts)` gives one
|
|
57
|
+
// vector per text. `queryPrefix` is put before a query only.
|
|
58
|
+
function createLevel2({ model, embed, queryPrefix = '' }) {
|
|
59
|
+
if (typeof model !== 'string' || !model) throw new Error('createLevel2 needs a model name');
|
|
60
|
+
if (typeof embed !== 'function') throw new Error('createLevel2 needs an embed function');
|
|
61
|
+
return {
|
|
62
|
+
model,
|
|
63
|
+
|
|
64
|
+
// Makes the stored vectors match `rows` ([{ key, sha256, text }]):
|
|
65
|
+
// embeds the new and changed rows and removes the others.
|
|
66
|
+
async refresh({ dir, rows }) {
|
|
67
|
+
const db = await open(dir);
|
|
68
|
+
try {
|
|
69
|
+
if (db.prepare("SELECT v FROM meta WHERE k = 'model'").get()?.v !== model) {
|
|
70
|
+
db.exec('DELETE FROM vectors');
|
|
71
|
+
db.prepare("INSERT OR REPLACE INTO meta (k, v) VALUES ('model', ?)").run(model);
|
|
72
|
+
}
|
|
73
|
+
const have = new Map(db.prepare('SELECT key, sha256 FROM vectors').all().map((x) => [x.key, x.sha256]));
|
|
74
|
+
const want = new Map(rows.map((r) => [r.key, r]));
|
|
75
|
+
const todo = rows.filter((r) => have.get(r.key) !== r.sha256);
|
|
76
|
+
const vs = await embedAll(embed, todo.map((r) => r.text));
|
|
77
|
+
db.exec('BEGIN');
|
|
78
|
+
const del = db.prepare('DELETE FROM vectors WHERE key = ?');
|
|
79
|
+
let removed = 0;
|
|
80
|
+
for (const k of have.keys()) if (!want.has(k)) { del.run(k); removed += 1; }
|
|
81
|
+
const put = db.prepare('INSERT OR REPLACE INTO vectors (key, sha256, vec) VALUES (?, ?, ?)');
|
|
82
|
+
todo.forEach((r, i) => put.run(r.key, r.sha256, blob(vs[i])));
|
|
83
|
+
db.exec('COMMIT');
|
|
84
|
+
return { embedded: todo.length, removed };
|
|
85
|
+
} finally {
|
|
86
|
+
db.close();
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
// The `keys` ranked by similarity to `query`, best first, at most `k`.
|
|
91
|
+
// A plain scan of the stored vectors: no vector database is needed at
|
|
92
|
+
// this size (design.md 11).
|
|
93
|
+
async rank({ dir, query, keys, k }) {
|
|
94
|
+
const [q] = await embedAll(embed, [`${queryPrefix}${query}`]);
|
|
95
|
+
const db = await open(dir);
|
|
96
|
+
try {
|
|
97
|
+
const wanted = new Set(keys);
|
|
98
|
+
const scored = [];
|
|
99
|
+
for (const { key, vec } of db.prepare('SELECT key, vec FROM vectors').all()) {
|
|
100
|
+
if (!wanted.has(key)) continue;
|
|
101
|
+
const v = vector(vec);
|
|
102
|
+
if (v.length !== q.length) throw new Error('the stored vectors and the query differ in length');
|
|
103
|
+
let s = 0;
|
|
104
|
+
for (let i = 0; i < v.length; i++) s += v[i] * q[i];
|
|
105
|
+
scored.push([key, s]);
|
|
106
|
+
}
|
|
107
|
+
scored.sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
|
|
108
|
+
return scored.slice(0, k).map(([key]) => key);
|
|
109
|
+
} finally {
|
|
110
|
+
db.close();
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export const LIBRARY = '@huggingface/transformers';
|
|
117
|
+
const VERSION = '4.3.1';
|
|
118
|
+
export const INSTALL = `npm install --global @assuredloop/cli ${LIBRARY}@${VERSION}`;
|
|
119
|
+
|
|
120
|
+
// The library's path by normal Node resolution: from the project, then
|
|
121
|
+
// beside al. Null when neither has it.
|
|
122
|
+
export function findLibrary(top) {
|
|
123
|
+
for (const from of [join(top, 'package.json'), import.meta.url]) {
|
|
124
|
+
try { return createRequire(from).resolve(LIBRARY); } catch { /* not here */ }
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// The real model: bge-small-en-v1.5 (MIT), the ONNX port, pinned by revision.
|
|
130
|
+
const MODEL = 'Xenova/bge-small-en-v1.5';
|
|
131
|
+
const REVISION = 'ea104dacec62c0de699686887e3f920caeb4f3e3';
|
|
132
|
+
|
|
133
|
+
// Level 2 with the library at `path`, which is imported at the first embed.
|
|
134
|
+
export function level2(path) {
|
|
135
|
+
let extractor = null;
|
|
136
|
+
async function embed(texts) {
|
|
137
|
+
if (!extractor) {
|
|
138
|
+
// The path is the library's require entry; its exports may come as a default.
|
|
139
|
+
const mod = await import(pathToFileURL(path).href);
|
|
140
|
+
const pipeline = mod.pipeline ?? mod.default?.pipeline;
|
|
141
|
+
if (typeof pipeline !== 'function') throw new Error(`${path} has no pipeline function`);
|
|
142
|
+
extractor = await pipeline('feature-extraction', MODEL, { revision: REVISION, dtype: 'q8' });
|
|
143
|
+
}
|
|
144
|
+
// bge takes the first token's vector (CLS pooling).
|
|
145
|
+
const out = await extractor(texts, { pooling: 'cls', normalize: true });
|
|
146
|
+
return out.tolist();
|
|
147
|
+
}
|
|
148
|
+
return createLevel2({
|
|
149
|
+
model: `${MODEL}@${REVISION.slice(0, 12)}`,
|
|
150
|
+
embed,
|
|
151
|
+
// bge's instruction for a short query that looks for passages.
|
|
152
|
+
queryPrefix: 'Represent this sentence for searching relevant passages: ',
|
|
153
|
+
});
|
|
154
|
+
}
|
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/search.js
CHANGED
|
@@ -1,13 +1,10 @@
|
|
|
1
1
|
// al search (design.md 11): the current system, a change's context and the
|
|
2
2
|
// history, at the strongest level installed. Level 0 scans the export; level
|
|
3
3
|
// 1 is a SQLite FTS5 index of it (BM25) with the ID in its own exact column;
|
|
4
|
-
// level 2 fuses level 1 with the ranking of
|
|
5
|
-
//
|
|
6
|
-
// vectors: the package does, and returns a ranked list.
|
|
4
|
+
// level 2 fuses level 1 with the ranking of level2.js (reciprocal rank
|
|
5
|
+
// fusion), which needs the optional peer dependency @huggingface/transformers.
|
|
7
6
|
import { mkdirSync, rmSync } from 'node:fs';
|
|
8
|
-
import { createRequire } from 'node:module';
|
|
9
7
|
import { isAbsolute, join } from 'node:path';
|
|
10
|
-
import { pathToFileURL } from 'node:url';
|
|
11
8
|
import { parse } from 'yaml';
|
|
12
9
|
import { Fail, isName } from './base.js';
|
|
13
10
|
import { git } from './git.js';
|
|
@@ -39,22 +36,13 @@ async function sqlite() {
|
|
|
39
36
|
}
|
|
40
37
|
}
|
|
41
38
|
|
|
42
|
-
//
|
|
43
|
-
//
|
|
39
|
+
// Level 2 when its library is installed, or null with the reason. level2.js
|
|
40
|
+
// is loaded only here, and the library only at its first embed.
|
|
44
41
|
async function level2(top) {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const mod = await import(pathToFileURL(path).href);
|
|
50
|
-
const l2 = mod.default;
|
|
51
|
-
if (!l2 || typeof l2.refresh !== 'function' || typeof l2.rank !== 'function') return { reason: `${path} is not a level 2 package (no refresh and rank)` };
|
|
52
|
-
return { l2 };
|
|
53
|
-
} catch (e) {
|
|
54
|
-
return { reason: `@assuredloop/search did not load: ${e.message.split('\n')[0]}` };
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
return { reason: 'level 2 is not installed: no @assuredloop/search in the project or beside al' };
|
|
42
|
+
const { findLibrary, level2: create, LIBRARY, INSTALL } = await import('./level2.js');
|
|
43
|
+
const path = findLibrary(top);
|
|
44
|
+
if (!path) return { reason: `level 2 is not installed: no ${LIBRARY} in the project or beside al`, install: `level 2: ${LIBRARY} is not installed; to add level 2, run ${INSTALL}` };
|
|
45
|
+
return { l2: create(path) };
|
|
58
46
|
}
|
|
59
47
|
|
|
60
48
|
// The folder of the index: <git dir>/assuredloop, never in the working tree.
|
|
@@ -352,7 +340,7 @@ export async function search({ top, args, opts }) {
|
|
|
352
340
|
let lite = null;
|
|
353
341
|
if (level === 2) {
|
|
354
342
|
found2 = await level2(top);
|
|
355
|
-
if (!found2.l2) { falls.push(found2.reason); level = 1; }
|
|
343
|
+
if (!found2.l2) { falls.push(found2.reason); notKnown.push(found2.install); level = 1; }
|
|
356
344
|
}
|
|
357
345
|
if (level >= 1) {
|
|
358
346
|
lite = await sqlite();
|
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,7 @@ 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
12
|
|
|
13
13
|
export const line = (label, text) => `${label.length < 10 ? label.padEnd(10) : `${label} `}${text}`;
|
|
14
14
|
const list = (x) => (Array.isArray(x) ? x : []);
|
|
@@ -21,10 +21,8 @@ export const NOT_CHECKED = 'whether each kind is right; whether a requirement is
|
|
|
21
21
|
// The sign-off state of one requirement or question: "signed in S1",
|
|
22
22
|
// "changed since S1" or "not signed".
|
|
23
23
|
export function signState(state, r, q) {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
return `signed in ${s.id}`;
|
|
27
|
-
}
|
|
24
|
+
const s = signedBy(state, r.name, q.id);
|
|
25
|
+
if (s) return `signed in ${s.id}`;
|
|
28
26
|
const before = list(r.data?.signoff).filter((x) => list(x?.covers).some((c) => c?.id === q.id)).at(-1);
|
|
29
27
|
return before ? `changed since ${before.id}` : 'not signed';
|
|
30
28
|
}
|