@sdd-method/author-cli 0.11.0 → 0.12.1
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/build.js +33 -2
- package/dist/lib/catalogue/builders/adr-governs.js +39 -0
- package/dist/lib/catalogue/builders/adrs.js +11 -0
- package/dist/lib/catalogue/builders/contract-revisions.js +126 -0
- package/dist/lib/catalogue/builders/data-model.js +316 -0
- package/dist/lib/catalogue/builders/entities-mermaid.js +18 -85
- package/dist/lib/catalogue/builders/integration-components.js +49 -5
- package/dist/lib/catalogue/builders/quality-scores.js +17 -0
- package/dist/lib/catalogue/builders/requirements.js +132 -23
- package/dist/lib/catalogue/builders/user-journeys.js +8 -13
- package/dist/lib/catalogue/canonical-schema.yaml +767 -17
- package/dist/lib/catalogue/retired-catalogues.js +25 -0
- package/dist/lib/catalogue/rubric-coverage-map.yaml +90 -2
- package/dist/lib/catalogue/schema-compat.js +1 -1
- package/dist/lib/catalogue/schema-loader.js +3 -0
- package/dist/lib/catalogue/staleness-verdicts.js +75 -6
- package/dist/lib/structure/catalogue.js +183 -0
- package/dist/lib/structure/compose.js +174 -0
- package/dist/lib/structure/effects.js +36 -0
- package/dist/lib/structure/model.js +212 -0
- package/dist/lib/structure/render.js +242 -0
- package/dist/lib/structure/view.js +301 -0
- package/dist/lib/validate/agreement-evidence.js +20 -8
- package/dist/lib/validate/behaviour-store.js +54 -13
- package/dist/lib/validate/dispatch.js +7 -0
- package/dist/lib/validate/journey-impact.js +17 -2
- package/dist/lib/validate/structural-model.js +265 -0
- package/dist/maintain-version.json +2 -2
- package/dist/verify-version.json +2 -2
- package/package.json +2 -2
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { elementKey, revision } from "./model.js";
|
|
2
|
+
export const STRUCTURE = "structure";
|
|
3
|
+
export function catalogueOf(schema, name) {
|
|
4
|
+
return schema.catalogues.find((c) => c.name === name);
|
|
5
|
+
}
|
|
6
|
+
/** The model's elements as one view, reporting an id held twice. */
|
|
7
|
+
export function viewOf(files, problems) {
|
|
8
|
+
const view = new Map();
|
|
9
|
+
for (const f of files) {
|
|
10
|
+
for (const e of f.elements) {
|
|
11
|
+
const byId = view.get(e.catalogue) ?? new Map();
|
|
12
|
+
const prior = byId.get(e.id);
|
|
13
|
+
if (prior) {
|
|
14
|
+
problems.push(`${e.file}: ${elementKey(e.catalogue, e.id)} is also in ${prior.file}; an element is owned by one partition, and an id is held once`);
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
byId.set(e.id, e);
|
|
18
|
+
view.set(e.catalogue, byId);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return view;
|
|
22
|
+
}
|
|
23
|
+
export function revisionOf(e) {
|
|
24
|
+
return revision(e.catalogue, e.id, e.columns);
|
|
25
|
+
}
|
|
26
|
+
function typeOk(type, v) {
|
|
27
|
+
switch (type) {
|
|
28
|
+
case "int":
|
|
29
|
+
case "integer":
|
|
30
|
+
return typeof v === "number" && Number.isInteger(v);
|
|
31
|
+
case "number":
|
|
32
|
+
return typeof v === "number";
|
|
33
|
+
case "boolean":
|
|
34
|
+
return typeof v === "boolean";
|
|
35
|
+
case "date":
|
|
36
|
+
return typeof v === "string" && /^\d{4}-\d{2}-\d{2}$/.test(v);
|
|
37
|
+
default:
|
|
38
|
+
return typeof v === "string";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** The element's own shape against its catalogue: class, columns, required columns, types, vocabularies. */
|
|
42
|
+
export function checkElementShape(schema, e, where, problems) {
|
|
43
|
+
const key = elementKey(e.catalogue, e.id);
|
|
44
|
+
const cat = catalogueOf(schema, e.catalogue);
|
|
45
|
+
if (!cat) {
|
|
46
|
+
problems.push(`${where}: ${key}: the metamodel declares no catalogue ${e.catalogue}`);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (cat.sourceOfTruth !== STRUCTURE) {
|
|
50
|
+
problems.push(`${where}: ${key}: ${e.catalogue} is of class ${cat.sourceOfTruth ?? "(none)"}, not structure; it is authored elsewhere`);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
for (const [col, v] of Object.entries(e.columns)) {
|
|
54
|
+
const def = cat.columns.find((c) => c.name === col);
|
|
55
|
+
if (!def) {
|
|
56
|
+
problems.push(`${where}: ${key}: ${e.catalogue} has no column ${col}`);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (def.sourceOfTruth !== STRUCTURE) {
|
|
60
|
+
problems.push(`${where}: ${key}: ${e.catalogue}.${col} is of class ${def.sourceOfTruth ?? "(none)"}, not structure; it is never written in the model`);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (v === "")
|
|
64
|
+
problems.push(`${where}: ${key}: ${col} is empty; omit an empty value`);
|
|
65
|
+
else if (!typeOk(def.type, v))
|
|
66
|
+
problems.push(`${where}: ${key}: ${col} must be of type ${def.type}`);
|
|
67
|
+
const en = cat.columnEnums[col];
|
|
68
|
+
if (en !== undefined && typeof v === "string" && v !== "") {
|
|
69
|
+
const values = schema.enums[en.replace(/^enums\./, "")]?.values.map((x) => x.value) ?? [];
|
|
70
|
+
if (!values.includes(v))
|
|
71
|
+
problems.push(`${where}: ${key}: ${col} ${JSON.stringify(v)} is not a value of ${en}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
for (const def of cat.columns) {
|
|
75
|
+
if (def.name === cat.primaryKey || def.nullable || def.sourceOfTruth !== STRUCTURE)
|
|
76
|
+
continue;
|
|
77
|
+
if (e.columns[def.name] === undefined)
|
|
78
|
+
problems.push(`${where}: ${key}: required column ${def.name} is missing`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function find(view, catalogue, column, value) {
|
|
82
|
+
const byId = view.get(catalogue);
|
|
83
|
+
if (!byId)
|
|
84
|
+
return undefined;
|
|
85
|
+
if (column === "id")
|
|
86
|
+
return byId.get(value);
|
|
87
|
+
for (const e of byId.values())
|
|
88
|
+
if (String(e.columns[column] ?? "") === value)
|
|
89
|
+
return e;
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
/** The element's references: each non-external foreign key with a value. */
|
|
93
|
+
export function referencesOf(schema, e) {
|
|
94
|
+
const cat = catalogueOf(schema, e.catalogue);
|
|
95
|
+
if (!cat)
|
|
96
|
+
return [];
|
|
97
|
+
const out = [];
|
|
98
|
+
for (const fk of cat.foreignKeys) {
|
|
99
|
+
if (fk.external)
|
|
100
|
+
continue;
|
|
101
|
+
const v = e.columns[fk.column];
|
|
102
|
+
if (v === undefined || v === "")
|
|
103
|
+
continue;
|
|
104
|
+
const [target, targetColumn] = fk.references.split(".");
|
|
105
|
+
out.push({ column: fk.column, catalogue: target, targetColumn: targetColumn ?? "id", value: String(v), structural: catalogueOf(schema, target)?.sourceOfTruth === STRUCTURE });
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
/** The natural key's value for an element, or null when the catalogue declares none or the element lacks a part. */
|
|
110
|
+
function naturalKey(cat, e) {
|
|
111
|
+
const nk = cat.naturalKey;
|
|
112
|
+
if (!nk || nk.length === 0)
|
|
113
|
+
return null;
|
|
114
|
+
const parts = nk.map((c) => (c === "id" ? e.id : e.columns[c]));
|
|
115
|
+
if (parts.some((p) => p === undefined || p === ""))
|
|
116
|
+
return null;
|
|
117
|
+
return parts.map(String).join("\u0000");
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* The checks a view must satisfy on its own: every element's shape, references resolved within the view (or, into a
|
|
121
|
+
* catalogue of another class, against its built rows), no cycle through a self-reference, natural keys unique, and
|
|
122
|
+
* domain invariants naming their entity's own attributes and relationships.
|
|
123
|
+
*/
|
|
124
|
+
export function validateView(schema, view, built, problems, unresolved) {
|
|
125
|
+
for (const [catalogue, byId] of view) {
|
|
126
|
+
const cat = catalogueOf(schema, catalogue);
|
|
127
|
+
for (const e of byId.values()) {
|
|
128
|
+
const shape = [];
|
|
129
|
+
checkElementShape(schema, e, e.file, shape);
|
|
130
|
+
problems.push(...shape);
|
|
131
|
+
if (!cat || cat.sourceOfTruth !== STRUCTURE)
|
|
132
|
+
continue;
|
|
133
|
+
for (const r of referencesOf(schema, e)) {
|
|
134
|
+
if (r.structural) {
|
|
135
|
+
if (!find(view, r.catalogue, r.targetColumn, r.value))
|
|
136
|
+
problems.push(`${e.file}: ${elementKey(catalogue, e.id)}: ${r.column} ${JSON.stringify(r.value)} names no ${r.catalogue} element in this view (${elementKey(r.catalogue, r.value)})`);
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
const rows = built(r.catalogue, r.targetColumn);
|
|
140
|
+
if (rows === null)
|
|
141
|
+
unresolved.add(r.catalogue);
|
|
142
|
+
else if (!rows.has(r.value))
|
|
143
|
+
problems.push(`${e.file}: ${elementKey(catalogue, e.id)}: ${r.column} ${JSON.stringify(r.value)} names no row of ${r.catalogue} as built`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (!cat || cat.sourceOfTruth !== STRUCTURE)
|
|
148
|
+
continue;
|
|
149
|
+
// Hierarchies through a self-reference stay acyclic.
|
|
150
|
+
for (const fk of cat.foreignKeys.filter((f) => f.references.split(".")[0] === catalogue && !f.external)) {
|
|
151
|
+
const reported = new Set();
|
|
152
|
+
for (const start of byId.values()) {
|
|
153
|
+
const path = [];
|
|
154
|
+
const onPath = new Set();
|
|
155
|
+
let cur = start;
|
|
156
|
+
while (cur && !onPath.has(cur.id)) {
|
|
157
|
+
onPath.add(cur.id);
|
|
158
|
+
path.push(cur.id);
|
|
159
|
+
const next = cur.columns[fk.column];
|
|
160
|
+
cur = next === undefined || next === "" ? undefined : byId.get(String(next));
|
|
161
|
+
}
|
|
162
|
+
if (cur && onPath.has(cur.id)) {
|
|
163
|
+
const cycle = path.slice(path.indexOf(cur.id));
|
|
164
|
+
const sig = [...cycle].sort().join(",");
|
|
165
|
+
if (!reported.has(sig)) {
|
|
166
|
+
reported.add(sig);
|
|
167
|
+
problems.push(`${start.file}: ${catalogue}.${fk.column} forms a cycle: ${[...cycle, cur.id].map((id) => elementKey(catalogue, id)).join(" -> ")}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
// Natural keys are unique.
|
|
173
|
+
const seen = new Map();
|
|
174
|
+
for (const e of byId.values()) {
|
|
175
|
+
const k = naturalKey(cat, e);
|
|
176
|
+
if (k === null)
|
|
177
|
+
continue;
|
|
178
|
+
const prior = seen.get(k);
|
|
179
|
+
if (prior)
|
|
180
|
+
problems.push(`${e.file}: ${elementKey(catalogue, e.id)} and ${elementKey(catalogue, prior.id)} share the natural key (${cat.naturalKey.join(", ")}) ${JSON.stringify(k.split("\u0000").join(", "))}`);
|
|
181
|
+
else
|
|
182
|
+
seen.set(k, e);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
// Domain invariants name their own entity's attributes and relationships.
|
|
186
|
+
for (const inv of view.get("domain_invariants")?.values() ?? []) {
|
|
187
|
+
const key = elementKey("domain_invariants", inv.id);
|
|
188
|
+
const entity = String(inv.columns["entity_id"] ?? "");
|
|
189
|
+
const kind = String(inv.columns["kind"] ?? "");
|
|
190
|
+
const attrs = String(inv.columns["attribute_ids"] ?? "").split(";").map((s) => s.trim()).filter(Boolean);
|
|
191
|
+
const rel = String(inv.columns["relationship_id"] ?? "");
|
|
192
|
+
if ((kind === "unique" || kind === "required") && attrs.length === 0)
|
|
193
|
+
problems.push(`${inv.file}: ${key}: a ${kind} invariant names its attributes (attribute_ids)`);
|
|
194
|
+
if (kind === "cardinality" && rel === "")
|
|
195
|
+
problems.push(`${inv.file}: ${key}: a cardinality invariant names its relationship (relationship_id)`);
|
|
196
|
+
for (const a of attrs) {
|
|
197
|
+
const at = view.get("entity_attributes")?.get(a);
|
|
198
|
+
if (!at)
|
|
199
|
+
problems.push(`${inv.file}: ${key}: attribute ${elementKey("entity_attributes", a)} is not in this view`);
|
|
200
|
+
else if (String(at.columns["entity_id"] ?? "") !== entity)
|
|
201
|
+
problems.push(`${inv.file}: ${key}: attribute ${elementKey("entity_attributes", a)} belongs to ${String(at.columns["entity_id"] ?? "")}, not ${entity}`);
|
|
202
|
+
}
|
|
203
|
+
if (rel !== "") {
|
|
204
|
+
const r = view.get("entity_relationships")?.get(rel);
|
|
205
|
+
if (r && String(r.columns["from_entity_id"] ?? "") !== entity)
|
|
206
|
+
problems.push(`${inv.file}: ${key}: relationship ${elementKey("entity_relationships", rel)} is ${String(r.columns["from_entity_id"] ?? "")}'s, not ${entity}'s`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* One change's deltas against the current view (M-ADR 0230 §4.4): each element's shape, ADDED ids new, MODIFIED and
|
|
212
|
+
* REMOVED ids held by the delta's partition, and a base revision for every element it modifies, removes or reads.
|
|
213
|
+
* A base revision that no longer matches the current element is reported as moved: the change is revalidated.
|
|
214
|
+
* `addedElsewhere` holds the elements other open changes add, for a reference into one of them.
|
|
215
|
+
*/
|
|
216
|
+
export function validateDeltas(schema, current, deltas, addedElsewhere, problems) {
|
|
217
|
+
const ownAdded = new Set();
|
|
218
|
+
const dependsOn = new Set();
|
|
219
|
+
for (const d of deltas) {
|
|
220
|
+
for (const e of d.added)
|
|
221
|
+
ownAdded.add(elementKey(e.catalogue, e.id));
|
|
222
|
+
for (const c of d.dependsOn)
|
|
223
|
+
dependsOn.add(c);
|
|
224
|
+
}
|
|
225
|
+
const seenWrite = new Map();
|
|
226
|
+
for (const d of deltas) {
|
|
227
|
+
const write = (key, op) => {
|
|
228
|
+
const prior = seenWrite.get(key);
|
|
229
|
+
if (prior)
|
|
230
|
+
problems.push(`${d.file}: ${op} ${key}, which this change also writes in ${prior}`);
|
|
231
|
+
else
|
|
232
|
+
seenWrite.set(key, `${d.file} (${op})`);
|
|
233
|
+
};
|
|
234
|
+
const needBase = (key, why) => {
|
|
235
|
+
const b = d.base.get(key);
|
|
236
|
+
if (b === undefined) {
|
|
237
|
+
problems.push(`${d.file}: no base revision for ${key}, which it ${why}`);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
const [catalogue, ...rest] = key.split("/");
|
|
241
|
+
const cur = current.get(catalogue)?.get(rest.join("/"));
|
|
242
|
+
if (cur && revisionOf(cur) !== b)
|
|
243
|
+
problems.push(`${d.file}: base revision of ${key} has moved since the change was written (${b.slice(0, 19)}… now ${revisionOf(cur).slice(0, 19)}…): revalidate the change`);
|
|
244
|
+
};
|
|
245
|
+
for (const e of d.added) {
|
|
246
|
+
const key = elementKey(e.catalogue, e.id);
|
|
247
|
+
checkElementShape(schema, e, `${d.file}: ADDED`, problems);
|
|
248
|
+
if (current.get(e.catalogue)?.has(e.id))
|
|
249
|
+
problems.push(`${d.file}: ADDED ${key}, which the model already holds; an added id is new`);
|
|
250
|
+
write(key, "ADDED");
|
|
251
|
+
}
|
|
252
|
+
for (const e of d.modified) {
|
|
253
|
+
const key = elementKey(e.catalogue, e.id);
|
|
254
|
+
checkElementShape(schema, e, `${d.file}: MODIFIED`, problems);
|
|
255
|
+
const cur = current.get(e.catalogue)?.get(e.id);
|
|
256
|
+
if (!cur)
|
|
257
|
+
problems.push(`${d.file}: MODIFIED ${key}, which the model does not hold`);
|
|
258
|
+
else {
|
|
259
|
+
if (cur.partition !== d.partition)
|
|
260
|
+
problems.push(`${d.file}: MODIFIED ${key}, which partition ${cur.partition} owns, not ${d.partition}`);
|
|
261
|
+
needBase(key, "modifies");
|
|
262
|
+
}
|
|
263
|
+
write(key, "MODIFIED");
|
|
264
|
+
}
|
|
265
|
+
for (const r of d.removed) {
|
|
266
|
+
const key = elementKey(r.catalogue, r.id);
|
|
267
|
+
const cur = current.get(r.catalogue)?.get(r.id);
|
|
268
|
+
if (!cur)
|
|
269
|
+
problems.push(`${d.file}: REMOVED ${key}, which the model does not hold`);
|
|
270
|
+
else {
|
|
271
|
+
if (cur.partition !== d.partition)
|
|
272
|
+
problems.push(`${d.file}: REMOVED ${key}, which partition ${cur.partition} owns, not ${d.partition}`);
|
|
273
|
+
needBase(key, "removes");
|
|
274
|
+
}
|
|
275
|
+
write(key, "REMOVED");
|
|
276
|
+
}
|
|
277
|
+
// Reads: every element an added or modified element references.
|
|
278
|
+
for (const e of [...d.added, ...d.modified]) {
|
|
279
|
+
for (const r of referencesOf(schema, e)) {
|
|
280
|
+
if (!r.structural)
|
|
281
|
+
continue;
|
|
282
|
+
const target = r.targetColumn === "id" ? r.value : find(current, r.catalogue, r.targetColumn, r.value)?.id;
|
|
283
|
+
const key = elementKey(r.catalogue, target ?? r.value);
|
|
284
|
+
if (target !== undefined && current.get(r.catalogue)?.has(target)) {
|
|
285
|
+
needBase(key, `reads (${elementKey(e.catalogue, e.id)}.${r.column})`);
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
if (ownAdded.has(key))
|
|
289
|
+
continue;
|
|
290
|
+
const by = addedElsewhere.get(key);
|
|
291
|
+
if (by !== undefined && dependsOn.has(by))
|
|
292
|
+
continue;
|
|
293
|
+
if (by !== undefined)
|
|
294
|
+
problems.push(`${d.file}: ${elementKey(e.catalogue, e.id)}.${r.column} names ${key}, which open change ${by} adds; declare it in depends_on`);
|
|
295
|
+
else
|
|
296
|
+
problems.push(`${d.file}: ${elementKey(e.catalogue, e.id)}.${r.column} names ${key}, which is in neither the model nor an open change`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
//# sourceMappingURL=view.js.map
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// What an agreement binds and what a verdict must show (M-ADR 0222 §4.6, M-ADR 0223 §4.4-§4.6).
|
|
2
2
|
// Pure text helpers; behaviour-store.ts runs them against git.
|
|
3
3
|
//
|
|
4
|
-
// agreed content the change's proposal, deltas, Gate 0, design, tasks (checkboxes ignored)
|
|
5
|
-
// scenario-to-test rows (its Before and After cells ignored)
|
|
4
|
+
// agreed content the change's proposal, deltas, Gate 0, design, tasks (checkboxes ignored), the test plan's
|
|
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, Proposed; report only until it is accepted) are what was approved. Each is compared
|
|
6
7
|
// with its state at the record's pinned commit: the latest amendment's commit, else sdd.commit.
|
|
7
8
|
// requirement a requirement's block in a spec: its heading to the next requirement or section heading. The
|
|
8
9
|
// stale check compares the blocks a change targets, not the whole file.
|
|
@@ -12,7 +13,15 @@
|
|
|
12
13
|
// verdict line verdict.md's own verdict: `Verdict: PASS|FAIL` or a leading `**PASS**`/`**FAIL**`.
|
|
13
14
|
/** The files under a change directory that agreement binds, relative to the change directory. */
|
|
14
15
|
export function boundFile(rel) {
|
|
15
|
-
return /^(proposal|design|gate-0|tasks|test-plan)\.md$/.test(rel) || /^specs\/.+\/spec\.md$/.test(rel);
|
|
16
|
+
return /^(proposal|design|gate-0|tasks|test-plan)\.md$/.test(rel) || /^specs\/.+\/spec\.md$/.test(rel) || structuralFile(rel);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* A change's structural artifact (M-ADR 0230 §4.4, Proposed): a partition delta or the no-op claim, both agreed
|
|
20
|
+
* content, so a delta's base revisions are pinned with it. The effect record (structure/applied.yaml) is written by
|
|
21
|
+
* the archive, after agreement, and is not.
|
|
22
|
+
*/
|
|
23
|
+
export function structuralFile(rel) {
|
|
24
|
+
return /^structure\/[^/]+\.yaml$/.test(rel) && rel !== "structure/applied.yaml";
|
|
16
25
|
}
|
|
17
26
|
/** A bound file's content as agreement compares it: all of it, with only the fields execution fills in masked. */
|
|
18
27
|
export function normaliseBound(rel, text) {
|
|
@@ -24,8 +33,8 @@ export function normaliseBound(rel, text) {
|
|
|
24
33
|
}
|
|
25
34
|
/**
|
|
26
35
|
* The test plan as agreed. Masked, because execution fills them in: the last two cells (Before and After) of each
|
|
27
|
-
* row of the scenario table, the whole Ledger
|
|
28
|
-
*
|
|
36
|
+
* row of the scenario table, and the whole Ledger and Mutants sections. Everything else, the stand-ins and any
|
|
37
|
+
* strategy or limitation stated, stays bound.
|
|
29
38
|
*/
|
|
30
39
|
export function normaliseTestPlan(text) {
|
|
31
40
|
const out = [];
|
|
@@ -34,12 +43,15 @@ export function normaliseTestPlan(text) {
|
|
|
34
43
|
const h = /^##\s+(.+?)\s*$/.exec(line);
|
|
35
44
|
if (h) {
|
|
36
45
|
section = h[1].toLowerCase();
|
|
37
|
-
|
|
46
|
+
if (section !== "mutants")
|
|
47
|
+
out.push(line);
|
|
38
48
|
continue;
|
|
39
49
|
}
|
|
40
|
-
|
|
50
|
+
// The ledger and the mutants are what apply records: the mutants are written against guards that exist only once
|
|
51
|
+
// the change is applied, and the judge writes its own for any guard they miss (M-ADR 0228 §4.1, Proposed).
|
|
52
|
+
if (section === "ledger" || section === "mutants")
|
|
41
53
|
continue;
|
|
42
|
-
if (
|
|
54
|
+
if (section === "" && /^\s*\|/.test(line)) {
|
|
43
55
|
const cells = line.trim().replace(/^\||\|$/g, "").split("|").map((c) => c.trim());
|
|
44
56
|
out.push(cells.slice(0, Math.max(0, cells.length - 2)).join(" | "));
|
|
45
57
|
continue;
|
|
@@ -14,8 +14,9 @@
|
|
|
14
14
|
// block, as it was at the agreed commit (the latest amendment's, else sdd.commit; M-ADR 0223
|
|
15
15
|
// §4.5). Another requirement of the same spec moving does not stale it. With no targets the
|
|
16
16
|
// check is vacuous, and says so.
|
|
17
|
-
// agreed content: the change's proposal, deltas, Gate 0, design, tasks (checkboxes ignored)
|
|
18
|
-
// plan (its execution results ignored) equal their state at that commit
|
|
17
|
+
// agreed content: the change's proposal, deltas, Gate 0, design, tasks (checkboxes ignored), test
|
|
18
|
+
// plan (its execution results ignored) and structural deltas equal their state at that commit
|
|
19
|
+
// (M-ADR 0222 §4.6; a structural delta's change is a [WARN] while M-ADR 0230 is Proposed).
|
|
19
20
|
// kinds: on a record held to 1.1.0, each test-plan row's Kind (changed or carried) is the one computed
|
|
20
21
|
// from the deltas and the living spec, a row naming no scenario of the deltas fails, and a
|
|
21
22
|
// filled Before is a fail for changed and a pass for carried (M-ADR 0223 §4.4).
|
|
@@ -68,7 +69,8 @@ import { promisify } from "node:util";
|
|
|
68
69
|
import { parseDocument } from "yaml";
|
|
69
70
|
import { observedIn, orderArchives, parseRequirements, parseSurfaces, renamedFrom, renamePairs, resolveImplements, surfaceMatches } from "../behaviour-store/archive.js";
|
|
70
71
|
import { parseCatalogueCsv } from "../catalogue/csv-reader.js";
|
|
71
|
-
import { boundFile, conditionProblems, isCommit, normaliseBound, requirementBlock, sameCommit, scenarioKind, taskGroups, testPlanHeader, testPlanMapping, verdictLine, versionAtLeast } from "./agreement-evidence.js";
|
|
72
|
+
import { boundFile, conditionProblems, structuralFile, isCommit, normaliseBound, requirementBlock, sameCommit, scenarioKind, taskGroups, testPlanHeader, testPlanMapping, verdictLine, versionAtLeast } from "./agreement-evidence.js";
|
|
73
|
+
import { ENFORCED as STRUCTURE_ENFORCED } from "./structural-model.js";
|
|
72
74
|
import { compareStoreSchema, SCHEMA_NAME, SHIPPED_SCHEMA_DIR, STORE_CONFIG, STORE_SCHEMA_DIR, storeSchemaName } from "../behaviour-store/schema.js";
|
|
73
75
|
const execFileAsync = promisify(execFile);
|
|
74
76
|
/** The OpenSpec release the method depends on (M-ADR 0223 ruling 2). The module installs exactly this. */
|
|
@@ -171,6 +173,19 @@ export function parseAgreementRecord(file, text) {
|
|
|
171
173
|
const conditions = Array.isArray(r.conditions) ? r.conditions : [];
|
|
172
174
|
if (r.conditions !== undefined && !Array.isArray(r.conditions))
|
|
173
175
|
problems.push(`${file}: conditions must be a list`);
|
|
176
|
+
const noLongerImplements = [];
|
|
177
|
+
if (r.no_longer_implements !== undefined) {
|
|
178
|
+
if (!Array.isArray(r.no_longer_implements))
|
|
179
|
+
problems.push(`${file}: no_longer_implements must be a list of {repo, requirement}`);
|
|
180
|
+
else
|
|
181
|
+
for (const [i, x] of r.no_longer_implements.entries()) {
|
|
182
|
+
const e = x;
|
|
183
|
+
if (!e || typeof e.repo !== "string" || typeof e.requirement !== "string")
|
|
184
|
+
problems.push(`${file}: no_longer_implements[${i}] names a repo and a requirement`);
|
|
185
|
+
else
|
|
186
|
+
noLongerImplements.push({ repo: e.repo, requirement: e.requirement });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
174
189
|
if (problems.length > 0)
|
|
175
190
|
return { problems };
|
|
176
191
|
return {
|
|
@@ -185,6 +200,7 @@ export function parseAgreementRecord(file, text) {
|
|
|
185
200
|
...(verdict ? { verdict } : {}),
|
|
186
201
|
workOrders,
|
|
187
202
|
conditions,
|
|
203
|
+
noLongerImplements,
|
|
188
204
|
},
|
|
189
205
|
problems,
|
|
190
206
|
};
|
|
@@ -335,9 +351,17 @@ async function listFiles(dir, rel = "") {
|
|
|
335
351
|
}
|
|
336
352
|
return out;
|
|
337
353
|
}
|
|
338
|
-
/**
|
|
339
|
-
|
|
340
|
-
|
|
354
|
+
/**
|
|
355
|
+
* Where a work order's repository is checked out: the SDD itself for the SDD's own work order (its repository id is
|
|
356
|
+
* the SDD's sdd_id), else a checkout beside the SDD (../<repo>). The SDD's own commits are always at hand, wherever
|
|
357
|
+
* the SDD is mounted, so its work order never depends on a sibling directory's name.
|
|
358
|
+
*/
|
|
359
|
+
function checkoutDir(sddRepo, codeRepo, ownId) {
|
|
360
|
+
return ownId !== "" && codeRepo === ownId ? sddRepo : join(sddRepo, "..", codeRepo);
|
|
361
|
+
}
|
|
362
|
+
/** A work order repository's checkout, when there is one. */
|
|
363
|
+
async function codeCheckout(sddRepo, codeRepo, ownId) {
|
|
364
|
+
const dir = checkoutDir(sddRepo, codeRepo, ownId);
|
|
341
365
|
try {
|
|
342
366
|
await execFileAsync("git", ["-C", dir, "rev-parse", "--git-dir"]);
|
|
343
367
|
return dir;
|
|
@@ -346,9 +370,9 @@ async function codeCheckout(sddRepo, codeRepo) {
|
|
|
346
370
|
return null;
|
|
347
371
|
}
|
|
348
372
|
}
|
|
349
|
-
/** Whether two commits of a
|
|
350
|
-
async function sameTree(sddRepo, codeRepo, a, b) {
|
|
351
|
-
const dir =
|
|
373
|
+
/** Whether two commits of a work order's repository, in its checkout, have one tree. */
|
|
374
|
+
async function sameTree(sddRepo, codeRepo, ownId, a, b) {
|
|
375
|
+
const dir = checkoutDir(sddRepo, codeRepo, ownId);
|
|
352
376
|
try {
|
|
353
377
|
const [x, y] = await Promise.all([a, b].map(async (c) => (await execFileAsync("git", ["-C", dir, "rev-parse", `${c}^{tree}`])).stdout.trim()));
|
|
354
378
|
return x === y ? "same" : "differs";
|
|
@@ -365,6 +389,15 @@ export async function checkBehaviourStore(repo, options = {}) {
|
|
|
365
389
|
const lines = [];
|
|
366
390
|
const failures = [];
|
|
367
391
|
const warnings = [];
|
|
392
|
+
// The SDD's own id: a work order for the SDD's own repository is checked in the SDD itself.
|
|
393
|
+
let ownId = "";
|
|
394
|
+
try {
|
|
395
|
+
const m = parseDocument((await readText(join(repo, ".sdd", "sdd-manifest.yaml"))) ?? "").toJS();
|
|
396
|
+
ownId = String(m?.sdd_id ?? "");
|
|
397
|
+
}
|
|
398
|
+
catch {
|
|
399
|
+
// the manifest check reports an unreadable manifest
|
|
400
|
+
}
|
|
368
401
|
// 1. records
|
|
369
402
|
const records = new Map();
|
|
370
403
|
let recordFiles = [];
|
|
@@ -454,7 +487,11 @@ export async function checkBehaviourStore(repo, options = {}) {
|
|
|
454
487
|
const a = await gitShow(repo, base, `${dir}/${f}`);
|
|
455
488
|
const b = await gitShow(repo, "HEAD", `${dir}/${f}`);
|
|
456
489
|
if (a === null || b === null || normaliseBound(f, a) !== normaliseBound(f, b)) {
|
|
457
|
-
|
|
490
|
+
const what = `${dir}: AGREED CONTENT CHANGED: ${f} ${a === null ? "was added" : b === null ? "was removed" : "differs"} since ${base}, with no amendment recording a commit and an approver; amend the agreement or renew it (M-ADR 0222 §4.6)`;
|
|
491
|
+
if (structuralFile(f) && !STRUCTURE_ENFORCED)
|
|
492
|
+
warnings.push(`${what}; a structural delta, report only while M-ADR 0230 is Proposed`);
|
|
493
|
+
else
|
|
494
|
+
failures.push(what);
|
|
458
495
|
}
|
|
459
496
|
}
|
|
460
497
|
}
|
|
@@ -601,7 +638,11 @@ export async function checkBehaviourStore(repo, options = {}) {
|
|
|
601
638
|
const a = await gitShow(repo, base, `${openDir}/${f}`);
|
|
602
639
|
const b = await readText(join(repo, dir, f));
|
|
603
640
|
if (a === null || b === null || normaliseBound(f, a) !== normaliseBound(f, b)) {
|
|
604
|
-
|
|
641
|
+
const what = `${dir}: AGREED CONTENT CHANGED: ${f} ${a === null ? "was added" : b === null ? "was removed" : "differs"} from what was agreed at ${base}, with no amendment recording a commit and an approver (M-ADR 0222 §4.6)`;
|
|
642
|
+
if (structuralFile(f) && !STRUCTURE_ENFORCED)
|
|
643
|
+
warnings.push(`${what}; a structural delta, report only while M-ADR 0230 is Proposed`);
|
|
644
|
+
else
|
|
645
|
+
failures.push(what);
|
|
605
646
|
}
|
|
606
647
|
}
|
|
607
648
|
// Stale at archive: the requirements the change targets, just before it archived, are as they were agreed. After
|
|
@@ -681,7 +722,7 @@ export async function checkBehaviourStore(repo, options = {}) {
|
|
|
681
722
|
failures.push(`${record.file}: verdict.merged ${m === undefined ? "names no commit" : `names ${JSON.stringify(m)}, not a commit`} for ${wo.repo}`);
|
|
682
723
|
continue;
|
|
683
724
|
}
|
|
684
|
-
const checkout = await codeCheckout(repo, wo.repo);
|
|
725
|
+
const checkout = await codeCheckout(repo, wo.repo, ownId);
|
|
685
726
|
if (checkout) {
|
|
686
727
|
for (const c of [j, m])
|
|
687
728
|
if (!(await commitResolves(checkout, c)))
|
|
@@ -690,7 +731,7 @@ export async function checkBehaviourStore(repo, options = {}) {
|
|
|
690
731
|
else if (sameCommit(j, m))
|
|
691
732
|
pinsUnverified++;
|
|
692
733
|
if (!sameCommit(j, m)) {
|
|
693
|
-
const cmp = await sameTree(repo, wo.repo, j, m);
|
|
734
|
+
const cmp = await sameTree(repo, wo.repo, ownId, j, m);
|
|
694
735
|
if (cmp === "differs")
|
|
695
736
|
failures.push(`${record.file}: ${wo.repo} merged ${m}, whose tree differs from the judged ${j}: what merged is not what was judged`);
|
|
696
737
|
if (cmp === "unresolved")
|
|
@@ -4,6 +4,7 @@ import { checkBundleCoherence } from "./bundle-coherence.js";
|
|
|
4
4
|
import { checkFeatureSpecPointers } from "./feature-spec-pointers.js";
|
|
5
5
|
import { checkIntents } from "./intents.js";
|
|
6
6
|
import { checkJourneyImpact } from "./journey-impact.js";
|
|
7
|
+
import { checkStructuralModel } from "./structural-model.js";
|
|
7
8
|
import { checkYamlParse } from "./yaml-parse.js";
|
|
8
9
|
// The behaviour store (M-ADR 0221-0223) can sit in an SDD of any profile, so every profile carries
|
|
9
10
|
// its two checks; without a store both report [N/A], an empty population, not a pass.
|
|
@@ -42,6 +43,12 @@ const BEHAVIOUR_STORE_VALIDATORS = [
|
|
|
42
43
|
source: { kind: "native", run: async (ctx) => checkJourneyImpact(ctx.targetRepo) },
|
|
43
44
|
build: () => ({ status: "run", args: [] }),
|
|
44
45
|
},
|
|
46
|
+
// The structural model and each open change's structural artifact (M-ADR 0230, Proposed). Report only; [N/A] without a model.
|
|
47
|
+
{
|
|
48
|
+
name: "structural-model",
|
|
49
|
+
source: { kind: "native", run: async (ctx) => checkStructuralModel(ctx.targetRepo) },
|
|
50
|
+
build: () => ({ status: "run", args: [] }),
|
|
51
|
+
},
|
|
45
52
|
];
|
|
46
53
|
// Validators that accept --app-root / --repo-root flags work fine from the
|
|
47
54
|
// bundled location because they override their internal ROOT_DIR via the
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// never the proposal's.
|
|
10
10
|
// unnamed a listed journey the proposal's `## Affected Journeys` section does not name.
|
|
11
11
|
// unshown a journey named `altered` with nothing to show it: no delta in the change to a feature of kind journey
|
|
12
|
-
// whose
|
|
12
|
+
// whose delta or living spec tags it @journey:<journey-id> (option B), and no task naming it (option A).
|
|
13
13
|
// A proposal line is `- <journey-id>: altered ...` or `- <journey-id>: unaffected, <reason>`; an unaffected line
|
|
14
14
|
// with no reason is reported too.
|
|
15
15
|
//
|
|
@@ -52,6 +52,10 @@ async function csv(repo, name, dir = "catalogues") {
|
|
|
52
52
|
return text === null ? null : parseCatalogueCsv(text).rows;
|
|
53
53
|
}
|
|
54
54
|
/** The `## Affected Journeys` section: journey id -> what the proposal says of it. */
|
|
55
|
+
/** The journey ids a spec tags with @journey:<id>. */
|
|
56
|
+
export function journeyTags(text) {
|
|
57
|
+
return new Set([...text.matchAll(/@journey:([a-z0-9][a-z0-9-]*)/g)].map((m) => m[1]));
|
|
58
|
+
}
|
|
55
59
|
export function parseAffectedJourneys(proposal) {
|
|
56
60
|
const out = new Map();
|
|
57
61
|
let inSection = false;
|
|
@@ -178,7 +182,18 @@ export async function checkJourneyImpact(repo) {
|
|
|
178
182
|
if (n.verdict === "unaffected" && n.rest === "")
|
|
179
183
|
warnings.push(`${CHANGES_DIR}/${change}/proposal.md: journey ${j} is named unaffected without a reason`);
|
|
180
184
|
if (n.verdict === "altered") {
|
|
181
|
-
|
|
185
|
+
// Option B: a delta to a feature of kind journey that names this journey with its @journey tag, in the delta
|
|
186
|
+
// or in the living spec it changes (M-ADR 0229 §4.2). The tag, not the feature's id, binds the two.
|
|
187
|
+
let journeyDelta = false;
|
|
188
|
+
for (const f of deltaFeatures) {
|
|
189
|
+
if (featureKinds.get(f) !== "journey")
|
|
190
|
+
continue;
|
|
191
|
+
const text = `${(await readText(join(dir, "specs", f, "spec.md"))) ?? ""}\n${(await readText(join(repo, "openspec", "specs", f, "spec.md"))) ?? ""}`;
|
|
192
|
+
if (journeyTags(text).has(j)) {
|
|
193
|
+
journeyDelta = true;
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
182
197
|
const shown = journeyDelta || tasks.includes(j);
|
|
183
198
|
if (!shown)
|
|
184
199
|
warnings.push(`${CHANGES_DIR}/${change}/proposal.md: journey ${j} is named altered, and the change shows nothing for it: no delta to its journey feature and no task naming it`);
|