@sdd-method/verify-cli 0.1.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 (53) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +17 -0
  3. package/README.md +7 -0
  4. package/dist/lib/catalogue/baseline-check.js +136 -0
  5. package/dist/lib/catalogue/canonical-schema.yaml +3557 -0
  6. package/dist/lib/catalogue/cli-version.js +10 -0
  7. package/dist/lib/catalogue/coverage-predicates.js +603 -0
  8. package/dist/lib/catalogue/coverage-render.js +522 -0
  9. package/dist/lib/catalogue/coverage-run.js +115 -0
  10. package/dist/lib/catalogue/csv-reader.js +97 -0
  11. package/dist/lib/catalogue/csv-writer.js +96 -0
  12. package/dist/lib/catalogue/ddl-generator.js +233 -0
  13. package/dist/lib/catalogue/engine-validator.js +222 -0
  14. package/dist/lib/catalogue/finding-identity.js +183 -0
  15. package/dist/lib/catalogue/findings.js +46 -0
  16. package/dist/lib/catalogue/fk-validator.js +136 -0
  17. package/dist/lib/catalogue/gate-checks.js +338 -0
  18. package/dist/lib/catalogue/graph-projection.js +329 -0
  19. package/dist/lib/catalogue/impact-run.js +228 -0
  20. package/dist/lib/catalogue/manifest-loader.js +422 -0
  21. package/dist/lib/catalogue/relationship-semantics.js +66 -0
  22. package/dist/lib/catalogue/rubric-coverage-map.yaml +764 -0
  23. package/dist/lib/catalogue/scaffold-delta.js +261 -0
  24. package/dist/lib/catalogue/schema-compat.js +52 -0
  25. package/dist/lib/catalogue/schema-loader.js +87 -0
  26. package/dist/lib/catalogue/scores-coverage.js +535 -0
  27. package/dist/lib/catalogue/staleness-verdicts.js +345 -0
  28. package/dist/lib/catalogue/validate.js +176 -0
  29. package/dist/lib/catalogue/version.js +18 -0
  30. package/dist/lib/list/discover.js +100 -0
  31. package/dist/lib/list/format.js +84 -0
  32. package/dist/lib/list/index.js +16 -0
  33. package/dist/lib/lock-file.js +413 -0
  34. package/dist/lib/pack/log.js +18 -0
  35. package/dist/lib/pack/schema.js +121 -0
  36. package/dist/lib/pack/seed.js +182 -0
  37. package/dist/lib/pack/validate.js +49 -0
  38. package/dist/lib/package-version.js +25 -0
  39. package/dist/lib/repo-kind.js +129 -0
  40. package/dist/lib/scripts.js +7 -0
  41. package/dist/lib/validate/aggregate.js +35 -0
  42. package/dist/lib/validate/bundle-coherence.js +69 -0
  43. package/dist/lib/validate/dispatch.js +218 -0
  44. package/dist/lib/validate/index.js +88 -0
  45. package/dist/lib/validate/runner.js +85 -0
  46. package/dist/verbs/catalogue-verify.js +114 -0
  47. package/dist/verbs/list.js +45 -0
  48. package/dist/verbs/pack-validate.js +17 -0
  49. package/dist/verbs/path-option.js +25 -0
  50. package/dist/verbs/validate.js +22 -0
  51. package/dist/verify-bin.js +8 -0
  52. package/dist/verify-program.js +115 -0
  53. package/package.json +32 -0
