@sdd-method/verify-cli 5.5.1 → 5.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,275 @@
1
+ // The Source-path grammar of M-ADR 0232 §4.2: the address of one node in a derived source, on a derived spec's `Source:`
2
+ // line, in a features map and in `shared:`. It is the grammar the first adopter's declaration module measured over its
3
+ // declarations, adopted here unchanged so that a path its deriver writes parses and resolves identically in this check:
4
+ //
5
+ // path = "" | step *( "." name | "[" selector "]" )
6
+ // step = name | "[" selector "]"
7
+ // name = 1*( any character but "." "[" "]" ) ; a map entry under that string key
8
+ // selector = quoted ; a map entry under a string key a name cannot spell
9
+ // | "#" 1*DIGIT ; a list element by its 0-based position
10
+ // | key *( ";" field "=" value ) ; a list element by its declared key and qualifiers, or, with no
11
+ // ; qualifiers and under a map, the entry under a NON-string key
12
+ // quoted = DQUOTE *( "\" ( DQUOTE | "\" ) | any but DQUOTE and "\" ) DQUOTE
13
+ //
14
+ // A list's key fields are declared (the source's `keys:`, by the list's pattern: its path with every element step written
15
+ // `[]`), first the key, then the qualifiers; no field is guessed. A selector that matches no element or more than one is
16
+ // refused, never a first match. The canonical form of a list element is its key with the fewest leading qualifiers that make
17
+ // it unique, else its position; a position where a key would do is refused, and so is any position where `positions` is false
18
+ // (the features map and `shared:`, which a reorder would silently re-point).
19
+ const BARE = /^[A-Za-z0-9_\-/$@]+$/;
20
+ /** Parse any valid spelling; throws, naming the offset, on anything else. */
21
+ export function parsePath(text) {
22
+ const out = [];
23
+ const bad = (at, expected) => new Error(`source path \`${text}\`: expected ${expected} at offset ${at}`);
24
+ const n = text.length;
25
+ let i = 0;
26
+ while (i < n) {
27
+ if (text[i] === "[") {
28
+ i = selector(text, i, out, bad);
29
+ }
30
+ else {
31
+ let j = i;
32
+ while (j < n && !"[].".includes(text[j]))
33
+ j++;
34
+ if (j === i)
35
+ throw bad(i, text[i] === "]" ? "a `]` with no `[`" : "an empty name");
36
+ out.push({ kind: "key", name: text.slice(i, j) });
37
+ i = j;
38
+ }
39
+ if (i < n) {
40
+ if (text[i] === ".") {
41
+ i++;
42
+ if (i >= n || text[i] === "." || text[i] === "[")
43
+ throw bad(i, "a name after `.`");
44
+ }
45
+ else if (text[i] !== "[")
46
+ throw bad(i, "`.` or `[` after a step");
47
+ }
48
+ }
49
+ return out;
50
+ }
51
+ function selector(text, open, out, bad) {
52
+ const n = text.length;
53
+ let i = open + 1;
54
+ if (i >= n)
55
+ throw bad(i, "a selector after `[`");
56
+ if (text[i] === '"') {
57
+ let s = "";
58
+ i++;
59
+ for (;;) {
60
+ if (i >= n)
61
+ throw bad(i, 'a closing `"`');
62
+ const c = text[i];
63
+ if (c === "\\") {
64
+ if (i + 1 >= n || (text[i + 1] !== '"' && text[i + 1] !== "\\"))
65
+ throw bad(i, '`\\"` or `\\\\` (the only escapes)');
66
+ s += text[i + 1];
67
+ i += 2;
68
+ }
69
+ else if (c === '"') {
70
+ i++;
71
+ break;
72
+ }
73
+ else {
74
+ s += c;
75
+ i++;
76
+ }
77
+ }
78
+ if (i >= n || text[i] !== "]")
79
+ throw bad(i, "`]` after a quoted key");
80
+ out.push({ kind: "key", name: s });
81
+ return i + 1;
82
+ }
83
+ const close = text.indexOf("]", i);
84
+ if (close < 0)
85
+ throw bad(i, "a closing `]`");
86
+ const body = text.slice(i, close);
87
+ if (body.startsWith("#")) {
88
+ if (!/^[0-9]{1,9}$/.test(body.slice(1)))
89
+ throw bad(i, "a position `#<digits>`");
90
+ out.push({ kind: "position", index: Number(body.slice(1)) });
91
+ return close + 1;
92
+ }
93
+ const parts = body.split(";");
94
+ if (parts[0] === "")
95
+ throw bad(i, "a key in `[...]`");
96
+ const qualifiers = [];
97
+ for (const p of parts.slice(1)) {
98
+ const eq = p.indexOf("=");
99
+ if (eq <= 0 || eq === p.length - 1)
100
+ throw bad(i, "a qualifier `field=value`, both non-empty");
101
+ if (qualifiers.some(([f]) => f === p.slice(0, eq)))
102
+ throw bad(i, "each qualifier field once");
103
+ qualifiers.push([p.slice(0, eq), p.slice(eq + 1)]);
104
+ }
105
+ out.push({ kind: "element", key: parts[0], qualifiers });
106
+ return close + 1;
107
+ }
108
+ function renderStep(s, first) {
109
+ if (s.kind === "key") {
110
+ if (BARE.test(s.name))
111
+ return (first ? "" : ".") + s.name;
112
+ return `["${s.name.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"]`;
113
+ }
114
+ if (s.kind === "element")
115
+ return `[${s.key}${s.qualifiers.map(([f, v]) => `;${f}=${v}`).join("")}]`;
116
+ return `[#${s.index}]`;
117
+ }
118
+ /** The canonical spelling of these steps. */
119
+ export function renderPath(steps) {
120
+ return steps.map((s, k) => renderStep(s, k === 0)).join("");
121
+ }
122
+ const isMap = (x) => x instanceof Map;
123
+ /** A key or qualifier value as both readers print it; null when it cannot be one (not a scalar, or a non-integer number). */
124
+ function scalar(o) {
125
+ if (typeof o === "string" || typeof o === "boolean")
126
+ return String(o);
127
+ if (typeof o === "number")
128
+ return Number.isInteger(o) ? String(o) : null;
129
+ return null;
130
+ }
131
+ const usableValue = (v) => v !== null && v !== "" && !v.includes("]") && !v.includes(";");
132
+ const usableKey = (v) => usableValue(v) && !v.startsWith("#") && !v.startsWith('"');
133
+ function matches(el, keyField, key, qualifiers) {
134
+ if (!isMap(el) || scalar(el.get(keyField)) !== key)
135
+ return false;
136
+ return qualifiers.every(([f, v]) => scalar(el.get(f)) === v);
137
+ }
138
+ /** The pattern text a step adds, given its container: a map entry keeps its key; any list element is `[]`. */
139
+ function patternStep(container, s, first) {
140
+ if (s.kind === "key")
141
+ return renderStep(s, first);
142
+ return isMap(container) && s.kind === "element" ? `[${s.key}]` : "[]";
143
+ }
144
+ /** The canonical step to element `i` of `list` (its key and fewest qualifiers, else its position). */
145
+ function canonicalElement(list, i, fields) {
146
+ const el = list[i];
147
+ if (fields.length > 0 && isMap(el)) {
148
+ const key = scalar(el.get(fields[0]));
149
+ if (usableKey(key)) {
150
+ const qualifiers = [];
151
+ for (let q = 0;; q++) {
152
+ if (list.filter((o) => matches(o, fields[0], key, qualifiers)).length === 1)
153
+ return { kind: "element", key, qualifiers };
154
+ if (q + 1 >= fields.length)
155
+ break;
156
+ const v = scalar(el.get(fields[q + 1]));
157
+ if (!usableValue(v))
158
+ break;
159
+ qualifiers.push([fields[q + 1], v]);
160
+ }
161
+ }
162
+ }
163
+ return { kind: "position", index: i };
164
+ }
165
+ /**
166
+ * Resolve `path` in `doc` (parsed with typed map keys: `toJS({ mapAsMap: true })`). Null when it names exactly one node,
167
+ * else the reason.
168
+ */
169
+ export function resolvePath(doc, path, opts = {}) {
170
+ const r = resolveNode(doc, path, opts);
171
+ return "error" in r ? r.error : null;
172
+ }
173
+ /** As resolvePath, returning the one node the path names. */
174
+ export function resolveNode(doc, path, opts = {}) {
175
+ let steps;
176
+ try {
177
+ steps = parsePath(path);
178
+ }
179
+ catch (e) {
180
+ return { error: e.message };
181
+ }
182
+ const keys = opts.keys ?? new Map();
183
+ let node = doc;
184
+ let pattern = "";
185
+ for (const [at, s] of steps.entries()) {
186
+ const why = (w) => ({ error: `does not resolve at step ${at + 1}: ${w}` });
187
+ const container = node;
188
+ if (s.kind === "key") {
189
+ if (!isMap(node) || !node.has(s.name))
190
+ return why(isMap(node) ? `no key '${s.name}'` : `a key step on a ${kind(node)}`);
191
+ node = node.get(s.name);
192
+ }
193
+ else if (s.kind === "position") {
194
+ if (opts.positions === false)
195
+ return why(`positional '[#${s.index}]' (a features map or shared path never addresses by position: a reorder would re-point it; map the whole list)`);
196
+ if (!Array.isArray(node) || s.index >= node.length)
197
+ return why(Array.isArray(node) ? `position ${s.index} of ${node.length}` : `a position on a ${kind(node)}`);
198
+ const canon = canonicalElement(node, s.index, keys.get(pattern) ?? []);
199
+ if (canon.kind !== "position")
200
+ return why(`'[#${s.index}]' where its key would do: address it as '${renderStep(canon, false)}'`);
201
+ node = node[s.index];
202
+ }
203
+ else if (isMap(node)) {
204
+ if (s.qualifiers.length > 0)
205
+ return why("qualifiers on a map entry");
206
+ const hits = [...node.keys()].filter((k) => typeof k !== "string" && String(k) === s.key);
207
+ if (hits.length !== 1)
208
+ return why(`${hits.length} non-string keys printing as '${s.key}'`);
209
+ node = node.get(hits[0]);
210
+ }
211
+ else {
212
+ if (!Array.isArray(node))
213
+ return why(`a selector on a ${kind(node)}`);
214
+ const fields = keys.get(pattern) ?? [];
215
+ if (fields.length === 0)
216
+ return why(`the list '${pattern}' declares no key (keys:), so its elements are addressed by position`);
217
+ const bad = s.qualifiers.find(([q]) => !fields.slice(1).includes(q));
218
+ if (bad)
219
+ return why(`'${bad[0]}' is not a qualifier of '${pattern}' (${fields.join(", ")})`);
220
+ const hits = node.filter((el) => matches(el, fields[0], s.key, s.qualifiers));
221
+ if (hits.length !== 1)
222
+ return why(`${hits.length} elements of '${pattern}' match (exactly one must)`);
223
+ node = hits[0];
224
+ }
225
+ pattern += patternStep(container, s, at === 0);
226
+ }
227
+ return { node };
228
+ }
229
+ function kind(node) {
230
+ return node === null || node === undefined ? "null" : isMap(node) ? "map" : Array.isArray(node) ? "list" : "scalar";
231
+ }
232
+ /** Is `path` the same node as, or inside, `prefix` (step-wise, compared in canonical spelling)? Unparseable: false. */
233
+ export function pathWithin(path, prefix) {
234
+ try {
235
+ const a = parsePath(path).map((s) => renderStep(s, false));
236
+ const b = parsePath(prefix).map((s) => renderStep(s, false));
237
+ return b.length <= a.length && b.every((x, k) => x === a[k]);
238
+ }
239
+ catch {
240
+ return false;
241
+ }
242
+ }
243
+ /** The paths on a `Source:` line: one per line is canonical; `, ` between paths is split outside brackets and quotes. */
244
+ export function splitSourceLine(text) {
245
+ const out = [];
246
+ let depth = 0;
247
+ let quoted = false;
248
+ let cur = "";
249
+ for (let i = 0; i < text.length; i++) {
250
+ const c = text[i];
251
+ if (quoted) {
252
+ cur += c;
253
+ if (c === "\\" && i + 1 < text.length)
254
+ cur += text[++i];
255
+ else if (c === '"')
256
+ quoted = false;
257
+ continue;
258
+ }
259
+ if (c === '"' && depth > 0)
260
+ quoted = true;
261
+ else if (c === "[")
262
+ depth++;
263
+ else if (c === "]")
264
+ depth = Math.max(0, depth - 1);
265
+ else if (c === "," && depth === 0) {
266
+ out.push(cur.trim());
267
+ cur = "";
268
+ continue;
269
+ }
270
+ cur += c;
271
+ }
272
+ out.push(cur.trim());
273
+ return out.filter((x) => x !== "");
274
+ }
275
+ //# sourceMappingURL=source-path.js.map
@@ -9,6 +9,7 @@
9
9
  // element as an agreed open change adds or modifies it is that change's intended element, not a disagreement: it is
