@assuredloop/cli 0.2.1 → 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 CHANGED
@@ -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 paragraph
140
- version, with its role, version, commit and links. It is not committed.
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
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.1",
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": {
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
- export function checks(top, { base, strict = false }) {
66
- const now = loadState(top);
67
- const was = base ? loadState(top, base) : null;
68
- const findings = [];
69
- const notKnown = [];
70
- const at = (p) => (p ? `${p.file}:${p.line}` : '-');
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 (design.md 6): a promise change with no signed requirement that covers it.
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) 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`);
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) {
@@ -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. With
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
- function reasons(state, r) {
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 { out, judged } = reasons(state, r);
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: 'working tree',
106
+ read,
97
107
  next: `al context ${name}; sign, record the dispositions, or align what is named, then al index`,
98
- notKnown: NOT_KNOWN,
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: 'working tree', next: 'run the same command with --yes to do it', notKnown: NOT_KNOWN };
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: 'working tree', next: 'commit the archive move with the closing PR', notKnown: NOT_KNOWN };
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
- return { ...c, root: c.root ?? 'specs', docs: Array.isArray(c.docs) ? c.docs : [] };
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) {
@@ -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;
@@ -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
- // Whether requirement (or question) `id` of request `name` is signed at its
187
- // current text: a sign-off covers that id with the current text's hash.
188
- export function signed(state, name, id) {
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 false;
217
+ if (!q) return null;
192
218
  return (Array.isArray(r.data?.signoff) ? r.data.signoff : [])
193
- .some((s) => (Array.isArray(s?.covers) ? s.covers : []).some((c) => c?.id === id && c.sha256 === q.sha256));
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, signed } from './state.js';
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
- if (signed(state, r.name, q.id)) {
25
- const s = list(r.data?.signoff).filter((x) => list(x?.covers).some((c) => c?.id === q.id && c.sha256 === q.sha256)).at(-1);
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
  }