@@ -0,0 +1,338 @@
1
+ /**
2
+ * Phase-0 gate checks as SQL — M-ADR 0205 §4.4 (engine-evolution Phase A3,
3
+ * final tranche). The nine small catalogue-integrity-gate steps
4
+ * re-expressed on the engine: emptiness-vs-populated-inputs signals
5
+ * (repo-edges 0157, data-model-entities 0159, capability-event 0160,
6
+ * feature-event 0161, platform-dependencies 0162, glossary-xref 0158),
7
+ * the per-domain event-catalogue count compare (0156), and the two
8
+ * features.csv Phase-0 checks (0177 F1 promotion, F2 near-duplicate
9
+ * surfaces — the classified borderline: engine for access, the token
10
+ * Jaccard stays host-language).
11
+ *
12
+ * The shell scripts remain the REFERENCE implementations until A4. Known,
13
+ * deliberate divergence class: several references read CSV columns
14
+ * POSITIONALLY with a naive comma split (`awk -F, $5`) and count PHYSICAL
15
+ * lines rather than CSV records — this port reads by column name over
16
+ * parsed records. Where real data makes those disagree, the dual-run
17
+ * driver surfaces it and the adjudication is a defect in the shell side
18
+ * (the exact matcher-error class M-ADR 0205 §1 exists to kill), never a
19
+ * smoothing of this side.
20
+ *
21
+ * File and YAML ingestion stay host-side, mirroring each reference's
22
+ * refusal semantics (a missing key, an unparseable document, an absent
23
+ * tool each behave exactly as the reference behaves).
24
+ */
25
+ import { readFileSync, readdirSync, existsSync } from "node:fs";
26
+ import { join } from "node:path";
27
+ import { parse as parseYaml } from "yaml";
28
+ async function scalar(session, sql) {
29
+ const reader = await session.conn.runAndReadAll(sql);
30
+ return Number(reader.getRows()[0][0]);
31
+ }
32
+ const count = (table, where = "") => `SELECT COUNT(*) FROM "${table}"${where ? ` WHERE ${where}` : ""};`;
33
+ // @rule gate-event-catalogue
34
+ export async function runEventCatalogueCheck(session, options) {
35
+ if (!options.eventsPresent)
36
+ return { skipped: true, gaps: [] };
37
+ const domainsDir = join(options.sddRoot, "docs", "domains");
38
+ if (!existsSync(domainsDir))
39
+ return { skipped: false, gaps: [] };
40
+ const gaps = [];
41
+ for (const domain of readdirSync(domainsDir).sort()) {
42
+ const catalogPath = join(domainsDir, domain, "contracts", "events", "event-catalog.yaml");
43
+ if (!existsSync(catalogPath))
44
+ continue;
45
+ // Mirror of `yq '.events | length' || echo 0`: missing key or null → 0,
46
+ // an array → its length, a mapping → its key count, unparseable → 0.
47
+ let declared = 0;
48
+ try {
49
+ const doc = parseYaml(readFileSync(catalogPath, "utf-8"));
50
+ const events = doc?.["events"];
51
+ if (Array.isArray(events))
52
+ declared = events.length;
53
+ else if (events !== null && events !== undefined && typeof events === "object")
54
+ declared = Object.keys(events).length;
55
+ }
56
+ catch {
57
+ declared = 0;
58
+ }
59
+ if (declared <= 0)
60
+ continue;
61
+ const emitted = await scalar(session, count("events", `COALESCE("domain_id", '') = '${domain.replace(/'/g, "''")}'`));
62
+ if (emitted < declared)
63
+ gaps.push({ domain, declared, emitted });
64
+ }
65
+ return { skipped: false, gaps };
66
+ }
67
+ // @rule gate-repo-edges
68
+ export async function runRepoEdgesCheck(session, options) {
69
+ const repoCaps = await scalar(session, count("repo_capability"));
70
+ const capSvcs = await scalar(session, count("capability_service"));
71
+ const svcsWithRepo = await scalar(session, count("services", `COALESCE("primary_repo_id", '') <> ''`));
72
+ const repoContracts = await scalar(session, count("repo_contract"));
73
+ const contracts = await scalar(session, count("contracts"));
74
+ const svcContracts = await scalar(session, count("service_contract"));
75
+ // Mirror of the reference's two glob patterns (bash without globstar:
76
+ // `**` collapses to one level) — direct files AND one directory deep,
77
+ // schema.yaml deliberately NOT excluded because the reference scans it.
78
+ let repoConfigsWithContracts = 0;
79
+ const cfgDir = join(options.sddRoot, "orchestration", "repo-config");
80
+ if (existsSync(cfgDir)) {
81
+ const candidates = [];
82
+ for (const entry of readdirSync(cfgDir, { withFileTypes: true })) {
83
+ const p = join(cfgDir, entry.name);
84
+ if (entry.isFile() && entry.name.endsWith(".yaml"))
85
+ candidates.push(p);
86
+ else if (entry.isDirectory()) {
87
+ for (const f of readdirSync(p)) {
88
+ if (f.endsWith(".yaml"))
89
+ candidates.push(join(p, f));
90
+ }
91
+ }
92
+ }
93
+ for (const p of candidates) {
94
+ if (/^contracts:/m.test(readFileSync(p, "utf-8")))
95
+ repoConfigsWithContracts++;
96
+ }
97
+ }
98
+ return {
99
+ ...(repoCaps === 0 && capSvcs > 0 && svcsWithRepo > 0
100
+ ? { repoCapabilityGap: { capabilityService: capSvcs, servicesWithRepo: svcsWithRepo } }
101
+ : {}),
102
+ ...(repoContracts === 0 && contracts > 0 && repoConfigsWithContracts > 0
103
+ ? { repoContractForwardGap: { contracts, repoConfigsWithContracts } }
104
+ : {}),
105
+ ...(repoContracts === 0 && svcContracts > 0 && svcsWithRepo > 0
106
+ ? { repoContractPathXGap: { serviceContracts: svcContracts, servicesWithRepo: svcsWithRepo } }
107
+ : {}),
108
+ ...(repoContracts === 0 &&
109
+ contracts > 0 &&
110
+ repoConfigsWithContracts === 0 &&
111
+ svcContracts === 0
112
+ ? { repoContractReverseGap: { contracts } }
113
+ : {}),
114
+ };
115
+ }
116
+ // ─── 0158: glossary-xref ingestion gap ───────────────────────────────────
117
+ // @rule gate-glossary-xref
118
+ export async function runGlossaryXrefCheck(session, options) {
119
+ const manifest = join(options.sddRoot, "docs", "platform", "reference", "glossary-alignment-manifest.yaml");
120
+ if (!existsSync(manifest) || !options.xrefPresent)
121
+ return { finding: false };
122
+ const rows = await scalar(session, count("glossary_xref"));
123
+ return { finding: rows === 0 };
124
+ }
125
+ // @rule gate-data-model-entities
126
+ export async function runDataModelEntitiesCheck(session) {
127
+ const domainModels = await scalar(session, count("domain_models"));
128
+ const entities = await scalar(session, count("entities"));
129
+ const services = await scalar(session, count("services"));
130
+ const serviceEntity = await scalar(session, count("service_entity"));
131
+ const containerEntity = await scalar(session, count("c4_container_entity"));
132
+ const containersWithService = await scalar(session, count("c4_containers", `COALESCE("service_id", '') <> ''`));
133
+ return {
134
+ ...(domainModels > 0 && entities === 0 ? { entitiesGap: { domainModels } } : {}),
135
+ ...(entities > 0 && services > 0 && serviceEntity === 0
136
+ ? { serviceEntityGap: { entities, services } }
137
+ : {}),
138
+ ...(serviceEntity > 0 && containersWithService > 0 && containerEntity === 0
139
+ ? { containerEntityGap: { containersWithService, serviceEntity } }
140
+ : {}),
141
+ };
142
+ }
143
+ // @rule gate-capability-event
144
+ export async function runCapabilityEventCheck(session) {
145
+ const serviceEvent = await scalar(session, count("service_event"));
146
+ const capabilityService = await scalar(session, count("capability_service"));
147
+ const capabilityEvent = await scalar(session, count("capability_event"));
148
+ let authoredGap;
149
+ if (capabilityEvent > 0) {
150
+ const authored = await scalar(session, count("capability_event", `COALESCE("source", '') = 'authored'`));
151
+ const derived = await scalar(session, count("capability_event", `COALESCE("source", '') = 'derived'`));
152
+ if (authored === 0 && derived > 0)
153
+ authoredGap = { derived };
154
+ }
155
+ return {
156
+ ...(serviceEvent > 0 && capabilityService > 0 && capabilityEvent === 0
157
+ ? { derivableGap: { serviceEvent, capabilityService } }
158
+ : {}),
159
+ ...(authoredGap !== undefined ? { authoredGap } : {}),
160
+ };
161
+ }
162
+ // ─── 0161: feature_event derivation gap ──────────────────────────────────
163
+ // @rule gate-feature-event
164
+ export async function runFeatureEventCheck(session) {
165
+ const serviceEvent = await scalar(session, count("service_event"));
166
+ const featureService = await scalar(session, count("feature_service"));
167
+ const featureEvent = await scalar(session, count("feature_event"));
168
+ return serviceEvent > 0 && featureService > 0 && featureEvent === 0
169
+ ? { gap: { serviceEvent, featureService } }
170
+ : {};
171
+ }
172
+ // ─── 0162: forward platform-dependencies parity gap ──────────────────────
173
+ // @rule gate-platform-dependencies
174
+ export async function runPlatformDependenciesCheck(session, options) {
175
+ const forwardFile = join(options.sddRoot, "app-platform-dependencies.yaml");
176
+ const forwardAuthored = existsSync(forwardFile) &&
177
+ /^\s*platform_dependencies:/m.test(readFileSync(forwardFile, "utf-8"));
178
+ if (!forwardAuthored)
179
+ return { finding: false };
180
+ const ppd = await scalar(session, count("product_platform_dependencies"));
181
+ return { finding: ppd === 0 };
182
+ }
183
+ // ─── 0177 F1: feature-spec promotion ─────────────────────────────────────
184
+ /** Python's round(): half to even — the reference computes pct with it. */
185
+ function pythonRound(x) {
186
+ const floor = Math.floor(x);
187
+ const diff = x - floor;
188
+ if (diff > 0.5)
189
+ return floor + 1;
190
+ if (diff < 0.5)
191
+ return floor;
192
+ return floor % 2 === 0 ? floor : floor + 1;
193
+ }
194
+ // @rule gate-feature-spec-promotion
195
+ export async function runFeatureSpecPromotionCheck(session) {
196
+ const reader = await session.conn.runAndReadAll(`SELECT COALESCE("source", '') FROM "features";`);
197
+ let total = 0;
198
+ let resolvable = 0;
199
+ let intermediate = 0;
200
+ for (const r of reader.getRows()) {
201
+ const src = String(r[0] ?? "").trim();
202
+ total++;
203
+ if (src === "feature-spec" || src === "feature-tree")
204
+ resolvable++;
205
+ else if (src === "capability-spec" || src === "feature-grounding")
206
+ intermediate++;
207
+ }
208
+ if (total === 0 || intermediate === 0)
209
+ return {};
210
+ return {
211
+ finding: {
212
+ intermediate,
213
+ total,
214
+ pctResolvable: pythonRound((100 * resolvable) / total),
215
+ note: intermediate >= resolvable ? "predominantly un-promoted" : "partially promoted",
216
+ },
217
+ };
218
+ }
219
+ // ─── 0177 F2: near-duplicate feature surfaces (the borderline: engine for
220
+ // access, the token Jaccard host-language) ───────────────────────────
221
+ function tokens(slug) {
222
+ return new Set(slug.split("-").filter((t) => t !== ""));
223
+ }
224
+ function near(a, b) {
225
+ const ta = tokens(a);
226
+ const tb = tokens(b);
227
+ if (ta.size === 0 || tb.size === 0)
228
+ return false;
229
+ const inter = [...ta].filter((t) => tb.has(t)).length;
230
+ if (inter === ta.size || inter === tb.size)
231
+ return true; // subset either way
232
+ const union = ta.size + tb.size - inter;
233
+ return union > 0 && inter / union >= 0.5;
234
+ }
235
+ // @rule gate-feature-surface-duplication
236
+ export async function runFeatureSurfaceDuplicationCheck(session) {
237
+ const reader = await session.conn.runAndReadAll(`SELECT COALESCE("capability_id", ''), COALESCE("name", ''), COALESCE("source", '') FROM "features";`);
238
+ const rollup = new Map();
239
+ const detailed = new Map();
240
+ for (const r of reader.getRows()) {
241
+ const cap = String(r[0] ?? "").trim();
242
+ const slug = String(r[1] ?? "").trim();
243
+ const src = String(r[2] ?? "").trim();
244
+ if (cap === "" || slug === "")
245
+ continue;
246
+ if (src === "capability-spec") {
247
+ rollup.set(cap, [...(rollup.get(cap) ?? []), slug]);
248
+ }
249
+ else if (src === "feature-grounding") {
250
+ detailed.set(cap, [...(detailed.get(cap) ?? []), slug]);
251
+ }
252
+ }
253
+ let pairs = 0;
254
+ const caps = new Set();
255
+ for (const [cap, rollupSlugs] of rollup) {
256
+ const detailedSlugs = detailed.get(cap);
257
+ if (!detailedSlugs)
258
+ continue;
259
+ for (const r of rollupSlugs) {
260
+ for (const d of detailedSlugs) {
261
+ if (r !== d && near(r, d)) {
262
+ pairs++;
263
+ caps.add(cap);
264
+ }
265
+ }
266
+ }
267
+ }
268
+ return pairs > 0 ? { finding: { pairs, capabilities: caps.size } } : {};
269
+ }
270
+ // @rule gate-relationship-coherence
271
+ export async function runRelationshipCoherenceCheck(session) {
272
+ const ownedUntraced = [];
273
+ let reader = await session.conn.runAndReadAll(`SELECT f."id", f."capability_id" FROM "features" f
274
+ WHERE COALESCE(f."capability_id", '') <> ''
275
+ AND NOT EXISTS (SELECT 1 FROM "capability_feature" cf
276
+ WHERE cf."capability_id" = f."capability_id"
277
+ AND cf."feature_id" = f."id")
278
+ ORDER BY f."id";`);
279
+ for (const r of reader.getRows()) {
280
+ ownedUntraced.push({ featureId: String(r[0]), capabilityId: String(r[1]) });
281
+ }
282
+ const publisherDrift = [];
283
+ reader = await session.conn.runAndReadAll(`SELECT se."service_id", se."event_id" FROM "service_event" se
284
+ WHERE se."role" = 'publishes'
285
+ AND NOT EXISTS (SELECT 1 FROM "events" e
286
+ WHERE e."id" = se."event_id"
287
+ AND COALESCE(e."publisher_service_id", '') = se."service_id")
288
+ ORDER BY 1, 2;`);
289
+ for (const r of reader.getRows()) {
290
+ publisherDrift.push({
291
+ serviceId: String(r[0]),
292
+ eventId: String(r[1]),
293
+ missing: "publisher-fk",
294
+ });
295
+ }
296
+ reader = await session.conn.runAndReadAll(`SELECT e."publisher_service_id", e."id" FROM "events" e
297
+ WHERE COALESCE(e."publisher_service_id", '') <> ''
298
+ AND NOT EXISTS (SELECT 1 FROM "service_event" se
299
+ WHERE se."role" = 'publishes'
300
+ AND se."event_id" = e."id"
301
+ AND se."service_id" = e."publisher_service_id")
302
+ ORDER BY 1, 2;`);
303
+ for (const r of reader.getRows()) {
304
+ publisherDrift.push({
305
+ serviceId: String(r[0]),
306
+ eventId: String(r[1]),
307
+ missing: "publishes-edge",
308
+ });
309
+ }
310
+ const supersessionDrift = [];
311
+ reader = await session.conn.runAndReadAll(`SELECT s."superseding_adr_id", s."superseded_adr_id" FROM "adr_supersedes" s
312
+ WHERE NOT EXISTS (SELECT 1 FROM "adrs" a
313
+ WHERE a."id" = s."superseded_adr_id"
314
+ AND COALESCE(a."superseded_by_id", '') = s."superseding_adr_id")
315
+ ORDER BY 1, 2;`);
316
+ for (const r of reader.getRows()) {
317
+ supersessionDrift.push({
318
+ supersedingId: String(r[0]),
319
+ supersededId: String(r[1]),
320
+ missing: "fk-backfill",
321
+ });
322
+ }
323
+ reader = await session.conn.runAndReadAll(`SELECT a."superseded_by_id", a."id" FROM "adrs" a
324
+ WHERE COALESCE(a."superseded_by_id", '') <> ''
325
+ AND NOT EXISTS (SELECT 1 FROM "adr_supersedes" s
326
+ WHERE s."superseded_adr_id" = a."id"
327
+ AND s."superseding_adr_id" = a."superseded_by_id")
328
+ ORDER BY 1, 2;`);
329
+ for (const r of reader.getRows()) {
330
+ supersessionDrift.push({
331
+ supersedingId: String(r[0]),
332
+ supersededId: String(r[1]),
333
+ missing: "edge-row",
334
+ });
335
+ }
336
+ return { ownedUntraced, publisherDrift, supersessionDrift };
337
+ }
338
+ //# sourceMappingURL=gate-checks.js.map
@@ -0,0 +1,329 @@
1
+ /**
2
+ * Graph projection — `sdd-cli catalogue graph` (M-ADR 0205 §4.6, Phase C0b).
3
+ *
4
+ * Derives a typed node/edge list (JSON Lines) from built catalogues. The
5
+ * projection is DERIVED, never authored, never a second source of truth:
6
+ * regenerated on every run, digest-stamped, not committed. The derivation
7
+ * rule is the C0a rule table (catalogue-engine-evolution plan), keyed on
8
+ * the rubric-coverage-map's `kind` × the schema's FK arity:
9
+ *
10
+ * entity (less `applications`) → nodes, id = "<catalogue>/<pk>"
11
+ * entity FK columns → edges typed "<catalogue>.<column>"
12
+ * edge, 2 FKs → edges, declaration order = source→target;
13
+ * a row with an empty nullable endpoint
14
+ * degrades to a property record, never an edge
15
+ * edge, 3 FKs → edges with declared endpoint pairs
16
+ * (THREE_FK_ENDPOINTS below)
17
+ * edge, 1 FK → property records on the FK owner
18
+ * `applications` → excluded (C0a ruling 2026-08-06: filtered
19
+ * view of products — its rows ARE product
20
+ * rows; emitting it creates edge-less
21
+ * shadow nodes beside the products nodes)
22
+ * finding / enum kinds → excluded (diagnostics / vocabularies)
23
+ *
24
+ * Emission reads the PARSED CSV ROWS, deliberately not an engine readback:
25
+ * the engine's load TRY_CASTs typed columns and silently nulls unparseable
26
+ * cells (a real, measured case: prose in a date-typed column). A projection
27
+ * that inherits cast smoothing is no longer a faithful derivation of the
28
+ * CSV canon. C1's impact queries run on the engine; this emission does not.
29
+ *
30
+ * Every edge endpoint and property owner MUST resolve to a node record in
31
+ * the same export — a dangling reference here is a defect in the projection,
32
+ * not a finding about the estate (the FK layer already reports those). A
33
+ * candidate whose endpoint does not resolve is OMITTED and COUNTED, loudly,
34
+ * in the header and summary: `omitted_external` for external /
35
+ * external-for-profiles FKs (expected at member tier, resolving at
36
+ * aggregate), `omitted_unresolved` for the rest (mirrors an FK-layer
37
+ * finding). Stub nodes are never emitted — inventing nodes is the failure
38
+ * mode the C0a rule exists to prevent.
39
+ */
40
+ import { createHash } from "node:crypto";
41
+ /** Node-kind catalogues excluded from projection by C0a ruling. */
42
+ const EXCLUDED_NODE_CATALOGUES = new Set(["applications"]);
43
+ /**
44
+ * Declared endpoint pairs for the three 3-FK edge catalogues (C0a).
45
+ * `product_platform_dependencies` is SERVICE-FIRST — the aggregator already
46
+ * derives product→capability (cross_sdd_platform_capability) by joining
47
+ * target_service_id, so a capability-first rule would emit the same
48
+ * relationship twice; its resolution is per-row in projectRow below.
49
+ */
50
+ const THREE_FK_ENDPOINTS = {
51
+ system_external_dependency: ["c4_system_id", "external_system_id"],
52
+ cross_sdd_platform_capability: [
53
+ "consuming_product_id",
54
+ "platform_capability_id",
55
+ ],
56
+ product_platform_dependencies: null, // per-row: target_service_id, else target_capability_id
57
+ // schema 3.9.0 (M-ADR 0211 §4.1): the third FK, contract_id, is a PROPERTY of the
58
+ // capability→service dependency — the contract it is against — not an endpoint. The
59
+ // edge stays capability→service; the contract rides as a property.
60
+ capability_service: ["capability_id", "service_id"],
61
+ };
62
+ /**
63
+ * Kinds must cover every schema catalogue — a catalogue without a kind
64
+ * cannot be classified and the projection must not guess (the map is
65
+ * guarded method-side by the entity-scoring-coverage correspondence
66
+ * instance; this is the consumer-side half of that guarantee).
67
+ */
68
+ export function assertKindCoverage(schema, kinds) {
69
+ const missing = schema.catalogues
70
+ .map((c) => c.name)
71
+ .filter((n) => !kinds.has(n));
72
+ if (missing.length > 0) {
73
+ throw new Error(`rubric-coverage-map has no kind for: ${missing.join(", ")} — ` +
74
+ `sync the vendored map (it must cover every schema catalogue).`);
75
+ }
76
+ }
77
+ export function projectGraph(schema, kinds, rowsByCatalogue, tier,
78
+ /**
79
+ * When provided (M-ADR 0206 §4.6), every edge record is stamped with its
80
+ * declared `class` and RESOLVED `impact_flow` (role-conditional flows
81
+ * resolve per row), so consumers need no map lookup. Additive fields; the
82
+ * export regenerates per run, so the format change costs one restated
83
+ * determinism proof.
84
+ */
85
+ semantics) {
86
+ assertKindCoverage(schema, kinds);
87
+ const stampFor = (type, props) => {
88
+ if (semantics === undefined)
89
+ return {};
90
+ const decl = type.includes(".")
91
+ ? semantics.entityFks.get(type)
92
+ : semantics.edges.get(type);
93
+ if (decl === undefined) {
94
+ throw new Error(`graph: relationship ${type} has no declaration in the ` +
95
+ `rubric-coverage-map (M-ADR 0206) — sync the vendored map.`);
96
+ }
97
+ const flow = decl.impactFlow ??
98
+ decl.impactFlowBy.values[(props[decl.impactFlowBy.column] ?? "").trim()];
99
+ if (flow === undefined) {
100
+ throw new Error(`graph: ${type} resolves flow by column '${decl.impactFlowBy.column}' ` +
101
+ `but the row value is not in the declared map.`);
102
+ }
103
+ return { class: decl.class, impact_flow: flow };
104
+ };
105
+ const nodeCats = schema.catalogues.filter((c) => kinds.get(c.name) === "entity" && !EXCLUDED_NODE_CATALOGUES.has(c.name));
106
+ // `annotation` (M-ADR 0133 §4.2b; kinds corrected at schema 3.9.0): a single-FK assertion
107
+ // ABOUT one entity. It takes the same path as a 1-FK edge — a property record on the FK
108
+ // owner — so it joins the edge set here rather than vanishing from the projection.
109
+ const edgeCats = schema.catalogues.filter((c) => kinds.get(c.name) === "edge" || kinds.get(c.name) === "annotation");
110
+ // Pass 1 — the node set. Endpoint resolution below is against THIS
111
+ // export's nodes, which is exactly the per-export invariant.
112
+ const nodeIds = new Set();
113
+ const nodeLines = [];
114
+ for (const cat of nodeCats) {
115
+ for (const row of rowsByCatalogue.get(cat.name) ?? []) {
116
+ const key = cell(row, cat.primaryKey);
117
+ const id = `${cat.name}/${key}`;
118
+ nodeIds.add(id);
119
+ nodeLines.push(jsonLine({
120
+ t: "node",
121
+ id,
122
+ catalogue: cat.name,
123
+ key,
124
+ properties: propsOf(cat, row, new Set([cat.primaryKey])),
125
+ }));
126
+ }
127
+ }
128
+ const resolveTarget = (fk, value) => {
129
+ const [refCat, refCol] = fk.references.split(".");
130
+ // The rule resolves FK values against node PKs; a reference to a
131
+ // non-PK column would resolve against the wrong namespace. All 127
132
+ // parsed FKs reference `.id` today — guard it rather than assume it.
133
+ const refDef = schema.catalogues.find((c) => c.name === refCat);
134
+ if (refDef && refDef.primaryKey !== refCol) {
135
+ throw new Error(`FK ${fk.column} references non-PK column ${fk.references} — ` +
136
+ `the projection cannot resolve non-PK references.`);
137
+ }
138
+ const target = `${refCat}/${value}`;
139
+ return nodeIds.has(target) ? target : null;
140
+ };
141
+ const isExternal = (fk) => fk.external || (fk.externalForProfiles ?? []).length > 0;
142
+ const edgeLines = [];
143
+ const propertyLines = [];
144
+ const omissions = { external: 0, unresolved: 0 };
145
+ const emitEdge = (cat, row, type, sourceFk, targetFk) => {
146
+ const sv = cell(row, sourceFk.column);
147
+ const tv = cell(row, targetFk.column);
148
+ const source = resolveTarget(sourceFk, sv);
149
+ const target = resolveTarget(targetFk, tv);
150
+ if (source === null || target === null) {
151
+ const failedFk = source === null ? sourceFk : targetFk;
152
+ if (isExternal(failedFk))
153
+ omissions.external += 1;
154
+ else
155
+ omissions.unresolved += 1;
156
+ return;
157
+ }
158
+ const props = propsOf(cat, row, new Set([sourceFk.column, targetFk.column]));
159
+ edgeLines.push(jsonLine({
160
+ t: "edge",
161
+ type,
162
+ ...stampFor(type, props),
163
+ source,
164
+ target,
165
+ properties: props,
166
+ }));
167
+ };
168
+ const emitProperty = (cat, row, ownerFk) => {
169
+ const ov = cell(row, ownerFk.column);
170
+ const owner = ov === "" ? null : resolveTarget(ownerFk, ov);
171
+ if (owner === null) {
172
+ if (ov !== "" && isExternal(ownerFk))
173
+ omissions.external += 1;
174
+ else
175
+ omissions.unresolved += 1;
176
+ return;
177
+ }
178
+ propertyLines.push(jsonLine({
179
+ t: "property",
180
+ owner,
181
+ catalogue: cat.name,
182
+ via: ownerFk.column,
183
+ properties: propsOf(cat, row, new Set([ownerFk.column])),
184
+ }));
185
+ };
186
+ // Pass 2a — edges from edge catalogues, schema declaration order.
187
+ for (const cat of edgeCats) {
188
+ const fks = cat.foreignKeys;
189
+ for (const row of rowsByCatalogue.get(cat.name) ?? []) {
190
+ if (fks.length === 1) {
191
+ // Attribute table the metamodel calls an edge: the other "end" is
192
+ // an enum/key/name — a property record, never an edge.
193
+ emitProperty(cat, row, fks[0]);
194
+ continue;
195
+ }
196
+ let sourceFk;
197
+ let targetFk;
198
+ if (fks.length === 2) {
199
+ [sourceFk, targetFk] = fks;
200
+ }
201
+ else if (cat.name === "product_platform_dependencies") {
202
+ sourceFk = fks.find((f) => f.column === "product_id");
203
+ const svc = cell(row, "target_service_id");
204
+ const cap = cell(row, "target_capability_id");
205
+ const targetCol = svc !== ""
206
+ ? "target_service_id"
207
+ : cap !== ""
208
+ ? "target_capability_id"
209
+ : null;
210
+ if (targetCol === null) {
211
+ // Neither target declared: property-only row on the product.
212
+ emitProperty(cat, row, sourceFk);
213
+ continue;
214
+ }
215
+ targetFk = fks.find((f) => f.column === targetCol);
216
+ }
217
+ else {
218
+ const pair = THREE_FK_ENDPOINTS[cat.name];
219
+ if (pair === undefined || pair === null) {
220
+ throw new Error(`edge catalogue ${cat.name} has ${fks.length} FKs and no declared ` +
221
+ `endpoint pair — extend THREE_FK_ENDPOINTS (a C0a-level decision, ` +
222
+ `not a default).`);
223
+ }
224
+ sourceFk = fks.find((f) => f.column === pair[0]);
225
+ targetFk = fks.find((f) => f.column === pair[1]);
226
+ }
227
+ if (sourceFk === undefined || targetFk === undefined) {
228
+ throw new Error(`edge catalogue ${cat.name}: declared endpoint column missing from ` +
229
+ `schema foreign_keys — schema and rule table disagree.`);
230
+ }
231
+ const sv = cell(row, sourceFk.column);
232
+ const tv = cell(row, targetFk.column);
233
+ if (sv === "" || tv === "") {
234
+ // Empty nullable endpoint: degrade to a property record on the
235
+ // present endpoint (screen_action is the live case — measured
236
+ // 2026-08-06: all 85+1,652 rows on both estates have no event_id).
237
+ const present = sv !== "" ? sourceFk : tv !== "" ? targetFk : null;
238
+ if (present === null) {
239
+ omissions.unresolved += 1; // no endpoint at all — counted, never guessed
240
+ continue;
241
+ }
242
+ emitProperty(cat, row, present);
243
+ continue;
244
+ }
245
+ emitEdge(cat, row, cat.name, sourceFk, targetFk);
246
+ }
247
+ }
248
+ // Pass 2b — edges carried on node rows (41 FK columns on 22 catalogues;
249
+ // 34–40% of all edges on the measured estates — the third of the graph
250
+ // the naive "edge catalogues = edges" rule drops).
251
+ for (const cat of nodeCats) {
252
+ for (const fk of cat.foreignKeys) {
253
+ for (const row of rowsByCatalogue.get(cat.name) ?? []) {
254
+ const value = cell(row, fk.column);
255
+ if (value === "")
256
+ continue;
257
+ const source = `${cat.name}/${cell(row, cat.primaryKey)}`;
258
+ const target = resolveTarget(fk, value);
259
+ if (target === null) {
260
+ if (isExternal(fk))
261
+ omissions.external += 1;
262
+ else
263
+ omissions.unresolved += 1;
264
+ continue;
265
+ }
266
+ edgeLines.push(jsonLine({
267
+ t: "edge",
268
+ type: `${cat.name}.${fk.column}`,
269
+ ...stampFor(`${cat.name}.${fk.column}`, {}),
270
+ source,
271
+ target,
272
+ properties: {},
273
+ }));
274
+ }
275
+ }
276
+ }
277
+ const body = [...nodeLines, ...edgeLines, ...propertyLines];
278
+ const bodyText = body.map((l) => `${l}\n`).join("");
279
+ const digest = createHash("sha256").update(bodyText, "utf8").digest("hex");
280
+ const counts = {
281
+ nodes: nodeLines.length,
282
+ edges: edgeLines.length,
283
+ properties: propertyLines.length,
284
+ omittedExternal: omissions.external,
285
+ omittedUnresolved: omissions.unresolved,
286
+ };
287
+ const header = jsonLine({
288
+ t: "header",
289
+ format: "sdd-graph/1",
290
+ schema_version: schema.schemaVersion,
291
+ tier,
292
+ nodes: counts.nodes,
293
+ edges: counts.edges,
294
+ properties: counts.properties,
295
+ omitted_external: counts.omittedExternal,
296
+ omitted_unresolved: counts.omittedUnresolved,
297
+ digest: `sha256:${digest}`,
298
+ });
299
+ return { text: `${header}\n${bodyText}`, counts, digest };
300
+ }
301
+ /**
302
+ * Row columns as a properties object: schema column order (deterministic),
303
+ * empty string omitted (the schema's own convention — empty means absent),
304
+ * excluded columns (PK / endpoint / owner) carried structurally instead.
305
+ * Values stay the strings the CSV canon holds — no cast, no smoothing.
306
+ */
307
+ function propsOf(cat, row, exclude) {
308
+ const out = {};
309
+ for (const col of cat.columns) {
310
+ if (exclude.has(col.name))
311
+ continue;
312
+ const v = row[col.name];
313
+ const s = v === null || v === undefined ? "" : String(v);
314
+ if (s === "")
315
+ continue;
316
+ out[col.name] = s;
317
+ }
318
+ return out;
319
+ }
320
+ /** JSON.stringify with stable insertion-order keys — the whole line contract. */
321
+ function jsonLine(obj) {
322
+ return JSON.stringify(obj);
323
+ }
324
+ /** One cell as the trimmed string the CSV canon holds ('' = absent). */
325
+ function cell(row, column) {
326
+ const v = row[column];
327
+ return v === null || v === undefined ? "" : String(v).trim();
328
+ }
329
+ //# sourceMappingURL=graph-projection.js.map