@sdd-method/author-cli 0.9.0 → 0.11.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/author-program.js +1 -0
- package/dist/lib/behaviour-store/archive.js +13 -0
- package/dist/lib/catalogue/build.js +12 -0
- package/dist/lib/catalogue/builders/capability-spec.js +1 -0
- package/dist/lib/catalogue/builders/feature-grounding.js +1 -0
- package/dist/lib/catalogue/builders/features.js +23 -2
- package/dist/lib/catalogue/builders/journey-requirement.js +144 -0
- package/dist/lib/catalogue/builders/requirements.js +43 -16
- package/dist/lib/catalogue/canonical-schema.yaml +81 -14
- package/dist/lib/catalogue/manifest-loader.js +13 -0
- package/dist/lib/catalogue/rubric-coverage-map.yaml +18 -0
- package/dist/lib/catalogue/schema-compat.js +1 -1
- package/dist/lib/intents/format.js +77 -0
- package/dist/lib/intents/migrate.js +670 -0
- package/dist/lib/metamodel/tql-subset-parser.js +35 -2
- package/dist/lib/validate/agreement-evidence.js +204 -0
- package/dist/lib/validate/behaviour-store.js +475 -28
- package/dist/lib/validate/dispatch.js +21 -0
- package/dist/lib/validate/feature-spec-pointers.js +175 -0
- package/dist/lib/validate/intents.js +76 -0
- package/dist/lib/validate/journey-impact.js +193 -0
- package/dist/maintain-version.json +2 -2
- package/dist/verbs/migrate.js +44 -0
- package/dist/verify-version.json +2 -2
- package/package.json +2 -2
|
@@ -174,7 +174,13 @@ export function parseTypes(text, fileLabel = "catalogue-schema.tql", options = {
|
|
|
174
174
|
throw new ParseError(`${where}: plays needs <relation>:<role>`);
|
|
175
175
|
if (decl.plays.has(w[1]))
|
|
176
176
|
throw new ParseError(`${where}: ${lab} plays ${w[1]} twice`);
|
|
177
|
-
|
|
177
|
+
const ann = parseAnnotations(w.slice(2), where);
|
|
178
|
+
// A plays clause's cardinality constrains participation, which the relational projection has no place for:
|
|
179
|
+
// refused, so a constraint the model gained can never be dropped by the projection with the gate green.
|
|
180
|
+
if (ann.key || ann.required || ann.multi || ann.optional) {
|
|
181
|
+
throw new ParseError(`${where}: ${lab} plays ${w[1]} with a cardinality annotation; participation cardinality cannot be projected onto the catalogue schema, so it is not admitted`);
|
|
182
|
+
}
|
|
183
|
+
decl.plays.set(w[1], ann);
|
|
178
184
|
}
|
|
179
185
|
else if (head === "relates") {
|
|
180
186
|
if (kind !== "relation")
|
|
@@ -225,6 +231,16 @@ export function parseTypes(text, fileLabel = "catalogue-schema.tql", options = {
|
|
|
225
231
|
}
|
|
226
232
|
const ROW_RE = /^insert \$m isa ([a-z][a-z0-9-]*), (.*);$/;
|
|
227
233
|
const TOK_RE = /has ([a-z][a-z0-9-]*) ("(?:[^"\\]|\\.)*"|-?\d+|true|false)/y;
|
|
234
|
+
function valueFamily(v) {
|
|
235
|
+
return typeof v === "boolean" ? "boolean" : typeof v === "number" ? "integer" : "string";
|
|
236
|
+
}
|
|
237
|
+
function attrFamily(valueType) {
|
|
238
|
+
if (valueType === undefined)
|
|
239
|
+
return undefined;
|
|
240
|
+
if (valueType === "long" || valueType === "integer")
|
|
241
|
+
return "integer";
|
|
242
|
+
return valueType;
|
|
243
|
+
}
|
|
228
244
|
export function parseMeta(text, fileLabel = "catalogue-schema-meta.tql", options = {}) {
|
|
229
245
|
const metaTypes = options.metaTypes ?? META_TYPES;
|
|
230
246
|
const rows = new Map();
|
|
@@ -268,9 +284,26 @@ export function parseMeta(text, fileLabel = "catalogue-schema-meta.tql", options
|
|
|
268
284
|
else
|
|
269
285
|
throw new ParseError(`${where}: expected ', has ...' at offset ${i}: ${JSON.stringify(body.slice(i, i + 40))}`);
|
|
270
286
|
}
|
|
287
|
+
const decl = options.types?.entities.get(typ);
|
|
288
|
+
if (options.types && !decl)
|
|
289
|
+
throw new ParseError(`${where}: meta type ${typ} is not declared in the type file`);
|
|
290
|
+
if (decl) {
|
|
291
|
+
for (const [k, vs] of acc) {
|
|
292
|
+
const own = decl.owns.get(k);
|
|
293
|
+
if (!own)
|
|
294
|
+
throw new ParseError(`${where}: ${typ} does not own ${k}; an undeclared meta property is refused, not ignored`);
|
|
295
|
+
if (!own.multi && vs.length > 1)
|
|
296
|
+
throw new ParseError(`${where}: ${typ}.${k} is a single value and appears ${vs.length} times; a repeated scalar is refused, not reduced to its first value`);
|
|
297
|
+
const fam = attrFamily(options.types.attrs.get(k));
|
|
298
|
+
for (const v of vs) {
|
|
299
|
+
if (fam !== undefined && valueFamily(v) !== fam)
|
|
300
|
+
throw new ParseError(`${where}: ${typ}.${k} holds ${JSON.stringify(v)}, but ${k} is of value type ${options.types.attrs.get(k)}`);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
271
304
|
const row = {};
|
|
272
305
|
for (const [k, vs] of acc)
|
|
273
|
-
row[k] = LIST_KEYS.has(k) ? vs : vs[0];
|
|
306
|
+
row[k] = (decl ? decl.owns.get(k).multi : LIST_KEYS.has(k)) ? vs : vs[0];
|
|
274
307
|
const list = rows.get(typ) ?? [];
|
|
275
308
|
list.push(row);
|
|
276
309
|
rows.set(typ, list);
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// What an agreement binds and what a verdict must show (M-ADR 0222 §4.6, M-ADR 0223 §4.4-§4.6).
|
|
2
|
+
// Pure text helpers; behaviour-store.ts runs them against git.
|
|
3
|
+
//
|
|
4
|
+
// agreed content the change's proposal, deltas, Gate 0, design, tasks (checkboxes ignored) and the test plan's
|
|
5
|
+
// scenario-to-test rows (its Before and After cells ignored) are what was approved. Each is compared
|
|
6
|
+
// with its state at the record's pinned commit: the latest amendment's commit, else sdd.commit.
|
|
7
|
+
// requirement a requirement's block in a spec: its heading to the next requirement or section heading. The
|
|
8
|
+
// stale check compares the blocks a change targets, not the whole file.
|
|
9
|
+
// scenario kind changed (a scenario of an ADDED requirement, or one of a MODIFIED requirement that is new or
|
|
10
|
+
// differs from the living spec's scenario of the same title) goes red then green; carried (identical
|
|
11
|
+
// to the living spec's) is regression and stays green.
|
|
12
|
+
// verdict line verdict.md's own verdict: `Verdict: PASS|FAIL` or a leading `**PASS**`/`**FAIL**`.
|
|
13
|
+
/** The files under a change directory that agreement binds, relative to the change directory. */
|
|
14
|
+
export function boundFile(rel) {
|
|
15
|
+
return /^(proposal|design|gate-0|tasks|test-plan)\.md$/.test(rel) || /^specs\/.+\/spec\.md$/.test(rel);
|
|
16
|
+
}
|
|
17
|
+
/** A bound file's content as agreement compares it: all of it, with only the fields execution fills in masked. */
|
|
18
|
+
export function normaliseBound(rel, text) {
|
|
19
|
+
if (rel === "tasks.md")
|
|
20
|
+
return text.replace(/^(\s*-\s*\[)\s*[xX~]?\s*(\])/gm, "$1 $2").trimEnd();
|
|
21
|
+
if (rel === "test-plan.md")
|
|
22
|
+
return normaliseTestPlan(text);
|
|
23
|
+
return text.trimEnd();
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* 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 section, and the last two cells (Killed by and Result) of each row of
|
|
28
|
+
* the Mutants table. Everything else, the stand-ins and any strategy or limitation stated, stays bound.
|
|
29
|
+
*/
|
|
30
|
+
export function normaliseTestPlan(text) {
|
|
31
|
+
const out = [];
|
|
32
|
+
let section = "";
|
|
33
|
+
for (const line of text.split("\n")) {
|
|
34
|
+
const h = /^##\s+(.+?)\s*$/.exec(line);
|
|
35
|
+
if (h) {
|
|
36
|
+
section = h[1].toLowerCase();
|
|
37
|
+
out.push(line);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (section === "ledger")
|
|
41
|
+
continue;
|
|
42
|
+
if ((section === "" || section === "mutants") && /^\s*\|/.test(line)) {
|
|
43
|
+
const cells = line.trim().replace(/^\||\|$/g, "").split("|").map((c) => c.trim());
|
|
44
|
+
out.push(cells.slice(0, Math.max(0, cells.length - 2)).join(" | "));
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
out.push(line);
|
|
48
|
+
}
|
|
49
|
+
return out.join("\n").trimEnd();
|
|
50
|
+
}
|
|
51
|
+
/** A tasks file's repository groups (`## <n>. <repository>`) and the requirements each says it implements. */
|
|
52
|
+
export function taskGroups(tasks) {
|
|
53
|
+
const out = [];
|
|
54
|
+
let current = null;
|
|
55
|
+
for (const line of tasks.split("\n")) {
|
|
56
|
+
const g = /^##\s+\d+\.\s+`?([A-Za-z0-9][A-Za-z0-9._-]*)`?\s*$/.exec(line);
|
|
57
|
+
if (g) {
|
|
58
|
+
current = { repo: g[1], implements: [] };
|
|
59
|
+
out.push(current);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (/^##\s/.test(line)) {
|
|
63
|
+
current = null;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const m = /^\s*(?:[-*]\s+)?\**Implements:?\**:?\s*(.+)$/i.exec(line);
|
|
67
|
+
if (current && m)
|
|
68
|
+
current.implements.push(...m[1].split(",").map((x) => x.trim().replace(/^`|`$/g, "")).filter((x) => x && !/^none$/i.test(x)));
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
/** A pinned commit: 7 to 40 hex characters. */
|
|
73
|
+
export function isCommit(v) {
|
|
74
|
+
return typeof v === "string" && /^[0-9a-f]{7,40}$/i.test(v);
|
|
75
|
+
}
|
|
76
|
+
/** a >= b, for x.y.z versions; a missing or malformed version is lowest. */
|
|
77
|
+
export function versionAtLeast(a, b) {
|
|
78
|
+
const p = (v) => (v && /^\d+\.\d+\.\d+$/.test(v) ? v.split(".").map(Number) : [-1, -1, -1]);
|
|
79
|
+
const [x, y] = [p(a), p(b)];
|
|
80
|
+
for (let i = 0; i < 3; i++)
|
|
81
|
+
if (x[i] !== y[i])
|
|
82
|
+
return x[i] > y[i];
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
/** The test plan's scenario-to-test rows: every table row before `## Ledger`, as trimmed cells, with the first row (the header) and separator rows dropped. The template holds one table there; a second would be read as more rows. */
|
|
86
|
+
export function testPlanMapping(text) {
|
|
87
|
+
const head = text.split(/^##\s+Ledger\b/m)[0] ?? "";
|
|
88
|
+
const rows = head.split("\n").filter((l) => /^\s*\|/.test(l)).map((l) => l.trim().replace(/^\||\|$/g, "").split("|").map((c) => c.trim()));
|
|
89
|
+
return rows.filter((r, i) => i > 0 && !r.every((c) => /^:?-{3,}:?$/.test(c) || c === ""));
|
|
90
|
+
}
|
|
91
|
+
/** The test plan table's header cells, lower-cased. */
|
|
92
|
+
export function testPlanHeader(text) {
|
|
93
|
+
const head = text.split(/^##\s+Ledger\b/m)[0] ?? "";
|
|
94
|
+
const first = head.split("\n").find((l) => /^\s*\|/.test(l));
|
|
95
|
+
return first ? first.trim().replace(/^\||\|$/g, "").split("|").map((c) => c.trim().toLowerCase()) : [];
|
|
96
|
+
}
|
|
97
|
+
/** A requirement's block: `### Requirement: <name>` to the next requirement or `##` heading. Null when absent. */
|
|
98
|
+
export function requirementBlock(spec, name) {
|
|
99
|
+
const lines = spec.split("\n");
|
|
100
|
+
const start = lines.findIndex((l) => new RegExp(`^###\\s+Requirement:\\s*${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*$`).test(l));
|
|
101
|
+
if (start < 0)
|
|
102
|
+
return null;
|
|
103
|
+
let end = lines.length;
|
|
104
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
105
|
+
if (/^###\s+Requirement:/.test(lines[i]) || /^##\s/.test(lines[i])) {
|
|
106
|
+
end = i;
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return lines.slice(start, end).join("\n").trimEnd();
|
|
111
|
+
}
|
|
112
|
+
/** Scenario title -> its block text, within one requirement block. */
|
|
113
|
+
export function scenarios(block) {
|
|
114
|
+
const out = new Map();
|
|
115
|
+
let title = null;
|
|
116
|
+
let buf = [];
|
|
117
|
+
const flush = () => {
|
|
118
|
+
if (title !== null)
|
|
119
|
+
out.set(title, buf.join("\n").trim());
|
|
120
|
+
};
|
|
121
|
+
for (const line of block.split("\n")) {
|
|
122
|
+
const m = /^####\s+Scenario:\s*(.+?)\s*$/.exec(line);
|
|
123
|
+
if (m) {
|
|
124
|
+
flush();
|
|
125
|
+
title = m[1];
|
|
126
|
+
buf = [];
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (title !== null)
|
|
130
|
+
buf.push(line);
|
|
131
|
+
}
|
|
132
|
+
flush();
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
/** The section a requirement sits under in a delta: ADDED, MODIFIED, REMOVED or RENAMED. */
|
|
136
|
+
export function deltaOperation(delta, name) {
|
|
137
|
+
let op = null;
|
|
138
|
+
for (const line of delta.split("\n")) {
|
|
139
|
+
const s = /^##\s+(ADDED|MODIFIED|REMOVED|RENAMED)\s+Requirements\b/.exec(line);
|
|
140
|
+
if (s)
|
|
141
|
+
op = s[1];
|
|
142
|
+
else if (/^##\s/.test(line))
|
|
143
|
+
op = null;
|
|
144
|
+
if (new RegExp(`^###\\s+Requirement:\\s*${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*$`).test(line))
|
|
145
|
+
return op;
|
|
146
|
+
}
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
/** changed or carried, for one scenario of a requirement in a delta, against the living spec (null when the delta has no such scenario). */
|
|
150
|
+
export function scenarioKind(delta, living, requirement, scenario) {
|
|
151
|
+
const op = deltaOperation(delta, requirement);
|
|
152
|
+
const block = requirementBlock(delta, requirement);
|
|
153
|
+
if (!op || !block)
|
|
154
|
+
return null;
|
|
155
|
+
const mine = scenarios(block).get(scenario);
|
|
156
|
+
if (mine === undefined)
|
|
157
|
+
return null;
|
|
158
|
+
if (op !== "MODIFIED")
|
|
159
|
+
return "changed";
|
|
160
|
+
const theirs = living === null ? null : requirementBlock(living, requirement);
|
|
161
|
+
const before = theirs === null ? undefined : scenarios(theirs).get(scenario);
|
|
162
|
+
return before !== undefined && before === mine ? "carried" : "changed";
|
|
163
|
+
}
|
|
164
|
+
/** verdict.md's own verdict, or null when it states none in a form this reads. */
|
|
165
|
+
export function verdictLine(text) {
|
|
166
|
+
const m = /^\s*(?:#+\s*)?Verdict:\s*\**\s*(PASS|FAIL)\b/im.exec(text) ?? /^\s*\*\*(PASS|FAIL)\*\*/m.exec(text);
|
|
167
|
+
return m?.[1] ?? null;
|
|
168
|
+
}
|
|
169
|
+
/** Two pins name one commit when one abbreviates the other. */
|
|
170
|
+
export function sameCommit(a, b) {
|
|
171
|
+
const [x, y] = [a.toLowerCase(), b.toLowerCase()];
|
|
172
|
+
return x.length >= 7 && y.length >= 7 && (x.startsWith(y) || y.startsWith(x));
|
|
173
|
+
}
|
|
174
|
+
function isoDate(v) {
|
|
175
|
+
if (v instanceof Date && !Number.isNaN(v.getTime()))
|
|
176
|
+
return v.toISOString().slice(0, 10);
|
|
177
|
+
if (typeof v === "string" && /^\d{4}-\d{2}-\d{2}$/.test(v))
|
|
178
|
+
return v;
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Problems with one agreement condition (M-ADR 0222 §4.6 as amended); `today` is YYYY-MM-DD. A withdrawn or
|
|
183
|
+
* superseded change has closed its obligations: its conditions are not overdue.
|
|
184
|
+
*/
|
|
185
|
+
export function conditionProblems(c, today, recordStatus = "agreed") {
|
|
186
|
+
const out = [];
|
|
187
|
+
for (const k of ["assumption", "owner", "how", "release_limit"]) {
|
|
188
|
+
if (typeof c[k] !== "string" || c[k].trim() === "")
|
|
189
|
+
out.push(`no ${k}`);
|
|
190
|
+
}
|
|
191
|
+
const by = isoDate(c.validate_by);
|
|
192
|
+
if (by === null)
|
|
193
|
+
out.push("validate_by is not a YYYY-MM-DD date");
|
|
194
|
+
const status = typeof c.status === "string" ? c.status : "";
|
|
195
|
+
if (!["open", "validated", "failed"].includes(status))
|
|
196
|
+
out.push(`status ${JSON.stringify(c.status)} is not open | validated | failed`);
|
|
197
|
+
if ((status === "validated" || status === "failed") && (typeof c.evidence !== "string" || c.evidence.trim() === ""))
|
|
198
|
+
out.push(`${status} without evidence`);
|
|
199
|
+
if (status === "open" && by !== null && by < today && recordStatus !== "withdrawn" && recordStatus !== "superseded")
|
|
200
|
+
out.push(`OVERDUE: open past its validate_by ${by}; validate it, extend it by a ruling, or withdraw the change`);
|
|
201
|
+
return out;
|
|
202
|
+
}
|
|
203
|
+
export { isoDate };
|
|
204
|
+
//# sourceMappingURL=agreement-evidence.js.map
|