@sdd-method/author-cli 0.11.0 → 0.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/lib/catalogue/build.js +33 -2
- package/dist/lib/catalogue/builders/adr-governs.js +39 -0
- package/dist/lib/catalogue/builders/adrs.js +11 -0
- package/dist/lib/catalogue/builders/contract-revisions.js +126 -0
- package/dist/lib/catalogue/builders/data-model.js +316 -0
- package/dist/lib/catalogue/builders/entities-mermaid.js +18 -85
- package/dist/lib/catalogue/builders/integration-components.js +49 -5
- package/dist/lib/catalogue/builders/quality-scores.js +17 -0
- package/dist/lib/catalogue/builders/requirements.js +132 -23
- package/dist/lib/catalogue/builders/user-journeys.js +8 -13
- package/dist/lib/catalogue/canonical-schema.yaml +767 -17
- package/dist/lib/catalogue/retired-catalogues.js +25 -0
- package/dist/lib/catalogue/rubric-coverage-map.yaml +90 -2
- package/dist/lib/catalogue/schema-compat.js +1 -1
- package/dist/lib/catalogue/schema-loader.js +3 -0
- package/dist/lib/catalogue/staleness-verdicts.js +75 -6
- package/dist/lib/structure/catalogue.js +183 -0
- package/dist/lib/structure/compose.js +174 -0
- package/dist/lib/structure/effects.js +36 -0
- package/dist/lib/structure/model.js +212 -0
- package/dist/lib/structure/render.js +242 -0
- package/dist/lib/structure/view.js +301 -0
- package/dist/lib/validate/agreement-evidence.js +20 -8
- package/dist/lib/validate/behaviour-store.js +54 -13
- package/dist/lib/validate/dispatch.js +7 -0
- package/dist/lib/validate/journey-impact.js +17 -2
- package/dist/lib/validate/structural-model.js +265 -0
- package/dist/maintain-version.json +2 -2
- package/dist/verify-version.json +2 -2
- package/package.json +2 -2
|
@@ -16,6 +16,11 @@
|
|
|
16
16
|
* still produces a valid but empty `catalogues/` directory — useful as a
|
|
17
17
|
* smoke test that the infrastructure works end-to-end on any SDD repo.
|
|
18
18
|
*/
|
|
19
|
+
import { overlayModel } from "../structure/catalogue.js";
|
|
20
|
+
import { buildDataModelElements, readDataModelSources } from "./builders/data-model.js";
|
|
21
|
+
import { removeRetiredCatalogues } from "./retired-catalogues.js";
|
|
22
|
+
import { loadModel } from "../structure/model.js";
|
|
23
|
+
import { viewOf } from "../structure/view.js";
|
|
19
24
|
import { checkCapabilityServicePopulation } from "./population-checks.js";
|
|
20
25
|
import { basename, join, relative, resolve } from "node:path";
|
|
21
26
|
import { copyFile, mkdir, readFile, stat } from "node:fs/promises";
|
|
@@ -35,6 +40,8 @@ import { buildDataItems, buildFeatureDemand, buildIntegrationSupply } from "./bu
|
|
|
35
40
|
import { buildEvidenceTargetRepos } from "./builders/evidence-target-repo.js";
|
|
36
41
|
import { buildRepoDependencies } from "./builders/repo-dependency.js";
|
|
37
42
|
import { buildRequirements } from "./builders/requirements.js";
|
|
43
|
+
import { deriveAdrGoverns } from "./builders/adr-governs.js";
|
|
44
|
+
import { buildContractRevisions } from "./builders/contract-revisions.js";
|
|
38
45
|
import { buildJourneyRequirement } from "./builders/journey-requirement.js";
|
|
39
46
|
import { buildDomains, buildProducts, buildApplicationDataPlaneBindings, buildApplicationDataPlaneDeviations, buildApplicationConformanceCoverage, buildImplementations, buildRepositories, buildServices, buildCapabilities, buildCapabilityService, buildCapabilityAdr, buildCapabilityFeature, buildCapabilityContract, buildAdrs, buildAdrSupersedes, buildFeatures, buildFeatureGroups, buildFeatureGrounding, buildCapabilitySpec, buildContracts, buildDeclaredExternalContracts, buildRepoEdges, deriveFeatureRepoFromServices, buildRepoCapabilityExemption, buildServiceCapabilityExemption, buildEvents, buildServiceEvent, buildCapabilityEvent, buildFeatureEvent, buildServiceContract, buildServiceContractConsumes, buildFeatureContract, buildDomainModels, buildCapabilityDomainModel, buildFeatureDomainModel, buildFeatureSubscriptionChannels, buildProductPlatformDependencies, buildGlossaryItems, backfillGlossaryRelations, buildGlossaryXref, buildC4Systems, buildC4Containers, buildC4Components, buildC4ExternalSystems, buildExternalSystemExemption, buildContainerDependency, reportEntryIdReferentCollisions, buildSystemExternalDependency, buildPersonaC4System, buildPersonaContainer, buildPersonas, buildQualityScores, buildScaffolds, buildScreens, buildUserJourneys, buildPersonaJourney, buildPersonaFeature, buildJourneyFeature, buildProductEdges, buildIntegrations, entitySlug, buildEntitiesFromManifest, buildEntitiesFromMermaid, buildServiceEntityFromTables, enrichEntityPersistenceKind, buildC4ContainerEntity, buildC4SystemEntity, } from "./builders/index.js";
|
|
40
47
|
import { buildIntegrationComponents } from "./builders/integration-components.js";
|
|
@@ -68,7 +75,14 @@ export async function runCatalogueBuild(inputs, write) {
|
|
|
68
75
|
`If the manifest follows a newer sdd-manifest-schema than this build, upgrade ` +
|
|
69
76
|
`sdd-cli; otherwise remove or correct the key.`);
|
|
70
77
|
}
|
|
71
|
-
const
|
|
78
|
+
const collected = await collectRows(schema, manifest, findings, sddPath, inputs.snapshotStore);
|
|
79
|
+
// 3 (then). Where the SDD has a structural model, it is the record of structure: every structure catalogue is built from
|
|
80
|
+
// its current view, and what the sources say otherwise is reported (M-ADR 0230 §4.3, Proposed).
|
|
81
|
+
const modelProblems = [];
|
|
82
|
+
const modelFiles = await loadModel(sddPath, modelProblems);
|
|
83
|
+
const rowsByCatalogue = modelFiles === null ? collected : overlayModel(schema, viewOf(modelFiles, modelProblems), collected, findings);
|
|
84
|
+
for (const p of modelProblems)
|
|
85
|
+
findings.add("convention", `${p} (M-ADR 0230, Proposed)`);
|
|
72
86
|
// 3a. Normalise every keyed catalogue to PK-unique rows. Multi-source edge
|
|
73
87
|
// tables (feature_service, feature_repo) can have the same fact asserted by
|
|
74
88
|
// more than one walker; each walker's own seen-set only dedupes intra-walker.
|
|
@@ -118,6 +132,9 @@ export async function runCatalogueBuild(inputs, write) {
|
|
|
118
132
|
`resolving; neither is worth deleting the rows over.`);
|
|
119
133
|
}
|
|
120
134
|
}
|
|
135
|
+
// 4a. A catalogue the schema has retired leaves the SDD's own catalogues/ with its replacement's first build.
|
|
136
|
+
if (outDir === ownCatalogues)
|
|
137
|
+
await removeRetiredCatalogues(outDir, findings, "catalogues");
|
|
121
138
|
// 5. Internal FK validation (external FKs logged as findings). Pass the
|
|
122
139
|
// SDD profile so profile-external FKs (schema 2.23.0, e.g. an app/integration
|
|
123
140
|
// capability's platform-resident domain_id per M-ADR 0136) resolve at
|
|
@@ -191,6 +208,7 @@ export async function runCatalogueBuild(inputs, write) {
|
|
|
191
208
|
pkConflicts: dedupe.conflicts.length,
|
|
192
209
|
unionedRows: dedupe.unionedRows,
|
|
193
210
|
outDir,
|
|
211
|
+
findings: findings.all(),
|
|
194
212
|
};
|
|
195
213
|
}
|
|
196
214
|
/**
|
|
@@ -1168,10 +1186,16 @@ async function collectRows(schema, manifest, findings, sddPath, snapshotStore) {
|
|
|
1168
1186
|
});
|
|
1169
1187
|
out.set("requirements", r.requirements);
|
|
1170
1188
|
out.set("requirement_lineage", r.lineage);
|
|
1171
|
-
out.set("
|
|
1189
|
+
out.set("requirement_revisions", r.revisions);
|
|
1190
|
+
out.set("requirement_assertions", r.assertions);
|
|
1191
|
+
out.set("change_effects", r.effects);
|
|
1172
1192
|
out.set("changes", r.changes);
|
|
1173
1193
|
out.set("change_work_order", r.workOrders);
|
|
1174
1194
|
out.set("change_work_item", r.workItems);
|
|
1195
|
+
// M-ADR 0230 §4.2 (Proposed): which view each decision governs, now the store's changes are known.
|
|
1196
|
+
out.set("adrs", deriveAdrGoverns(out.get("adrs") ?? [], out.get("adr_supersedes") ?? [], r.changes, findings));
|
|
1197
|
+
// M-ADR 0230 §4.2 (Proposed): each contract's revisions by digest, active beside agreed.
|
|
1198
|
+
out.set("contract_revisions", await buildContractRevisions({ sddPath, findings, contracts: out.get("contracts") ?? [], changes: r.changes }));
|
|
1175
1199
|
// M-ADR 0229 §4.4 (Proposed): the requirements each journey cites, from its tagged end-to-end files or its
|
|
1176
1200
|
// journey feature's living spec.
|
|
1177
1201
|
out.set("journey_requirement", await buildJourneyRequirement({
|
|
@@ -1400,6 +1424,13 @@ async function collectRows(schema, manifest, findings, sddPath, snapshotStore) {
|
|
|
1400
1424
|
entityRows,
|
|
1401
1425
|
});
|
|
1402
1426
|
out.set("entities", enrichedEntityRows);
|
|
1427
|
+
// 17a. The domain models' own content (M-ADR 0230 §4.3, §4.11 proof 7, Proposed; schema 6.0.0): each entity's
|
|
1428
|
+
// attributes, the relationships with both ends' cardinality and roles, and the keys as invariants, from the same
|
|
1429
|
+
// parse the entities came from.
|
|
1430
|
+
const dataModel = buildDataModelElements(await readDataModelSources(sddPath, domainModels.map((r) => ({ id: String(r["id"]), domainId: String(r["domain_id"]), docPath: String(r["doc_path"]) }))), enrichedEntityRows, findings);
|
|
1431
|
+
out.set("entity_attributes", dataModel.entityAttributes);
|
|
1432
|
+
out.set("entity_relationships", dataModel.entityRelationships);
|
|
1433
|
+
out.set("domain_invariants", dataModel.domainInvariants);
|
|
1403
1434
|
const serviceEntityRows = await buildServiceEntityFromTables({
|
|
1404
1435
|
sddPath,
|
|
1405
1436
|
findings,
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export function deriveAdrGoverns(adrs, supersedes, changes, findings) {
|
|
2
|
+
const changeState = (name) => {
|
|
3
|
+
const rows = changes.filter((c) => c["name"] === name);
|
|
4
|
+
if (rows.some((c) => c["status"] === "archived"))
|
|
5
|
+
return "archived";
|
|
6
|
+
if (rows.some((c) => c["status"] === "withdrawn" || c["status"] === "superseded"))
|
|
7
|
+
return "closed";
|
|
8
|
+
return rows.length > 0 ? "open" : "absent";
|
|
9
|
+
};
|
|
10
|
+
const accepted = new Map();
|
|
11
|
+
for (const a of adrs) {
|
|
12
|
+
if (!/^accepted\b/i.test(String(a["status"] ?? "")))
|
|
13
|
+
continue;
|
|
14
|
+
const change = String(a["effective_change"] ?? "");
|
|
15
|
+
if (change === "") {
|
|
16
|
+
accepted.set(String(a["id"]), "current");
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
const state = changeState(change);
|
|
20
|
+
if (state === "absent")
|
|
21
|
+
findings.add("convention", `${String(a["doc_path"])}: effective on archive of ${change}, which is no change in this store; the decision governs nothing until it is (M-ADR 0230 §4.2, Proposed)`);
|
|
22
|
+
accepted.set(String(a["id"]), state === "archived" ? "current" : state === "open" ? "intended" : "none");
|
|
23
|
+
}
|
|
24
|
+
const successors = new Map();
|
|
25
|
+
for (const s of supersedes) {
|
|
26
|
+
const old = String(s["superseded_adr_id"]);
|
|
27
|
+
successors.set(old, [...(successors.get(old) ?? []), String(s["superseding_adr_id"])]);
|
|
28
|
+
}
|
|
29
|
+
return adrs.map((a) => {
|
|
30
|
+
const id = String(a["id"]);
|
|
31
|
+
let governs = accepted.get(id) ?? "none";
|
|
32
|
+
if (/^superseded\b/i.test(String(a["status"] ?? ""))) {
|
|
33
|
+
const next = (successors.get(id) ?? []).map((s) => accepted.get(s) ?? "none");
|
|
34
|
+
governs = next.length > 0 && !next.includes("current") ? "current" : "none";
|
|
35
|
+
}
|
|
36
|
+
return { ...a, governs };
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
//# sourceMappingURL=adr-governs.js.map
|
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
// - **Date:** YYYY-MM-DD
|
|
20
20
|
// - **Supersedes:** raw text (captured into supersedes_raw for the
|
|
21
21
|
// adr_supersedes builder to parse)
|
|
22
|
+
// - **Effective:** `on acceptance` (the default) or `on archive of <change>` (M-ADR 0230 §4.2, Proposed):
|
|
23
|
+
// effective_change holds the change; governs is derived once the store's changes are known (adr-governs.ts)
|
|
22
24
|
//
|
|
23
25
|
// ID canonicalisation: filename stem `ADR XXXX[: Title]` → `ADR-{0-padded}`.
|
|
24
26
|
// Title = the portion after the colon when present, else the file stem
|
|
@@ -80,6 +82,12 @@ export async function buildAdrs(inputs) {
|
|
|
80
82
|
const decisionDate = extractField(text, "Date") || "";
|
|
81
83
|
const supersedesRaw = extractField(text, "Supersedes") || "";
|
|
82
84
|
const supersededByRaw = extractField(text, "Superseded By") || "";
|
|
85
|
+
const effectiveRaw = (extractField(text, "Effective") || "").trim();
|
|
86
|
+
const onArchive = /^on archive of\s+`?([A-Za-z0-9][A-Za-z0-9._-]*)`?\s*\.?$/i.exec(effectiveRaw);
|
|
87
|
+
if (effectiveRaw !== "" && !onArchive && !/^on acceptance\.?$/i.test(effectiveRaw)) {
|
|
88
|
+
inputs.findings.add("convention", `${relPath}: **Effective:** is "${effectiveRaw}", neither "on acceptance" nor "on archive of <change>"; read as on acceptance (M-ADR 0230 §4.2, Proposed)`);
|
|
89
|
+
}
|
|
90
|
+
const effectiveChange = onArchive?.[1] ?? "";
|
|
83
91
|
if (scope === "domain" && !inputs.knownDomainIds.has(domainId)) {
|
|
84
92
|
inputs.findings.add("orphan", `${relPath}: ADR is in domain "${domainId}" but no docs/domains/${domainId}/ exists`);
|
|
85
93
|
}
|
|
@@ -97,6 +105,7 @@ export async function buildAdrs(inputs) {
|
|
|
97
105
|
docPath: relPath,
|
|
98
106
|
supersedesRaw,
|
|
99
107
|
supersededByRaw,
|
|
108
|
+
effectiveChange,
|
|
100
109
|
});
|
|
101
110
|
}
|
|
102
111
|
if (methodScopeSkipCount > 0) {
|
|
@@ -112,6 +121,8 @@ export async function buildAdrs(inputs) {
|
|
|
112
121
|
superseded_by_id: "", // populated in a backfill pass after adr_supersedes builds
|
|
113
122
|
inference_confidence: "",
|
|
114
123
|
supersedes_raw: a.supersedesRaw,
|
|
124
|
+
effective_change: a.effectiveChange,
|
|
125
|
+
governs: "", // derived once the store's changes are known (adr-governs.ts)
|
|
115
126
|
source_sdd_id: "",
|
|
116
127
|
}));
|
|
117
128
|
return { rows, parsed };
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// contract_revisions: a contract version's content revisions, by digest (M-ADR 0230 §4.2, Proposed; schema 6.0.0).
|
|
2
|
+
// active the contract file's current content (deprecated where the contract is)
|
|
3
|
+
// agreed a revision an agreed open change agrees (agreement record 1.2.0, contracts[].agrees)
|
|
4
|
+
// retired a revision an archived change agreed that the file no longer holds
|
|
5
|
+
// An archived change that agreed the current content is the active revision's change. A compatible amendment is a
|
|
6
|
+
// new revision at the same path, so an agreed revision and the active one coexist.
|
|
7
|
+
//
|
|
8
|
+
// A record names a contract by the file in its own repository (`file:`, or a `files:` list), which the catalogue holds
|
|
9
|
+
// under the SDD's mount of that repository; so a record's file matches the contract whose spec_path ends with it, and
|
|
10
|
+
// a file matching no contract, or several, is a finding and produces no row. `relies_on` and `agrees` are a digest
|
|
11
|
+
// beside `file:`, or a map from each file to its digest beside `files:`. A `relies_on` that no longer matches the
|
|
12
|
+
// file's content is reported: the change relies on a revision that has moved, and is re-agreed.
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
14
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { parse as parseYaml } from "yaml";
|
|
17
|
+
const AGREEMENTS = join("docs", "agreements");
|
|
18
|
+
const DIGEST = /^sha256:[0-9a-f]{64}$/;
|
|
19
|
+
export function contentDigest(bytes) {
|
|
20
|
+
return `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
|
|
21
|
+
}
|
|
22
|
+
const revisionId = (contract, digest) => `${contract}@${digest.slice("sha256:".length, "sha256:".length + 12)}`;
|
|
23
|
+
/** Each (file, relies_on, agrees) a record's contracts list pins. */
|
|
24
|
+
function pins(record, where, findings) {
|
|
25
|
+
const r = record?.agreement_record;
|
|
26
|
+
if (!Array.isArray(r?.contracts))
|
|
27
|
+
return [];
|
|
28
|
+
const out = [];
|
|
29
|
+
for (const [i, raw] of r.contracts.entries()) {
|
|
30
|
+
const c = raw;
|
|
31
|
+
if (!c)
|
|
32
|
+
continue;
|
|
33
|
+
const files = typeof c["file"] === "string" ? [c["file"]] : Array.isArray(c["files"]) ? c["files"].map(String) : [];
|
|
34
|
+
const digestFor = (key, file) => {
|
|
35
|
+
const v = c[key];
|
|
36
|
+
if (v === undefined)
|
|
37
|
+
return undefined;
|
|
38
|
+
const d = typeof v === "string" && typeof c["file"] === "string" ? v : typeof v === "object" && v !== null ? v[file] : undefined;
|
|
39
|
+
if (d === undefined)
|
|
40
|
+
return undefined;
|
|
41
|
+
if (typeof d !== "string" || !DIGEST.test(d)) {
|
|
42
|
+
findings.add("convention", `${where}: contracts[${i}].${key} for ${file} is not a sha256:<64 hex> digest`);
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
return d;
|
|
46
|
+
};
|
|
47
|
+
for (const file of files) {
|
|
48
|
+
const reliesOn = digestFor("relies_on", file);
|
|
49
|
+
const agrees = digestFor("agrees", file);
|
|
50
|
+
if (reliesOn || agrees)
|
|
51
|
+
out.push({ file, ...(reliesOn ? { reliesOn } : {}), ...(agrees ? { agrees } : {}) });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
export async function buildContractRevisions(inputs) {
|
|
57
|
+
const { sddPath, findings } = inputs;
|
|
58
|
+
const rows = new Map();
|
|
59
|
+
const current = new Map(); // contract id -> current digest
|
|
60
|
+
for (const c of inputs.contracts) {
|
|
61
|
+
const path = String(c["spec_path"] ?? "");
|
|
62
|
+
if (path === "")
|
|
63
|
+
continue;
|
|
64
|
+
const bytes = await readFile(join(sddPath, path)).catch(() => null);
|
|
65
|
+
if (bytes === null)
|
|
66
|
+
continue;
|
|
67
|
+
const id = String(c["id"]);
|
|
68
|
+
const digest = contentDigest(bytes);
|
|
69
|
+
current.set(id, digest);
|
|
70
|
+
const rid = revisionId(id, digest);
|
|
71
|
+
rows.set(rid, { id: rid, contract_id: id, digest, state: c["status"] === "deprecated" ? "deprecated" : "active", sunset_date: "", change_id: "", source_sdd_id: "", platform_sdd_id: "" });
|
|
72
|
+
}
|
|
73
|
+
const byFile = (file) => inputs.contracts.filter((c) => String(c["spec_path"] ?? "").endsWith(`/${file}`) || c["spec_path"] === file).map((c) => String(c["id"]));
|
|
74
|
+
let names = [];
|
|
75
|
+
try {
|
|
76
|
+
names = (await readdir(join(sddPath, AGREEMENTS))).filter((n) => n.endsWith(".yaml")).sort();
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
names = [];
|
|
80
|
+
}
|
|
81
|
+
for (const name of names) {
|
|
82
|
+
const change = name.slice(0, -".yaml".length);
|
|
83
|
+
const where = join(AGREEMENTS, name);
|
|
84
|
+
let doc;
|
|
85
|
+
try {
|
|
86
|
+
doc = parseYaml(await readFile(join(sddPath, where), "utf8"));
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
continue; // the behaviour-store check reports an unparseable record
|
|
90
|
+
}
|
|
91
|
+
const changeRows = inputs.changes.filter((c) => c["name"] === change);
|
|
92
|
+
const archived = changeRows.find((c) => c["status"] === "archived");
|
|
93
|
+
const open = changeRows.find((c) => c["status"] !== "archived");
|
|
94
|
+
const state = archived ? "archived" : open?.["status"] === "agreed" ? "agreed" : "other";
|
|
95
|
+
if (state === "other")
|
|
96
|
+
continue;
|
|
97
|
+
const changeId = String((archived ?? open)["id"]);
|
|
98
|
+
for (const p of pins(doc, where, findings)) {
|
|
99
|
+
const matches = byFile(p.file);
|
|
100
|
+
if (matches.length !== 1) {
|
|
101
|
+
findings.add("convention", `${where}: contract file ${p.file} matches ${matches.length === 0 ? "no catalogued contract" : `${matches.length} catalogued contracts (${matches.join(", ")})`}; no revision row`);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const contract = matches[0];
|
|
105
|
+
const now = current.get(contract);
|
|
106
|
+
if (state === "agreed" && p.reliesOn && now && p.reliesOn !== now) {
|
|
107
|
+
findings.add("divergence", `${where}: relies on revision ${p.reliesOn.slice(0, 19)}… of ${contract}, which has moved to ${now.slice(0, 19)}…; the change relies on a revision no longer published, so it is re-agreed (M-ADR 0230 §4.2, Proposed)`);
|
|
108
|
+
}
|
|
109
|
+
if (!p.agrees)
|
|
110
|
+
continue;
|
|
111
|
+
const rid = revisionId(contract, p.agrees);
|
|
112
|
+
const existing = rows.get(rid);
|
|
113
|
+
if (p.agrees === now) {
|
|
114
|
+
if (existing && state === "archived")
|
|
115
|
+
rows.set(rid, { ...existing, change_id: changeId });
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (state === "agreed")
|
|
119
|
+
rows.set(rid, { id: rid, contract_id: contract, digest: p.agrees, state: "agreed", sunset_date: "", change_id: changeId, source_sdd_id: "", platform_sdd_id: "" });
|
|
120
|
+
else if (!existing)
|
|
121
|
+
rows.set(rid, { id: rid, contract_id: contract, digest: p.agrees, state: "retired", sunset_date: "", change_id: changeId, source_sdd_id: "", platform_sdd_id: "" });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return [...rows.values()].sort((a, b) => String(a["id"]).localeCompare(String(b["id"])));
|
|
125
|
+
}
|
|
126
|
+
//# sourceMappingURL=contract-revisions.js.map
|
|
@@ -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
|