@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.
- package/dist/lib/catalogue/builders/feature-tree-walker.js +311 -0
- package/dist/lib/catalogue/builders/features.js +351 -0
- package/dist/lib/catalogue/canonical-schema.yaml +19 -1
- package/dist/lib/catalogue/coverage-predicates.js +19 -2
- package/dist/lib/catalogue/coverage-render.js +10 -1
- package/dist/lib/catalogue/impact-run.js +54 -2
- package/dist/lib/catalogue/rubric-coverage-map.yaml +4 -0
- package/dist/lib/derived-specs/index.js +501 -0
- package/dist/lib/derived-specs/source-path.js +275 -0
- package/dist/lib/structure/catalogue.js +13 -0
- package/dist/lib/validate/agreement-evidence.js +14 -3
- package/dist/lib/validate/behaviour-store.js +20 -2
- package/dist/lib/validate/carve-citations.js +64 -5
- package/dist/lib/validate/derived-specs.js +300 -0
- package/dist/lib/validate/dispatch.js +8 -1
- package/dist/maintain-version.json +2 -2
- package/dist/verbs/catalogue-verify.js +2 -0
- package/dist/verify-version.json +2 -2
- package/package.json +2 -2
|
@@ -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)
|
|
7
|
-
//
|
|
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}`};
|
|
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}`};
|
|
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
|
-
|
|
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>."
|
|
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";
|