10
10
  // listed apart, and catalogued once the change archives. Pure: no file is read or written.
11
11
  import { stringify } from "yaml";
12
+ import { maturityFromRunway } from "../catalogue/builders/features.js";
12
13
  import { elementKey, FORMAT_VERSION } from "./model.js";
13
14
  import { STRUCTURE } from "./view.js";
14
15
  /** The view each generated file renders, for a derived path column of an element only the model holds. */
@@ -26,7 +27,14 @@ function modelDerived(catalogue, e, view) {
26
27
  switch (catalogue) {
27
28
  case "features":
28
29
  case "capabilities":
30
+ case "feature_groups":
29
31
  return { source: "structural-model" };
32
+ // A link the model states is a declared one (sdm-0enc): an ADR the capability cites, an event it publishes or
33
+ // consumes. The heuristic values (high, medium; derived) belong to links a builder infers from the sources.
34
+ case "capability_adr":
35
+ return { confidence: "spec" };
36
+ case "capability_event":
37
+ return { source: "authored" };
30
38
  case "domain_models":
31
39
  return { doc_path: VIEW_PATHS.dataModel };
32
40
  case "c4_systems":
@@ -56,6 +64,7 @@ export function overlayModel(schema, view, rows, findings, intended = new Map(),
56
64
  continue;
57
65
  const pk = cat.primaryKey;
58
66
  const structural = structureColumns(cat);
67
+ const derivesMaturity = cat.name === "features" && ["maturity", "runway"].every((n) => cat.columns.some((c) => c.name === n));
59
68
  const source = new Map();
60
69
  for (const r of rows.get(cat.name) ?? []) {
61
70
  const id = cell(r[pk]);
@@ -101,6 +110,10 @@ export function overlayModel(schema, view, rows, findings, intended = new Map(),
101
110
  row[pk] = e.id;
102
111
  for (const c of structural)
103
112
  row[c] = e.columns[c];
113
+ // A derived column follows its input as catalogued, not as the source stated it: a feature's maturity is read
114
+ // from the runway the model holds, so the two never disagree in one row (sdm-dlb9).
115
+ if (derivesMaturity)
116
+ row["maturity"] = maturityFromRunway(cell(row["runway"]));
104
117
  built.push(row);
105
118
  }
106
119
  const missing = [...source.keys()].filter((id) => id !== "" && !elements.has(id) && !asIntended(id, source.get(id))).sort();
@@ -3,8 +3,10 @@
3
3
  //
4
4
  // agreed content the change's proposal, deltas, Gate 0, design, tasks (checkboxes ignored), the test plan's
5
5
  // scenario-to-test rows (its Before and After cells ignored) and its structural deltas with their
6
- // base revisions (M-ADR 0230 §4.4; report only until it is accepted) are what was approved. Each is compared
7
- // with its state at the record's pinned commit: the latest amendment's commit, else sdd.commit.
6
+ // base revisions (M-ADR 0230 §4.4; report only until it is accepted) and a derived change's proposed
7
+ // sources under derived/ (M-ADR 0232; byte for byte, since archive writes them to the source path) are
8
+ // what was approved. Each is compared with its state at the record's pinned commit: the latest
9
+ // amendment's commit, else sdd.commit.
8
10
  // requirement a requirement's block in a spec: its heading to the next requirement or section heading. The
9
11
  // stale check compares the blocks a change targets, not the whole file.
10
12
  // scenario kind changed (a scenario of an ADDED requirement, or one of a MODIFIED requirement that is new or
@@ -13,7 +15,14 @@
13
15
  // verdict line verdict.md's own verdict: `Verdict: PASS|FAIL` or a leading `**PASS**`/`**FAIL**`.
14
16
  /** The files under a change directory that agreement binds, relative to the change directory. */
15
17
  export function boundFile(rel) {
16
- return /^(proposal|design|gate-0|tasks|test-plan)\.md$/.test(rel) || /^specs\/.+\/spec\.md$/.test(rel) || structuralFile(rel);
18
+ return /^(proposal|design|gate-0|tasks|test-plan)\.md$/.test(rel) || /^specs\/.+\/spec\.md$/.test(rel) || structuralFile(rel) || derivedFile(rel);
19
+ }
20
+ /**
21
+ * A derived change's proposed source (M-ADR 0232): archive writes it to the source path, so an edit after agreement would
22
+ * archive content nobody agreed, even where no derived spec reads the edited path (found by a judge's mutant in the first adopter pilot, sdm-bocd).
23
+ */
24
+ export function derivedFile(rel) {
25
+ return /^derived\/.+/.test(rel);
17
26
  }
18
27
  /**
19
28
  * A change's structural artifact (M-ADR 0230 §4.4): a partition delta or the no-op claim, both agreed
@@ -29,6 +38,8 @@ export function normaliseBound(rel, text) {
29
38
  return text.replace(/^(\s*-\s*\[)\s*[xX~]?\s*(\])/gm, "$1 $2").trimEnd();
30
39
  if (rel === "test-plan.md")
31
40
  return normaliseTestPlan(text);
41
+ if (derivedFile(rel))
42
+ return text;
32
43
  return text.trimEnd();
33
44
  }
34
45
  /**
@@ -78,6 +78,7 @@ import { contractMoves, generatedByProblems, observedIn, observedInProblems, ord
78
78
  import { parseCatalogueCsv } from "../catalogue/csv-reader.js";
79
79
  import { boundFile, conditionProblems, structuralFile, isCommit, normaliseBound, requirementBlock, sameCommit, scenarioKind, taskGroups, testPlanHeader, testPlanMapping, verdictLine, versionAtLeast } from "./agreement-evidence.js";
80
80
  import { ENFORCED as STRUCTURE_ENFORCED } from "./structural-model.js";
81
+ import { derivedChangeProblems, loadDerivedSources } from "../derived-specs/index.js";
81
82
  import { BASELINE_SCHEMA_NAME, compareStoreSchema, SCHEMA_NAME, SHIPPED_SCHEMA_DIR, shippedSchemaDir, STORE_CONFIG, STORE_SCHEMA_DIR, storeSchemaDir, storeSchemaName } from "../behaviour-store/schema.js";
82
83
  const execFileAsync = promisify(execFile);
83
84
  /** The OpenSpec release the method depends on (M-ADR 0223 ruling 2). The module installs exactly this. */
@@ -891,6 +892,16 @@ export async function checkBehaviourStore(repo, options = {}) {
891
892
  }
892
893
  movesAt.push(found);
893
894
  }
895
+ // Derived features (M-ADR 0232 §4.1, §4.5): no authored delta to a feature a derived source owns, and a change carrying
896
+ // a proposed source holds exactly the regenerated deltas.
897
+ let derivedChecked = 0;
898
+ for (const change of open) {
899
+ const p = await derivedChangeProblems(repo, change);
900
+ failures.push(...p);
901
+ derivedChecked++;
902
+ }
903
+ if ((await loadDerivedSources(repo).catch(() => null))?.length)
904
+ lines.push(`derived: ${derivedChecked} open change(s) checked against the derived sources' ownership`);
894
905
  // An open change's moves count too, after every archive: the catalogue it is built with already holds the new
895
906
  // identity, so the lists that named the old one follow the move while it is open, not only once it archives.
896
907
  const openMoves = [];
@@ -942,6 +953,13 @@ export async function checkBehaviourStore(repo, options = {}) {
942
953
  }
943
954
  return false;
944
955
  };
956
+ /**
957
+ * What the gate asks of this baseline (sdm-bjy3). Every baseline records **Baseline gate:**, a structure-only one
958
+ * too; for it the gate is its structural deltas evidenced in the code it observes, not requirements and tests.
959
+ */
960
+ const gateRule = async (dir) => (await admitsBehaviour(dir))
961
+ ? "a baseline archives once its gate passes: every requirement cites its evidence, characterization tests exist where the code has none, and every covered surface is evidenced at its transport"
962
+ : "every baseline records **Baseline gate:**, a structure-only one too (it admits no behaviour): PASS once its structural deltas are evidenced in the code it observes";
945
963
  /** What is wrong with a baseline's Surfaces list against the catalogue (M-ADR 0222 §4.3, §4.4). */
946
964
  const surfaceProblems = async (dir, evidence, after = -1) => {
947
965
  const out = [];
@@ -1089,7 +1107,7 @@ export async function checkBehaviourStore(repo, options = {}) {
1089
1107
  if (versionAtLeast(await shippedRecordSchema(repo, archivedAt), "1.4.0")) {
1090
1108
  const gate = baselineGate(evidence);
1091
1109
  if (gate !== "PASS")
1092
- failures.push(`${dir}/baseline-evidence.md: archived ${gate === null ? "with no **Baseline gate:** line" : `with **Baseline gate:** ${gate}`}; a baseline archives once its gate passes: every requirement cites its evidence, characterization tests exist where the code has none, and every covered surface is evidenced at its transport (M-ADR 0222 §4.3)`);
1110
+ failures.push(`${dir}/baseline-evidence.md: archived ${gate === null ? "with no **Baseline gate:** line" : `with **Baseline gate:** ${gate}`}; ${await gateRule(dir)} (M-ADR 0222 §4.3)`);
1093
1111
  }
1094
1112
  }
1095
1113
  }
@@ -1145,7 +1163,7 @@ export async function checkBehaviourStore(repo, options = {}) {
1145
1163
  }
1146
1164
  const gate = baselineGate(evidence);
1147
1165
  if (gate !== "PASS")
1148
- found.push(`${dir}/baseline-evidence.md: ${gate === null ? "no **Baseline gate:** line yet" : `**Baseline gate:** ${gate}`}; it archives once the gate passes (M-ADR 0222 §4.3)`);
1166
+ found.push(`${dir}/baseline-evidence.md: ${gate === null ? "no **Baseline gate:** line yet" : `**Baseline gate:** ${gate}`}; ${await gateRule(dir)} (M-ADR 0222 §4.3)`);
1149
1167
  warnings.push(...found.map((w) => `open baseline: ${w}`));
1150
1168
  }
1151
1169
  if (openBaselines.length > 0)
@@ -18,13 +18,17 @@
18
18
  // itself is an event of the estate's, not recorded in the store, so a requirement both SDDs hold before the old one is
19
19
  // removed is "in flight" and is reported in the inventory, not failed.
20
20
  import { readdir, readFile } from "node:fs/promises";
21
+ import { parse as parseYaml } from "yaml";
21
22
  import { basename, dirname, join } from "node:path";
22
23
  const STORE = "openspec";
23
24
  const REQ_HEAD = /^###\s+Requirement:\s*(.+?)\s*$/;
24
25
  const SECTION = /^##\s+(ADDED|MODIFIED|REMOVED|RENAMED)\s+Requirements\s*$/i;
25
- const CITE_START = /^\s*(Replaces|Departs from)\s/;
26
+ const CITE_START = /^\s*(Replaces|Departs from|Succeeds)\s/;
26
27
  const REPLACES = /^\s*Replaces\s+([A-Za-z0-9._-]+):([A-Za-z0-9._-]+)::(.+?)\.\s*$/;
27
28
  const DEPARTS = /^\s*Departs from\s+([A-Za-z0-9._-]+):([A-Za-z0-9._-]+)::(.+?):\s+\S.*$/;
29
+ // A feature that left the other SDD before it was baselined has no requirement to cite (sdm-a31j): the citation is
30
+ // feature-level and names the delta finding that records the removal, as <baseline-id>/<finding-id>.
31
+ const SUCCEEDS = /^\s*Succeeds\s+([A-Za-z0-9._-]+):([A-Za-z0-9._-]+)\s+\(removed before baseline;\s*([^)\s]+)\s*\)\.?\s*$/;
28
32
  const REPLACED_BY = /replaced by\s+`?([A-Za-z0-9._-]+):([A-Za-z0-9._-]+)::([^`\n]+?)`?\s*(?:[.;,)]\s*)?$/im;
29
33
  const key = (r) => `${r.sdd}:${r.feature}::${r.name}`;
30
34
  async function read(p) {
@@ -94,7 +98,24 @@ async function view(repo) {
94
98
  }
95
99
  return { living, removed };
96
100
  }
97
- export async function checkCarveCitations(repo) {
101
+ /** The classification of finding `<baseline>/<id>` in an SDD's snapshot area, "" when absent, null when unreachable. */
102
+ async function deltaFinding(repo, ref, store) {
103
+ const area = /^snapshot_area:\s*["']?([^"'\n]+?)["']?\s*$/m.exec((await read(join(repo, ".sdd", "implementation-binding.yaml"))) ?? "")?.[1];
104
+ if (!area || store === "")
105
+ return null;
106
+ const slash = ref.indexOf("/");
107
+ if (slash <= 0)
108
+ return "";
109
+ const text = await read(join(store, area, ref.slice(0, slash), "delta.yaml"));
110
+ if (text === null)
111
+ return "";
112
+ const y = parseYaml(text);
113
+ const f = (y?.findings ?? []).find((x) => x?.id === ref.slice(slash + 1));
114
+ return f === undefined ? "" : String(f.classification ?? "");
115
+ }
116
+ export async function checkCarveCitations(repo,
117
+ /** Snapshot store root the other SDD's snapshot_area is relative to (the verb passes $SDD_SNAPSHOT_STORE). */
118
+ snapshotStore = "") {
98
119
  const own = await sddId(repo);
99
120
  const failures = [];
100
121
  const warnings = [];
@@ -113,6 +134,19 @@ export async function checkCarveCitations(repo) {
113
134
  for (const line of b.text.split("\n")) {
114
135
  if (!CITE_START.test(line))
115
136
  continue;
137
+ const sc = SUCCEEDS.exec(line);
138
+ if (/^\s*Succeeds\s/.test(line)) {
139
+ cites.push({
140
+ where: `${rel}: '${b.name}'`,
141
+ open,
142
+ line: line.trim(),
143
+ ref: sc ? { sdd: sc[1], feature: sc[2], name: "" } : null,
144
+ kind: "succeeds",
145
+ owner: "",
146
+ ...(sc ? { evidence: sc[3] } : {}),
147
+ });
148
+ continue;
149
+ }
116
150
  const r = REPLACES.exec(line);
117
151
  const d = r ? null : DEPARTS.exec(line);
118
152
  const m = r ?? d;
@@ -147,7 +181,7 @@ export async function checkCarveCitations(repo) {
147
181
  }
148
182
  }
149
183
  if (cites.length === 0 && removals.length === 0) {
150
- return { exitCode: 0, stdout: "[N/A] no carve citations (Replaces / Departs from lines, or removals replaced by another SDD) in this repository\n" };
184
+ return { exitCode: 0, stdout: "[N/A] no carve citations (Replaces / Departs from / Succeeds lines, or removals replaced by another SDD) in this repository\n" };
151
185
  }
152
186
  // The other SDDs, found beside this one by their sdd_id.
153
187
  const siblings = new Map();
@@ -173,11 +207,36 @@ export async function checkCarveCitations(repo) {
173
207
  const report = (open, msg) => (open ? warnings : failures).push(msg);
174
208
  let inFlight = 0;
175
209
  let carved = 0;
210
+ let succeeded = 0;
176
211
  let unresolved = 0;
177
212
  const replacedBy = new Map();
178
213
  for (const c of cites) {
179
214
  if (c.ref === null) {
180
- report(c.open, `${c.where}: "${c.line}" is not "Replaces <sdd-id>:<feature>::<requirement name>." or "Departs from <sdd-id>:<feature>::<requirement name>: <why>."`);
215
+ report(c.open, `${c.where}: "${c.line}" is not "Replaces <sdd-id>:<feature>::<requirement name>.", "Departs from <sdd-id>:<feature>::<requirement name>: <why>." or "Succeeds <sdd-id>:<feature> (removed before baseline; <baseline-id>/<finding-id>)."`);
216
+ continue;
217
+ }
218
+ if (c.kind === "succeeds") {
219
+ const p = siblings.get(c.ref.sdd);
220
+ const v = await other(c.ref.sdd);
221
+ if (!p || v === null) {
222
+ unresolved++;
223
+ warnings.push(`${c.where}: Succeeds ${c.ref.sdd}:${c.ref.feature} unresolved: no checkout beside this SDD has sdd_id "${c.ref.sdd}"; check it out beside this one (failed under --strict)`);
224
+ continue;
225
+ }
226
+ if (v.living.has(c.ref.feature)) {
227
+ report(c.open, `${c.where}: Succeeds ${c.ref.sdd}:${c.ref.feature}, but ${c.ref.sdd} still has a living spec for ${c.ref.feature}: cite its requirement with Replaces`);
228
+ continue;
229
+ }
230
+ const cls = await deltaFinding(p, c.evidence, snapshotStore);
231
+ if (cls === null) {
232
+ unresolved++;
233
+ warnings.push(`${c.where}: Succeeds ${c.ref.sdd}:${c.ref.feature}: its evidence ${c.evidence} is not checked: ${c.ref.sdd} declares no snapshot_area, or no snapshot store was given (SDD_SNAPSHOT_STORE) (failed under --strict)`);
234
+ }
235
+ else if (cls === "" || !/^capability-removed/.test(cls)) {
236
+ report(c.open, `${c.where}: Succeeds ${c.ref.sdd}:${c.ref.feature}: ${c.evidence} ${cls === "" ? `is not a finding in ${c.ref.sdd}'s snapshot area` : `is a ${cls} finding, not capability-removed`}`);
237
+ }
238
+ else
239
+ succeeded++;
181
240
  continue;
182
241
  }
183
242
  const v = await other(c.ref.sdd);
@@ -215,7 +274,7 @@ export async function checkCarveCitations(repo) {
215
274
  }
216
275
  }
217
276
  const lines = [
218
- ` citations: ${cites.length} Replaces / Departs line(s): ${inFlight} in flight (still living where they came from), ${carved} carved (removed there), ${unresolved} unresolved`,
277
+ ` citations: ${cites.length} Replaces / Departs / Succeeds line(s): ${inFlight} in flight (still living where they came from), ${carved} carved (removed there), ${succeeded ? `${succeeded} succeeded (removed there before its baseline), ` : ""}${unresolved} unresolved`,
219
278
  ` removals: ${removals.length} replaced by another SDD`,
220
279
  ];
221
280
  const body = lines.join("\n") + "\n";