@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.
Files changed (38) hide show
  1. package/dist/lib/behaviour-store/archive.js +13 -0
  2. package/dist/lib/catalogue/aggregate.js +64 -0
  3. package/dist/lib/catalogue/build.js +45 -2
  4. package/dist/lib/catalogue/builders/adr-governs.js +39 -0
  5. package/dist/lib/catalogue/builders/adrs.js +11 -0
  6. package/dist/lib/catalogue/builders/capability-spec.js +1 -0
  7. package/dist/lib/catalogue/builders/contract-revisions.js +126 -0
  8. package/dist/lib/catalogue/builders/data-model.js +316 -0
  9. package/dist/lib/catalogue/builders/entities-mermaid.js +18 -85
  10. package/dist/lib/catalogue/builders/feature-grounding.js +1 -0
  11. package/dist/lib/catalogue/builders/features.js +23 -2
  12. package/dist/lib/catalogue/builders/integration-components.js +49 -5
  13. package/dist/lib/catalogue/builders/journey-requirement.js +144 -0
  14. package/dist/lib/catalogue/builders/quality-scores.js +17 -0
  15. package/dist/lib/catalogue/builders/requirements.js +168 -32
  16. package/dist/lib/catalogue/canonical-schema.yaml +845 -28
  17. package/dist/lib/catalogue/manifest-loader.js +13 -0
  18. package/dist/lib/catalogue/retired-catalogues.js +25 -0
  19. package/dist/lib/catalogue/rubric-coverage-map.yaml +108 -2
  20. package/dist/lib/catalogue/schema-compat.js +1 -1
  21. package/dist/lib/catalogue/schema-loader.js +3 -0
  22. package/dist/lib/catalogue/staleness-verdicts.js +75 -6
  23. package/dist/lib/metamodel/tql-subset-parser.js +35 -2
  24. package/dist/lib/structure/catalogue.js +183 -0
  25. package/dist/lib/structure/compose.js +174 -0
  26. package/dist/lib/structure/effects.js +36 -0
  27. package/dist/lib/structure/model.js +212 -0
  28. package/dist/lib/structure/render.js +242 -0
  29. package/dist/lib/structure/view.js +301 -0
  30. package/dist/lib/validate/agreement-evidence.js +216 -0
  31. package/dist/lib/validate/behaviour-store.js +516 -28
  32. package/dist/lib/validate/dispatch.js +21 -0
  33. package/dist/lib/validate/feature-spec-pointers.js +175 -0
  34. package/dist/lib/validate/journey-impact.js +193 -0
  35. package/dist/lib/validate/structural-model.js +265 -0
  36. package/dist/maintain-version.json +2 -2
  37. package/dist/verify-version.json +2 -2
  38. 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 lines = text.split(/\r?\n/);
78
- const out = [];
79
- const seenInFile = new Set();
80
- let inMermaid = false;
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 (inEntity && entityName !== null) {
155
- findings.add("convention", `entities-mermaid: ${dm.doc_path}: unclosed entity block for "${entityName}" — discarded`);
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 out;
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);
@@ -153,6 +153,7 @@ export async function buildFeatureGrounding(inputs) {
153
153
  status: "active",
154
154
  source: "feature-grounding",
155
155
  runway: "",
156
+ kind: "feature",
156
157
  source_sdd_id: "",
157
158
  });
158
159
  const cfId = `${capabilityId}__${featureId}`;
@@ -89,9 +89,10 @@ export async function buildFeatures(inputs) {
89
89
  parent_feature_id: "",
90
90
  description,
91
91
  maturity: "",
92
- status: "active",
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: "active",
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 and a collision across the two homes is caught rather than
180
- // silently duplicated — measured disjoint today, but the two forms are authored
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
- args.findings.add("divergence", `integration_component: component id="${id}" declared in BOTH §9.1 homes (components array and ${rel}) — ` +
265
- `one component cannot be declared twice; the per-component manifest is dropped. Remove whichever declaration is stale.`);
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);