@sdd-method/author-cli 0.10.0 → 0.12.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/behaviour-store/archive.js +13 -0
- package/dist/lib/catalogue/build.js +45 -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/capability-spec.js +1 -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/feature-grounding.js +1 -0
- package/dist/lib/catalogue/builders/features.js +23 -2
- package/dist/lib/catalogue/builders/integration-components.js +49 -5
- package/dist/lib/catalogue/builders/journey-requirement.js +144 -0
- package/dist/lib/catalogue/builders/quality-scores.js +17 -0
- package/dist/lib/catalogue/builders/requirements.js +168 -32
- package/dist/lib/catalogue/canonical-schema.yaml +845 -28
- package/dist/lib/catalogue/manifest-loader.js +13 -0
- package/dist/lib/catalogue/retired-catalogues.js +25 -0
- package/dist/lib/catalogue/rubric-coverage-map.yaml +108 -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/intents/migrate.js +35 -9
- package/dist/lib/metamodel/tql-subset-parser.js +35 -2
- 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 +216 -0
- package/dist/lib/validate/behaviour-store.js +516 -28
- package/dist/lib/validate/dispatch.js +21 -0
- package/dist/lib/validate/feature-spec-pointers.js +175 -0
- package/dist/lib/validate/journey-impact.js +193 -0
- 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,242 @@
|
|
|
1
|
+
// The generated views of the structural model (M-ADR 0230 §4.3, Proposed): projections, never edited. Each file is
|
|
2
|
+
// rendered from the current view alone, in id order, so the same model renders the same bytes. Its first line names
|
|
3
|
+
// the generator and the digest of the body below it, which tells three states apart:
|
|
4
|
+
// missing the file is absent
|
|
5
|
+
// stale the body matches its header but not a render of the model: the model moved; render again
|
|
6
|
+
// hand-edited the body no longer matches its header: someone edited a generated view
|
|
7
|
+
// Pure: the verb reads and writes the files.
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
import { VIEW_PATHS } from "./catalogue.js";
|
|
10
|
+
export { VIEW_PATHS };
|
|
11
|
+
const HEADER = /^<!-- Generated by sdd-cli structure render from openspec\/structure\/ \(M-ADR 0230\)\. Do not edit: change the model through a change's structural deltas\. body (sha256:[0-9a-f]{64}) -->\n/;
|
|
12
|
+
const digest = (s) => `sha256:${createHash("sha256").update(s, "utf8").digest("hex")}`;
|
|
13
|
+
function withHeader(body) {
|
|
14
|
+
return `<!-- Generated by sdd-cli structure render from openspec/structure/ (M-ADR 0230). Do not edit: change the model through a change's structural deltas. body ${digest(body)} -->\n${body}`;
|
|
15
|
+
}
|
|
16
|
+
const elements = (view, catalogue) => [...(view.get(catalogue)?.values() ?? [])].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
17
|
+
const col = (e, c) => (e.columns[c] === undefined ? "" : String(e.columns[c]));
|
|
18
|
+
const nameOf = (e) => col(e, "name") || e.id;
|
|
19
|
+
/** A string inside a Mermaid quoted argument: one line, no double quote. */
|
|
20
|
+
const q = (s) => s.replace(/\s+/g, " ").replace(/"/g, "'").trim();
|
|
21
|
+
/** A cell of a Markdown table. */
|
|
22
|
+
const td = (s) => s.replace(/\s+/g, " ").replace(/\|/g, "\\|").trim();
|
|
23
|
+
/** Mermaid aliases for ids, which may hold characters an alias cannot; a clash after cleaning takes a suffix. */
|
|
24
|
+
function aliases() {
|
|
25
|
+
const taken = new Map();
|
|
26
|
+
const used = new Set();
|
|
27
|
+
return (id) => {
|
|
28
|
+
const known = taken.get(id);
|
|
29
|
+
if (known)
|
|
30
|
+
return known;
|
|
31
|
+
const base = id.replace(/[^A-Za-z0-9_]/g, "_").replace(/^(?=[0-9])/, "_") || "_";
|
|
32
|
+
let alias = base;
|
|
33
|
+
for (let n = 2; used.has(alias); n++)
|
|
34
|
+
alias = `${base}_${n}`;
|
|
35
|
+
used.add(alias);
|
|
36
|
+
taken.set(id, alias);
|
|
37
|
+
return alias;
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
function c4Context(view) {
|
|
41
|
+
const a = aliases();
|
|
42
|
+
const links = elements(view, "persona_c4system");
|
|
43
|
+
const personas = new Set(links.map((l) => col(l, "persona_id")));
|
|
44
|
+
const lines = ["# System context", "", "```mermaid", "C4Context", " title System context"];
|
|
45
|
+
for (const p of elements(view, "personas"))
|
|
46
|
+
if (personas.has(p.id))
|
|
47
|
+
lines.push(` Person(${a(p.id)}, "${q(nameOf(p))}", "${q(col(p, "role"))}")`);
|
|
48
|
+
for (const s of elements(view, "c4_systems"))
|
|
49
|
+
lines.push(` System(${a(s.id)}, "${q(nameOf(s))}", "${q(col(s, "description"))}")`);
|
|
50
|
+
for (const x of elements(view, "c4_external_systems"))
|
|
51
|
+
lines.push(` System_Ext(${a(x.id)}, "${q(nameOf(x))}", "${q(col(x, "description"))}")`);
|
|
52
|
+
for (const l of links)
|
|
53
|
+
lines.push(` Rel(${a(col(l, "persona_id"))}, ${a(col(l, "c4_system_id"))}, "${q(col(l, "interaction"))}")`);
|
|
54
|
+
for (const l of elements(view, "system_external_dependency"))
|
|
55
|
+
lines.push(` Rel(${a(col(l, "c4_system_id"))}, ${a(col(l, "external_system_id"))}, "${q(col(l, "interaction"))}")`);
|
|
56
|
+
lines.push("```", "");
|
|
57
|
+
return lines.join("\n");
|
|
58
|
+
}
|
|
59
|
+
function c4Containers(view) {
|
|
60
|
+
const containers = elements(view, "c4_containers");
|
|
61
|
+
const systemOf = new Map(containers.map((c) => [c.id, col(c, "system_id")]));
|
|
62
|
+
const deps = elements(view, "container_dependency");
|
|
63
|
+
const lines = ["# Containers", ""];
|
|
64
|
+
for (const s of elements(view, "c4_systems")) {
|
|
65
|
+
const a = aliases();
|
|
66
|
+
const inside = containers.filter((c) => col(c, "system_id") === s.id);
|
|
67
|
+
lines.push(`## ${nameOf(s)} (\`${s.id}\`)`, "", "```mermaid", "C4Container", ` title Containers of ${q(nameOf(s))}`, ` System_Boundary(${a(s.id)}, "${q(nameOf(s))}") {`);
|
|
68
|
+
for (const c of inside)
|
|
69
|
+
lines.push(` Container(${a(c.id)}, "${q(nameOf(c))}", "${q(col(c, "technology"))}", "${q(col(c, "description"))}")`);
|
|
70
|
+
lines.push(" }");
|
|
71
|
+
const outbound = deps.filter((d) => systemOf.get(col(d, "from_container_id")) === s.id);
|
|
72
|
+
const outside = [...new Set(outbound.map((d) => col(d, "to_container_id")).filter((t) => systemOf.get(t) !== s.id))].sort();
|
|
73
|
+
for (const t of outside) {
|
|
74
|
+
const c = view.get("c4_containers")?.get(t);
|
|
75
|
+
lines.push(` Container_Ext(${a(t)}, "${q(c ? nameOf(c) : t)}", "${q(c ? col(c, "technology") : "")}", "")`);
|
|
76
|
+
}
|
|
77
|
+
for (const d of outbound)
|
|
78
|
+
lines.push(` Rel(${a(col(d, "from_container_id"))}, ${a(col(d, "to_container_id"))}, "${q(col(d, "interaction"))}")`);
|
|
79
|
+
lines.push("```", "");
|
|
80
|
+
}
|
|
81
|
+
return lines.join("\n");
|
|
82
|
+
}
|
|
83
|
+
function services(view) {
|
|
84
|
+
const lines = [
|
|
85
|
+
"# Services",
|
|
86
|
+
"",
|
|
87
|
+
"| Service | Name | Domain | Primary repository | Runtime | Language | Component type | Status |",
|
|
88
|
+
"| --- | --- | --- | --- | --- | --- | --- | --- |",
|
|
89
|
+
];
|
|
90
|
+
for (const s of elements(view, "services")) {
|
|
91
|
+
const cells = [`\`${s.id}\``, nameOf(s), col(s, "domain_id"), col(s, "primary_repo_id"), col(s, "runtime_type"), col(s, "language"), col(s, "component_type"), col(s, "status")];
|
|
92
|
+
lines.push(`| ${cells.map(td).join(" | ")} |`);
|
|
93
|
+
}
|
|
94
|
+
lines.push("");
|
|
95
|
+
return lines.join("\n");
|
|
96
|
+
}
|
|
97
|
+
function featureTree(view) {
|
|
98
|
+
const caps = elements(view, "capabilities");
|
|
99
|
+
const groups = elements(view, "feature_groups");
|
|
100
|
+
const features = elements(view, "features");
|
|
101
|
+
const linked = new Map();
|
|
102
|
+
for (const l of elements(view, "capability_feature")) {
|
|
103
|
+
const set = linked.get(col(l, "capability_id")) ?? new Set();
|
|
104
|
+
set.add(col(l, "feature_id"));
|
|
105
|
+
linked.set(col(l, "capability_id"), set);
|
|
106
|
+
}
|
|
107
|
+
const placed = new Set();
|
|
108
|
+
const lines = ["# Feature tree", ""];
|
|
109
|
+
const feature = (f, depth) => {
|
|
110
|
+
placed.add(f.id);
|
|
111
|
+
const facts = [col(f, "kind"), col(f, "status")].filter((x) => x !== "").join(", ");
|
|
112
|
+
lines.push(`${" ".repeat(depth)}- ${td(nameOf(f))} (\`${f.id}\`)${facts ? `: ${facts}` : ""}`);
|
|
113
|
+
for (const c of features)
|
|
114
|
+
if (col(c, "parent_feature_id") === f.id && !placed.has(c.id))
|
|
115
|
+
feature(c, depth + 1);
|
|
116
|
+
};
|
|
117
|
+
const group = (g, depth) => {
|
|
118
|
+
lines.push(`${" ".repeat(depth)}- **${td(nameOf(g))}** (\`${g.id}\`)`);
|
|
119
|
+
for (const c of groups)
|
|
120
|
+
if (col(c, "parent_group_id") === g.id)
|
|
121
|
+
group(c, depth + 1);
|
|
122
|
+
for (const f of features)
|
|
123
|
+
if (col(f, "parent_group_id") === g.id && col(f, "parent_feature_id") === "" && !placed.has(f.id))
|
|
124
|
+
feature(f, depth + 1);
|
|
125
|
+
};
|
|
126
|
+
const isRoot = (f) => col(f, "parent_group_id") === "" && col(f, "parent_feature_id") === "";
|
|
127
|
+
const capability = (c, depth) => {
|
|
128
|
+
lines.push(`${"#".repeat(Math.min(depth + 2, 6))} ${nameOf(c)} (\`${c.id}\`)`, "");
|
|
129
|
+
const before = lines.length;
|
|
130
|
+
for (const g of groups)
|
|
131
|
+
if (col(g, "capability_id") === c.id && col(g, "parent_group_id") === "")
|
|
132
|
+
group(g, 0);
|
|
133
|
+
for (const f of features)
|
|
134
|
+
if (isRoot(f) && !placed.has(f.id) && (col(f, "capability_id") === c.id || linked.get(c.id)?.has(f.id)))
|
|
135
|
+
feature(f, 0);
|
|
136
|
+
if (lines.length > before)
|
|
137
|
+
lines.push("");
|
|
138
|
+
for (const child of caps)
|
|
139
|
+
if (col(child, "parent_capability_id") === c.id)
|
|
140
|
+
capability(child, depth + 1);
|
|
141
|
+
};
|
|
142
|
+
for (const c of caps)
|
|
143
|
+
if (col(c, "parent_capability_id") === "" || !view.get("capabilities")?.has(col(c, "parent_capability_id")))
|
|
144
|
+
capability(c, 0);
|
|
145
|
+
const rest = features.filter((f) => !placed.has(f.id));
|
|
146
|
+
const loose = groups.filter((g) => col(g, "capability_id") === "" && col(g, "parent_group_id") === "");
|
|
147
|
+
if (loose.length > 0 || rest.length > 0) {
|
|
148
|
+
lines.push("## Not under a capability", "");
|
|
149
|
+
for (const g of loose)
|
|
150
|
+
group(g, 0);
|
|
151
|
+
for (const f of features)
|
|
152
|
+
if (!placed.has(f.id) && isRoot(f))
|
|
153
|
+
feature(f, 0);
|
|
154
|
+
for (const f of features)
|
|
155
|
+
if (!placed.has(f.id))
|
|
156
|
+
feature(f, 0);
|
|
157
|
+
lines.push("");
|
|
158
|
+
}
|
|
159
|
+
return lines.join("\n");
|
|
160
|
+
}
|
|
161
|
+
/** Mermaid's end markers for relationship_end_cardinality, written at the from end and at the to end. */
|
|
162
|
+
const FROM_END = { "exactly-one": "||", "zero-or-one": "|o", "one-or-more": "}|", "zero-or-more": "}o" };
|
|
163
|
+
const TO_END = { "exactly-one": "||", "zero-or-one": "o|", "one-or-more": "|{", "zero-or-more": "o{" };
|
|
164
|
+
function dataModel(view) {
|
|
165
|
+
const entities = elements(view, "entities");
|
|
166
|
+
const attributes = elements(view, "entity_attributes");
|
|
167
|
+
const relationships = elements(view, "entity_relationships");
|
|
168
|
+
const invariants = elements(view, "domain_invariants");
|
|
169
|
+
const word = (s) => s.replace(/[^A-Za-z0-9_]/g, "_") || "_";
|
|
170
|
+
const sections = elements(view, "domains").map((d) => [`${nameOf(d)} (\`${d.id}\`)`, entities.filter((e) => col(e, "domain_id") === d.id)]);
|
|
171
|
+
const domains = new Set(view.get("domains")?.keys() ?? []);
|
|
172
|
+
sections.push(["Entities without a domain", entities.filter((e) => !domains.has(col(e, "domain_id")))]);
|
|
173
|
+
const lines = ["# Data model", ""];
|
|
174
|
+
for (const [title, members] of sections) {
|
|
175
|
+
if (members.length === 0)
|
|
176
|
+
continue;
|
|
177
|
+
const a = aliases();
|
|
178
|
+
const ids = new Set(members.map((e) => e.id));
|
|
179
|
+
lines.push(`## ${title}`, "", "```mermaid", "erDiagram");
|
|
180
|
+
for (const e of members) {
|
|
181
|
+
const attrs = attributes.filter((x) => col(x, "entity_id") === e.id);
|
|
182
|
+
if (attrs.length === 0) {
|
|
183
|
+
lines.push(` ${a(e.id)}["${q(nameOf(e))}"]`);
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
lines.push(` ${a(e.id)}["${q(nameOf(e))}"] {`);
|
|
187
|
+
for (const x of attrs)
|
|
188
|
+
lines.push(` ${word(col(x, "type"))} ${word(col(x, "name"))}`);
|
|
189
|
+
lines.push(" }");
|
|
190
|
+
}
|
|
191
|
+
for (const r of relationships) {
|
|
192
|
+
if (!ids.has(col(r, "from_entity_id")))
|
|
193
|
+
continue;
|
|
194
|
+
const ends = `${FROM_END[col(r, "from_cardinality")] ?? "}o"}--${TO_END[col(r, "to_cardinality")] ?? "o{"}`;
|
|
195
|
+
lines.push(` ${a(col(r, "from_entity_id"))} ${ends} ${a(col(r, "to_entity_id"))} : "${q(nameOf(r))}"`);
|
|
196
|
+
}
|
|
197
|
+
lines.push("```", "");
|
|
198
|
+
const rules = invariants.filter((i) => ids.has(col(i, "entity_id")));
|
|
199
|
+
if (rules.length > 0) {
|
|
200
|
+
lines.push("Invariants:", "");
|
|
201
|
+
for (const i of rules)
|
|
202
|
+
lines.push(`- \`${i.id}\` (${col(i, "kind")}) on \`${col(i, "entity_id")}\`: ${td(col(i, "statement"))}`);
|
|
203
|
+
lines.push("");
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return lines.join("\n");
|
|
207
|
+
}
|
|
208
|
+
/** Every generated view of the current view, by path relative to the SDD. */
|
|
209
|
+
export function renderViews(view) {
|
|
210
|
+
return new Map([
|
|
211
|
+
[VIEW_PATHS.c4Context, withHeader(c4Context(view))],
|
|
212
|
+
[VIEW_PATHS.c4Containers, withHeader(c4Containers(view))],
|
|
213
|
+
[VIEW_PATHS.services, withHeader(services(view))],
|
|
214
|
+
[VIEW_PATHS.featureTree, withHeader(featureTree(view))],
|
|
215
|
+
[VIEW_PATHS.dataModel, withHeader(dataModel(view))],
|
|
216
|
+
]);
|
|
217
|
+
}
|
|
218
|
+
/** A view file's state against its render. `text` is the file's content, or null when it is absent. */
|
|
219
|
+
export function viewState(rendered, text) {
|
|
220
|
+
if (text === null)
|
|
221
|
+
return "missing";
|
|
222
|
+
if (text === rendered)
|
|
223
|
+
return "current";
|
|
224
|
+
const m = HEADER.exec(text);
|
|
225
|
+
if (m === null || digest(text.slice(m[0].length)) !== m[1])
|
|
226
|
+
return "hand-edited";
|
|
227
|
+
return "stale";
|
|
228
|
+
}
|
|
229
|
+
/** One line per view that is not current. */
|
|
230
|
+
export function describeState(path, state) {
|
|
231
|
+
switch (state) {
|
|
232
|
+
case "missing":
|
|
233
|
+
return `${path}: missing; run \`sdd-cli structure render\``;
|
|
234
|
+
case "stale":
|
|
235
|
+
return `${path}: stale, rendered from an earlier model; run \`sdd-cli structure render\``;
|
|
236
|
+
case "hand-edited":
|
|
237
|
+
return `${path}: edited by hand since it was generated; a generated view is never edited. Make the change in the model, through a change's structural deltas, and render again`;
|
|
238
|
+
default:
|
|
239
|
+
return `${path}: current`;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
//# sourceMappingURL=render.js.map
|
|
@@ -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
|