@sdd-method/maintain-cli 0.11.0 → 0.13.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/aggregate.js +64 -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/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,316 @@
|
|
|
1
|
+
// A domain model's content, parsed whole (M-ADR 0230 §4.3, §4.11 proof 7, Proposed; schema 6.0.0). One parser per
|
|
2
|
+
// notation, shared by every reader of the file: the entities builder takes its entities from here, and this module
|
|
3
|
+
// builds entity_attributes, entity_relationships and domain_invariants from the same parse.
|
|
4
|
+
//
|
|
5
|
+
// Mermaid ER (`data-model.mermaid.md`, every ```mermaid fence opening with erDiagram):
|
|
6
|
+
// ENTITY { an entity block; ENTITY { type a; type b } on one line is one too
|
|
7
|
+
// type name [PK|FK|UK|PK_FK[, ...]] ["comment"]
|
|
8
|
+
// }
|
|
9
|
+
// A <end>--<end> B : label a relationship; each end marker || |o o| }| |{ }o o{ is one of four cardinalities;
|
|
10
|
+
// `..` (non-identifying) is read as `--`, and reported
|
|
11
|
+
// TypeQL, in the documented subset (M-ADR 0210 §4.2, `data-model.tql`), through the fail-closed parseTypes:
|
|
12
|
+
// an entity's owned attributes (@key, @card), and each binary relation as a relationship between its two roles'
|
|
13
|
+
// players, roles kept. The subset admits no participation cardinality, so both ends are zero-or-more, and a role's
|
|
14
|
+
// own @card is a cardinality invariant on the relationship.
|
|
15
|
+
// The keys become invariants: a primary key (PK, @key) is one unique invariant over its attributes, which are not
|
|
16
|
+
// nullable; a UK is a unique invariant over its attribute. What a parser reads and cannot hold is an `extraction`
|
|
17
|
+
// finding, never a silent skip.
|
|
18
|
+
import { readFile } from "node:fs/promises";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { ParseError, parseTypes } from "../../metamodel/tql-subset-parser.js";
|
|
21
|
+
import { entitySlug } from "./entity-slug.js";
|
|
22
|
+
const RE_MERMAID_OPEN = /^```\s*mermaid\s*$/i;
|
|
23
|
+
const RE_FENCE_CLOSE = /^```\s*$/;
|
|
24
|
+
const RE_COMMENT = /^\s*%%/;
|
|
25
|
+
const RE_ENTITY_OPEN = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*\{\s*$/;
|
|
26
|
+
const RE_ENTITY_INLINE = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*\{(.*)\}\s*$/;
|
|
27
|
+
const RE_ATTRIBUTE = /^\s*([A-Za-z_][\w[\]~(),.<>-]*)\s+([A-Za-z_*][\w.-]*)\s*((?:PK_FK|PK|FK|UK)(?:\s*,\s*(?:PK_FK|PK|FK|UK))*)?\s*(?:"([^"]*)")?\s*$/;
|
|
28
|
+
const RE_RELATIONSHIP = /^\s*([A-Za-z_][\w-]*)\s+(\|\||\|o|o\||\}\||\}o)(--|\.\.)(\|\||o\||\|o|\|\{|o\{)\s+([A-Za-z_][\w-]*)\s*:\s*(.+?)\s*$/;
|
|
29
|
+
const RE_BARE_ENTITY = /^\s*[A-Za-z_][\w-]*\s*$/;
|
|
30
|
+
const MERMAID_KEYWORDS = new Set(["erdiagram", "subgraph", "classdef", "style", "class"]);
|
|
31
|
+
const END = {
|
|
32
|
+
"||": "exactly-one",
|
|
33
|
+
"|o": "zero-or-one",
|
|
34
|
+
"o|": "zero-or-one",
|
|
35
|
+
"}|": "one-or-more",
|
|
36
|
+
"|{": "one-or-more",
|
|
37
|
+
"}o": "zero-or-more",
|
|
38
|
+
"o{": "zero-or-more",
|
|
39
|
+
};
|
|
40
|
+
function mermaidAttribute(text) {
|
|
41
|
+
const m = RE_ATTRIBUTE.exec(text);
|
|
42
|
+
if (!m)
|
|
43
|
+
return null;
|
|
44
|
+
const keys = new Set((m[3] ?? "").split(",").map((k) => k.trim()).flatMap((k) => (k === "PK_FK" ? ["PK", "FK"] : k === "" ? [] : [k])));
|
|
45
|
+
const description = m[4] ?? "";
|
|
46
|
+
const primaryKey = keys.has("PK");
|
|
47
|
+
const nullable = primaryKey ? false : /^nullable\b/i.test(description.trim()) ? true : undefined;
|
|
48
|
+
return { name: m[2].replace(/^\*/, ""), type: m[1], primaryKey, unique: keys.has("UK"), foreignKey: keys.has("FK"), nullable, description };
|
|
49
|
+
}
|
|
50
|
+
/** Parse every erDiagram fence of a Mermaid data-model file. `where` locates what is reported. */
|
|
51
|
+
export function parseMermaidDataModel(text, where) {
|
|
52
|
+
const lines = text.split(/\r?\n/);
|
|
53
|
+
const entities = [];
|
|
54
|
+
const relationships = [];
|
|
55
|
+
const unread = [];
|
|
56
|
+
const seen = new Set();
|
|
57
|
+
let inMermaid = false;
|
|
58
|
+
let isEr = false;
|
|
59
|
+
let firstKeywordSeen = false;
|
|
60
|
+
let open = null;
|
|
61
|
+
const close = (e) => {
|
|
62
|
+
const slug = entitySlug(e.name);
|
|
63
|
+
if (seen.has(slug))
|
|
64
|
+
return; // a later block naming the entity again: the first declaration stands
|
|
65
|
+
seen.add(slug);
|
|
66
|
+
entities.push(e);
|
|
67
|
+
};
|
|
68
|
+
for (let i = 0; i < lines.length; i++) {
|
|
69
|
+
const raw = lines[i] ?? "";
|
|
70
|
+
const at = `${where}:${i + 1}`;
|
|
71
|
+
if (!inMermaid) {
|
|
72
|
+
if (RE_MERMAID_OPEN.test(raw)) {
|
|
73
|
+
inMermaid = true;
|
|
74
|
+
isEr = false;
|
|
75
|
+
firstKeywordSeen = false;
|
|
76
|
+
}
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (RE_FENCE_CLOSE.test(raw)) {
|
|
80
|
+
inMermaid = false;
|
|
81
|
+
open = null; // a block the fence cut off is dropped, as the entities builder always has
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
const stripped = raw.trim();
|
|
85
|
+
if (!firstKeywordSeen && stripped !== "" && !RE_COMMENT.test(raw)) {
|
|
86
|
+
firstKeywordSeen = true;
|
|
87
|
+
isEr = stripped.toLowerCase().startsWith("erdiagram");
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (!isEr || stripped === "" || RE_COMMENT.test(raw))
|
|
91
|
+
continue;
|
|
92
|
+
if (open) {
|
|
93
|
+
if (stripped.startsWith("}")) {
|
|
94
|
+
close(open);
|
|
95
|
+
open = null;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
open.columnCount++;
|
|
99
|
+
const a = mermaidAttribute(raw);
|
|
100
|
+
if (a)
|
|
101
|
+
open.attributes.push(a);
|
|
102
|
+
else
|
|
103
|
+
unread.push(`${at}: an attribute line of ${open.name} not understood: ${JSON.stringify(stripped)}`);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const rel = RE_RELATIONSHIP.exec(raw);
|
|
107
|
+
if (rel) {
|
|
108
|
+
if (rel[3] === "..")
|
|
109
|
+
unread.push(`${at}: a non-identifying relationship (..), read as identifying: ${JSON.stringify(stripped)}`);
|
|
110
|
+
relationships.push({
|
|
111
|
+
from: rel[1],
|
|
112
|
+
to: rel[5],
|
|
113
|
+
name: rel[6].replace(/^"(.*)"$/, "$1").trim(),
|
|
114
|
+
fromCardinality: END[rel[2]],
|
|
115
|
+
toCardinality: END[rel[4]],
|
|
116
|
+
fromRole: "",
|
|
117
|
+
toRole: "",
|
|
118
|
+
roleCards: [],
|
|
119
|
+
});
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const inline = RE_ENTITY_INLINE.exec(raw);
|
|
123
|
+
if (inline && !MERMAID_KEYWORDS.has(inline[1].toLowerCase())) {
|
|
124
|
+
const parts = inline[2].split(";").map((p) => p.trim()).filter((p) => p !== "");
|
|
125
|
+
const attributes = [];
|
|
126
|
+
for (const p of parts) {
|
|
127
|
+
const a = mermaidAttribute(p);
|
|
128
|
+
if (a)
|
|
129
|
+
attributes.push(a);
|
|
130
|
+
else
|
|
131
|
+
unread.push(`${at}: an attribute of ${inline[1]} not understood: ${JSON.stringify(p)}`);
|
|
132
|
+
}
|
|
133
|
+
close({ name: inline[1], attributes, columnCount: parts.length });
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
const m = RE_ENTITY_OPEN.exec(raw);
|
|
137
|
+
if (m && !MERMAID_KEYWORDS.has(m[1].toLowerCase())) {
|
|
138
|
+
open = { name: m[1], attributes: [], columnCount: 0 };
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (RE_BARE_ENTITY.test(raw) && !MERMAID_KEYWORDS.has(stripped.toLowerCase())) {
|
|
142
|
+
unread.push(`${at}: an entity named without a block (${stripped}); it is catalogued only where a block declares it`);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
unread.push(`${at}: a line not understood: ${JSON.stringify(stripped)}`);
|
|
146
|
+
}
|
|
147
|
+
return { entities, relationships, unread, unclosed: open?.name ?? null };
|
|
148
|
+
}
|
|
149
|
+
/** Parse a TypeQL data model in the documented subset. A file outside the subset is refused whole, located. */
|
|
150
|
+
export function parseTypeqlDataModel(text, where) {
|
|
151
|
+
let t;
|
|
152
|
+
try {
|
|
153
|
+
t = parseTypes(text, where, { singlePlayer: false });
|
|
154
|
+
}
|
|
155
|
+
catch (e) {
|
|
156
|
+
if (e instanceof ParseError)
|
|
157
|
+
return { refused: e.message };
|
|
158
|
+
throw e;
|
|
159
|
+
}
|
|
160
|
+
const unread = [];
|
|
161
|
+
const entities = [];
|
|
162
|
+
for (const [label, d] of t.entities) {
|
|
163
|
+
const attributes = [];
|
|
164
|
+
for (const [attr, ann] of d.owns) {
|
|
165
|
+
if (ann.multi)
|
|
166
|
+
unread.push(`${where}: ${label} owns ${attr} @card(0..), a multi-valued attribute; the model holds one value per attribute`);
|
|
167
|
+
attributes.push({
|
|
168
|
+
name: attr,
|
|
169
|
+
type: t.attrs.get(attr) ?? "",
|
|
170
|
+
primaryKey: ann.key,
|
|
171
|
+
unique: false,
|
|
172
|
+
foreignKey: false,
|
|
173
|
+
// TypeDB's default for an ownership is @card(0..1)
|
|
174
|
+
nullable: ann.key || ann.required ? false : true,
|
|
175
|
+
description: "",
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
entities.push({ name: label, attributes, columnCount: attributes.length });
|
|
179
|
+
}
|
|
180
|
+
const relationships = [];
|
|
181
|
+
for (const [label, d] of t.relations) {
|
|
182
|
+
const roles = [...d.relates.keys()];
|
|
183
|
+
if (d.owns.size > 0)
|
|
184
|
+
unread.push(`${where}: relation ${label} owns attributes (${[...d.owns.keys()].join(", ")}); the model holds no attributes on a relationship`);
|
|
185
|
+
if (roles.length !== 2) {
|
|
186
|
+
unread.push(`${where}: relation ${label} relates ${roles.length} role(s); the model holds binary relationships only`);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
const [a, b] = roles;
|
|
190
|
+
const playersOf = (role) => (t.playersAll.get(`${label}:${role}`) ?? []).filter((p) => {
|
|
191
|
+
if (t.entities.has(p))
|
|
192
|
+
return true;
|
|
193
|
+
unread.push(`${where}: relation ${p} plays ${label}:${role}; the model relates entities only`);
|
|
194
|
+
return false;
|
|
195
|
+
});
|
|
196
|
+
const cards = [...d.relates].flatMap(([role, ann]) => ann.required ? [`each ${label} relates exactly one ${role}`] : ann.optional ? [`each ${label} relates at most one ${role}`] : []);
|
|
197
|
+
for (const from of playersOf(a)) {
|
|
198
|
+
for (const to of playersOf(b)) {
|
|
199
|
+
relationships.push({ from, to, name: label, fromCardinality: "zero-or-more", toCardinality: "zero-or-more", fromRole: a, toRole: b, roleCards: cards });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return { entities, relationships, unread, unclosed: null };
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Read and parse each domain model a domain_models row names. A missing file contributes nothing (the domain-models
|
|
207
|
+
* builder reports it); a TypeQL file outside the subset contributes nothing (the entities builder reports it).
|
|
208
|
+
*/
|
|
209
|
+
export async function readDataModelSources(sddPath, models) {
|
|
210
|
+
const out = [];
|
|
211
|
+
for (const m of models) {
|
|
212
|
+
const tql = m.docPath.endsWith(".tql");
|
|
213
|
+
if (!tql && !m.docPath.endsWith(".mermaid.md"))
|
|
214
|
+
continue;
|
|
215
|
+
const text = await readFile(m.docPath.startsWith("/") ? m.docPath : join(sddPath, m.docPath), "utf8").catch(() => null);
|
|
216
|
+
if (text === null)
|
|
217
|
+
continue;
|
|
218
|
+
const parsed = tql ? parseTypeqlDataModel(text, m.docPath) : parseMermaidDataModel(text, m.docPath);
|
|
219
|
+
if ("refused" in parsed)
|
|
220
|
+
continue; // the entities builder reports the refusal
|
|
221
|
+
out.push({ ...m, parsed });
|
|
222
|
+
}
|
|
223
|
+
return out;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* The attribute, relationship and invariant rows of every parsed model, against the entities catalogue as built. An
|
|
227
|
+
* entity this build did not catalogue contributes nothing; a relationship names an entity of its own domain, else the
|
|
228
|
+
* one entity of that name in another, else it is reported.
|
|
229
|
+
*/
|
|
230
|
+
export function buildDataModelElements(sources, entities, findings) {
|
|
231
|
+
const attributes = [];
|
|
232
|
+
const relationships = [];
|
|
233
|
+
const invariants = [];
|
|
234
|
+
const entityIds = new Set(entities.map((e) => String(e["id"])));
|
|
235
|
+
const bySlug = new Map();
|
|
236
|
+
for (const e of entities) {
|
|
237
|
+
const id = String(e["id"]);
|
|
238
|
+
const domain = String(e["domain_id"] ?? "");
|
|
239
|
+
if (domain === "" || !id.startsWith(`${domain}-`))
|
|
240
|
+
continue;
|
|
241
|
+
const slug = id.slice(domain.length + 1);
|
|
242
|
+
bySlug.set(slug, [...(bySlug.get(slug) ?? []), id]);
|
|
243
|
+
}
|
|
244
|
+
const resolve = (domainId, name) => {
|
|
245
|
+
const own = `${domainId}-${entitySlug(name)}`;
|
|
246
|
+
if (entityIds.has(own))
|
|
247
|
+
return own;
|
|
248
|
+
const elsewhere = bySlug.get(entitySlug(name)) ?? [];
|
|
249
|
+
return elsewhere.length === 1 ? elsewhere[0] : null;
|
|
250
|
+
};
|
|
251
|
+
const seenAttr = new Set();
|
|
252
|
+
const seenRel = new Set();
|
|
253
|
+
for (const s of sources) {
|
|
254
|
+
for (const u of s.parsed.unread)
|
|
255
|
+
findings.add("extraction", u, "warn", s.docPath);
|
|
256
|
+
for (const e of s.parsed.entities) {
|
|
257
|
+
const entityId = `${s.domainId}-${entitySlug(e.name)}`;
|
|
258
|
+
if (!entityIds.has(entityId))
|
|
259
|
+
continue;
|
|
260
|
+
const pk = [];
|
|
261
|
+
for (const a of e.attributes) {
|
|
262
|
+
const id = `${entityId}__${entitySlug(a.name) || a.name.toLowerCase()}`;
|
|
263
|
+
if (seenAttr.has(id)) {
|
|
264
|
+
findings.add("extraction", `${s.docPath}: ${e.name}.${a.name} is stated twice; the first stands`, "warn", s.docPath);
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
seenAttr.add(id);
|
|
268
|
+
attributes.push({
|
|
269
|
+
id,
|
|
270
|
+
entity_id: entityId,
|
|
271
|
+
name: a.name,
|
|
272
|
+
type: a.type,
|
|
273
|
+
nullable: a.nullable,
|
|
274
|
+
foreign_key: a.foreignKey ? true : undefined,
|
|
275
|
+
description: a.description,
|
|
276
|
+
});
|
|
277
|
+
if (a.primaryKey)
|
|
278
|
+
pk.push(id);
|
|
279
|
+
if (a.unique)
|
|
280
|
+
invariants.push({ id: `${id}__unique`, entity_id: entityId, kind: "unique", attribute_ids: id, statement: `${a.name} is unique among ${e.name} (UK)` });
|
|
281
|
+
}
|
|
282
|
+
if (pk.length > 0) {
|
|
283
|
+
const names = e.attributes.filter((a) => a.primaryKey).map((a) => a.name);
|
|
284
|
+
invariants.push({ id: `${entityId}__primary-key`, entity_id: entityId, kind: "unique", attribute_ids: pk.join(";"), statement: `${names.join(" and ")} identif${names.length === 1 ? "ies" : "y"} each ${e.name} (primary key)` });
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
for (const r of s.parsed.relationships) {
|
|
288
|
+
const from = resolve(s.domainId, r.from);
|
|
289
|
+
const to = resolve(s.domainId, r.to);
|
|
290
|
+
if (from === null || to === null) {
|
|
291
|
+
const missing = [from === null ? r.from : null, to === null ? r.to : null].filter((x) => x !== null).join(" and ");
|
|
292
|
+
findings.add("extraction", `${s.docPath}: the relationship ${r.from} ${r.name} ${r.to} names ${missing}, which no catalogued entity matches (in its domain, or once elsewhere); not catalogued`, "warn", s.docPath);
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
const id = `${from}__${entitySlug(r.name) || "related"}__${to}`;
|
|
296
|
+
if (seenRel.has(id)) {
|
|
297
|
+
findings.add("extraction", `${s.docPath}: the relationship ${r.from} ${r.name} ${r.to} is stated twice; the first stands`, "warn", s.docPath);
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
seenRel.add(id);
|
|
301
|
+
relationships.push({
|
|
302
|
+
id,
|
|
303
|
+
from_entity_id: from,
|
|
304
|
+
to_entity_id: to,
|
|
305
|
+
name: r.name,
|
|
306
|
+
from_cardinality: r.fromCardinality,
|
|
307
|
+
to_cardinality: r.toCardinality,
|
|
308
|
+
from_role: r.fromRole,
|
|
309
|
+
to_role: r.toRole,
|
|
310
|
+
});
|
|
311
|
+
r.roleCards.forEach((statement, i) => invariants.push({ id: `${id}__cardinality-${i + 1}`, entity_id: from, kind: "cardinality", relationship_id: id, statement }));
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return { entityAttributes: attributes, entityRelationships: relationships, domainInvariants: invariants };
|
|
315
|
+
}
|
|
316
|
+
//# sourceMappingURL=data-model.js.map
|
|
@@ -14,20 +14,19 @@
|
|
|
14
14
|
// - Files may mix `erDiagram` with other Mermaid diagram types (flowchart,
|
|
15
15
|
// sequenceDiagram). Each fenced block is independently checked for the
|
|
16
16
|
// erDiagram opener; non-ER blocks are skipped.
|
|
17
|
+
// The file is read by the data-model parser (data-model.ts, schema 6.0.0), the
|
|
18
|
+
// one parser every reader of a domain model shares; an entity on one line
|
|
19
|
+
// (`NAME { type a; type b }`) is an entity too, and a `data-model.tql` in the
|
|
20
|
+
// documented TypeQL subset gives its entities the same way.
|
|
17
21
|
import { readFile } from "node:fs/promises";
|
|
22
|
+
import { parseMermaidDataModel, parseTypeqlDataModel } from "./data-model.js";
|
|
18
23
|
import { entitySlug } from "./entity-slug.js";
|
|
19
24
|
import { extractFileEvidenceClass } from "./evidence-class.js";
|
|
20
|
-
const RE_MERMAID_OPEN = /^```\s*mermaid\s*$/i;
|
|
21
|
-
const RE_FENCE_CLOSE = /^```\s*$/;
|
|
22
|
-
const RE_COMMENT = /^\s*%%/;
|
|
23
|
-
const RE_ENTITY_OPEN = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*\{\s*$/;
|
|
24
|
-
const RE_REL_LINE = /^\s*[A-Za-z_][A-Za-z0-9_]*\s+\|?\|?[o|\-]+\{?\s+[A-Za-z]/;
|
|
25
|
-
const MERMAID_KEYWORDS = new Set(["erdiagram", "subgraph", "classdef", "style", "class"]);
|
|
26
25
|
export async function buildEntitiesFromMermaid(inputs) {
|
|
27
26
|
const rows = [];
|
|
28
27
|
const seenAcrossFiles = new Map(); // id → originating doc_path
|
|
29
28
|
for (const dm of inputs.domainModels) {
|
|
30
|
-
if (!dm.doc_path.endsWith(".mermaid.md"))
|
|
29
|
+
if (!dm.doc_path.endsWith(".mermaid.md") && !dm.doc_path.endsWith(".tql"))
|
|
31
30
|
continue;
|
|
32
31
|
const docPath = dm.doc_path.startsWith("/")
|
|
33
32
|
? dm.doc_path
|
|
@@ -74,87 +73,21 @@ export async function buildEntitiesFromMermaid(inputs) {
|
|
|
74
73
|
return rows;
|
|
75
74
|
}
|
|
76
75
|
function parseFile(text, dm, findings) {
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
let isEr = false;
|
|
82
|
-
let firstKeywordSeen = false;
|
|
83
|
-
let inEntity = false;
|
|
84
|
-
let entityName = null;
|
|
85
|
-
let entityColumns = 0;
|
|
86
|
-
for (let i = 0; i < lines.length; i++) {
|
|
87
|
-
const raw = lines[i] ?? "";
|
|
88
|
-
if (!inMermaid) {
|
|
89
|
-
if (RE_MERMAID_OPEN.test(raw)) {
|
|
90
|
-
inMermaid = true;
|
|
91
|
-
isEr = false;
|
|
92
|
-
firstKeywordSeen = false;
|
|
93
|
-
}
|
|
94
|
-
continue;
|
|
95
|
-
}
|
|
96
|
-
// in_mermaid == true
|
|
97
|
-
if (RE_FENCE_CLOSE.test(raw)) {
|
|
98
|
-
inMermaid = false;
|
|
99
|
-
isEr = false;
|
|
100
|
-
firstKeywordSeen = false;
|
|
101
|
-
// If we exit mid-entity, drop it silently — malformed block.
|
|
102
|
-
inEntity = false;
|
|
103
|
-
entityName = null;
|
|
104
|
-
continue;
|
|
105
|
-
}
|
|
106
|
-
const stripped = raw.trim();
|
|
107
|
-
if (!firstKeywordSeen && stripped !== "" && !RE_COMMENT.test(raw)) {
|
|
108
|
-
firstKeywordSeen = true;
|
|
109
|
-
isEr = stripped.toLowerCase().startsWith("erdiagram");
|
|
110
|
-
}
|
|
111
|
-
if (!isEr)
|
|
112
|
-
continue;
|
|
113
|
-
if (inEntity) {
|
|
114
|
-
if (stripped.startsWith("}")) {
|
|
115
|
-
if (entityName !== null) {
|
|
116
|
-
const slug = entitySlug(entityName);
|
|
117
|
-
if (!seenInFile.has(slug)) {
|
|
118
|
-
seenInFile.add(slug);
|
|
119
|
-
out.push({
|
|
120
|
-
name: entityName,
|
|
121
|
-
domainId: dm.domain_id,
|
|
122
|
-
sourceDomainModelId: dm.id,
|
|
123
|
-
columnCount: entityColumns,
|
|
124
|
-
});
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
inEntity = false;
|
|
128
|
-
entityName = null;
|
|
129
|
-
entityColumns = 0;
|
|
130
|
-
continue;
|
|
131
|
-
}
|
|
132
|
-
if (RE_COMMENT.test(raw))
|
|
133
|
-
continue;
|
|
134
|
-
if (stripped)
|
|
135
|
-
entityColumns++;
|
|
136
|
-
continue;
|
|
137
|
-
}
|
|
138
|
-
// Not in an entity block — look for an opener.
|
|
139
|
-
if (RE_COMMENT.test(raw))
|
|
140
|
-
continue;
|
|
141
|
-
if (RE_REL_LINE.test(raw))
|
|
142
|
-
continue;
|
|
143
|
-
const m = RE_ENTITY_OPEN.exec(raw);
|
|
144
|
-
if (m) {
|
|
145
|
-
const ident = m[1] ?? "";
|
|
146
|
-
if (MERMAID_KEYWORDS.has(ident.toLowerCase()))
|
|
147
|
-
continue;
|
|
148
|
-
entityName = ident;
|
|
149
|
-
inEntity = true;
|
|
150
|
-
entityColumns = 0;
|
|
151
|
-
}
|
|
76
|
+
const parsed = dm.doc_path.endsWith(".tql") ? parseTypeqlDataModel(text, dm.doc_path) : parseMermaidDataModel(text, dm.doc_path);
|
|
77
|
+
if ("refused" in parsed) {
|
|
78
|
+
findings.add("extraction", `entities: ${dm.doc_path} is outside the documented TypeQL subset, so none of its entities is catalogued: ${parsed.refused}`, "warn", dm.doc_path);
|
|
79
|
+
return [];
|
|
152
80
|
}
|
|
153
81
|
// Defensive: if a block ended without closing an entity, log it once.
|
|
154
|
-
if (
|
|
155
|
-
findings.add("convention", `entities-mermaid: ${dm.doc_path}: unclosed entity block for "${
|
|
82
|
+
if (parsed.unclosed !== null) {
|
|
83
|
+
findings.add("convention", `entities-mermaid: ${dm.doc_path}: unclosed entity block for "${parsed.unclosed}" — discarded`);
|
|
156
84
|
}
|
|
157
|
-
return
|
|
85
|
+
return parsed.entities.map((e) => ({
|
|
86
|
+
name: e.name,
|
|
87
|
+
domainId: dm.domain_id,
|
|
88
|
+
sourceDomainModelId: dm.id,
|
|
89
|
+
columnCount: e.columnCount,
|
|
90
|
+
}));
|
|
158
91
|
}
|
|
159
92
|
function resolveGlossaryTermBySlug(entityName, domainId, knownGlossaryIds) {
|
|
160
93
|
const slug = entitySlug(entityName);
|
|
@@ -89,9 +89,10 @@ export async function buildFeatures(inputs) {
|
|
|
89
89
|
parent_feature_id: "",
|
|
90
90
|
description,
|
|
91
91
|
maturity: "",
|
|
92
|
-
status:
|
|
92
|
+
status: featureStatus(text),
|
|
93
93
|
source: "feature-spec",
|
|
94
94
|
runway: "",
|
|
95
|
+
kind: featureKind(text),
|
|
95
96
|
source_sdd_id: "",
|
|
96
97
|
});
|
|
97
98
|
idByPath.set(relPath, id);
|
|
@@ -123,9 +124,10 @@ export async function buildFeatures(inputs) {
|
|
|
123
124
|
parent_feature_id: "",
|
|
124
125
|
description,
|
|
125
126
|
maturity: "",
|
|
126
|
-
status:
|
|
127
|
+
status: featureStatus(text),
|
|
127
128
|
source: "feature-spec",
|
|
128
129
|
runway: "",
|
|
130
|
+
kind: featureKind(text),
|
|
129
131
|
source_sdd_id: "",
|
|
130
132
|
});
|
|
131
133
|
idByPath.set(relPath, id);
|
|
@@ -145,6 +147,24 @@ export async function buildFeatures(inputs) {
|
|
|
145
147
|
}
|
|
146
148
|
return { rows, idByPath, idBySlug };
|
|
147
149
|
}
|
|
150
|
+
/**
|
|
151
|
+
* A feature spec's catalogue status: `retired` when the spec says `**Status:** retired` (its behaviour retired by
|
|
152
|
+
* an archived change, the feature kept in the catalogue so its retired requirements still resolve), else
|
|
153
|
+
* `active`. A retired feature stays where it is; it does not have to move to withdrawn/ first.
|
|
154
|
+
*/
|
|
155
|
+
export function featureStatus(text) {
|
|
156
|
+
const m = /^\*\*Status:\*\*\s*([A-Za-z-]+)/m.exec(text) ?? /^>.*\bStatus:\s*([A-Za-z-]+)/m.exec(text);
|
|
157
|
+
return m?.[1]?.toLowerCase() === "retired" ? "retired" : "active";
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* A feature spec's kind (schema 4.4.0): the value of its `**Kind:**` line, lower-cased, else `feature`. An
|
|
161
|
+
* obligation (M-ADR 0226, Proposed) and a journey feature (M-ADR 0229 §4.2, Proposed) say so; any other value is written as given, so
|
|
162
|
+
* the catalogue's vocabulary gate names the spec rather than a default hiding the mistake.
|
|
163
|
+
*/
|
|
164
|
+
export function featureKind(text) {
|
|
165
|
+
const m = /^\*\*Kind:\*\*\s*([A-Za-z-]+)/m.exec(text);
|
|
166
|
+
return m?.[1]?.toLowerCase() ?? "feature";
|
|
167
|
+
}
|
|
148
168
|
async function emitBulletRows(ctx) {
|
|
149
169
|
const featureTreePath = await findFeatureTreeFile(ctx.sddPath);
|
|
150
170
|
if (!featureTreePath)
|
|
@@ -188,6 +208,7 @@ async function emitBulletRows(ctx) {
|
|
|
188
208
|
status: "active",
|
|
189
209
|
source: "feature-tree",
|
|
190
210
|
runway: ev.runway,
|
|
211
|
+
kind: "feature",
|
|
191
212
|
source_sdd_id: "",
|
|
192
213
|
});
|
|
193
214
|
if (!ctx.idBySlug.has(ev.slug))
|
|
@@ -176,9 +176,8 @@ export async function buildIntegrationComponents(inputs) {
|
|
|
176
176
|
}
|
|
177
177
|
// SECOND HOME (§9.1): a full `integration-manifest.yaml` per component repo under
|
|
178
178
|
// `repos/<component>/`. Read after the array form so `seen` already holds the
|
|
179
|
-
// array-form ids
|
|
180
|
-
//
|
|
181
|
-
// independently and nothing stops them meeting.
|
|
179
|
+
// array-form ids: a component in both homes that agrees on every tier field is
|
|
180
|
+
// one declaration stated twice, and one that disagrees is a divergence (below).
|
|
182
181
|
rows.push(...(await readComponentManifests({
|
|
183
182
|
sddPath: inputs.sddPath,
|
|
184
183
|
findings: inputs.findings,
|
|
@@ -186,6 +185,7 @@ export async function buildIntegrationComponents(inputs) {
|
|
|
186
185
|
knownRepoIds: inputs.knownRepoIds,
|
|
187
186
|
knownServiceIds: inputs.knownServiceIds,
|
|
188
187
|
seen,
|
|
188
|
+
arrayRows: new Map(rows.map((r) => [String(r["id"]), r])),
|
|
189
189
|
})));
|
|
190
190
|
checkAggregate({
|
|
191
191
|
findings: inputs.findings,
|
|
@@ -196,6 +196,43 @@ export async function buildIntegrationComponents(inputs) {
|
|
|
196
196
|
});
|
|
197
197
|
return rows;
|
|
198
198
|
}
|
|
199
|
+
/** The fields the component tier reads, compared when a component is declared in both homes. */
|
|
200
|
+
const TIER_FIELDS = [
|
|
201
|
+
"component_type",
|
|
202
|
+
"direction",
|
|
203
|
+
"frequency",
|
|
204
|
+
"transport",
|
|
205
|
+
"format",
|
|
206
|
+
"auth",
|
|
207
|
+
"ingestion_pattern",
|
|
208
|
+
"invocation_pattern",
|
|
209
|
+
"pattern_qualifier",
|
|
210
|
+
"primary_entities",
|
|
211
|
+
];
|
|
212
|
+
function tierValue(m, f) {
|
|
213
|
+
switch (f) {
|
|
214
|
+
case "component_type":
|
|
215
|
+
return (m.componentType ?? "").trim();
|
|
216
|
+
case "direction":
|
|
217
|
+
return (m.dataFlow?.direction ?? "").trim();
|
|
218
|
+
case "frequency":
|
|
219
|
+
return (m.dataFlow?.frequency ?? "").trim();
|
|
220
|
+
case "transport":
|
|
221
|
+
return (m.protocol?.transport ?? "").trim();
|
|
222
|
+
case "format":
|
|
223
|
+
return (m.protocol?.format ?? "").trim();
|
|
224
|
+
case "auth":
|
|
225
|
+
return (m.protocol?.auth ?? "").trim();
|
|
226
|
+
case "ingestion_pattern":
|
|
227
|
+
return (m.ingestionPattern ?? "").trim();
|
|
228
|
+
case "invocation_pattern":
|
|
229
|
+
return (m.invocationPattern ?? "").trim();
|
|
230
|
+
case "pattern_qualifier":
|
|
231
|
+
return (m.patternQualifier ?? "").trim();
|
|
232
|
+
case "primary_entities":
|
|
233
|
+
return (m.dataFlow?.primaryEntities ?? []).join(";");
|
|
234
|
+
}
|
|
235
|
+
}
|
|
199
236
|
/** `repos/` — where the per-component manifests live (M-ADR 0171 §9.1, second home). */
|
|
200
237
|
const REPOS_DIR = "repos";
|
|
201
238
|
/**
|
|
@@ -261,8 +298,15 @@ async function readComponentManifests(args) {
|
|
|
261
298
|
const componentId = (parsed.data.integrationId ?? "").trim() || dir;
|
|
262
299
|
const id = `${args.integrationId}__${componentId}`;
|
|
263
300
|
if (args.seen.has(id)) {
|
|
264
|
-
|
|
265
|
-
|
|
301
|
+
// The reverse template has the array mirror the per-component manifests, so a component in both homes is
|
|
302
|
+
// expected. A mirror that agrees on every field the tier reads is one declaration stated twice, and is not a
|
|
303
|
+
// finding; one that disagrees is, and the array's declaration is kept.
|
|
304
|
+
const mirrored = args.arrayRows.get(id);
|
|
305
|
+
const differs = mirrored === undefined ? ["(not an array row)"] : TIER_FIELDS.filter((f) => String(mirrored[f] ?? "") !== tierValue(parsed.data, f));
|
|
306
|
+
if (differs.length > 0) {
|
|
307
|
+
args.findings.add("divergence", `integration_component: component id="${id}" is declared in both §9.1 homes (components array and ${rel}) and they disagree on ${differs.join(", ")}; ` +
|
|
308
|
+
`the components array's declaration is kept. Correct whichever is stale.`);
|
|
309
|
+
}
|
|
266
310
|
continue;
|
|
267
311
|
}
|
|
268
312
|
args.seen.add(id);
|