@sdd-method/sdd-cli 1.18.0 → 1.20.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.
@@ -30,10 +30,14 @@ import { writeCatalogue } from "./csv-writer.js";
30
30
  import { buildLegacyContractIdMap } from "./contract-id.js";
31
31
  import { validateWithEngineToLog } from "./engine-validator.js";
32
32
  import { FindingsLog } from "./findings.js";
33
+ import { readLockFile } from "../lock-file.js";
33
34
  import { mergeEdgeLabel } from "./builders/edge-label-merge.js";
34
35
  import { isAsyncContractType } from "./builders/product-platform-dependencies.js";
35
36
  import { resolveContractPaths } from "./contract-path-resolution.js";
36
37
  import { buildRepoEdges } from "./builders/repo-edges.js";
38
+ import { buildCapabilitySpec } from "./builders/capability-spec.js";
39
+ import { buildFeatureGrounding } from "./builders/feature-grounding.js";
40
+ import { loadFeatureGroundingAliases } from "./feature-grounding-aliases-loader.js";
37
41
  import { buildFeatureContract } from "./builders/feature-contract.js";
38
42
  import { buildCapabilityEvent } from "./builders/event-edges.js";
39
43
  import { discoverVocabulary } from "./application-archetype/vocabulary.js";
@@ -49,7 +53,7 @@ export async function runCatalogueAggregate(inputs, write) {
49
53
  const rootManifest = await loadSddManifest(sddPath, {
50
54
  cliVersion: CLI_VERSION,
51
55
  });
52
- write(` ecosystem-root: ${rootManifest.sddId} profile: ${rootManifest.profile}\n`);
56
+ write(` root: ${rootManifest.sddId} profile: ${rootManifest.profile} tier: ${rootManifest.profile === "organisation-profile" ? "organisation" : "platform"}\n`);
53
57
  // 1. Resolve aggregation-manifest path + load.
54
58
  //
55
59
  // Two anchors, deliberately, because the terms are different KINDS of path:
@@ -75,8 +79,9 @@ export async function runCatalogueAggregate(inputs, write) {
75
79
  ecosystemRootPath: sddPath,
76
80
  });
77
81
  if (aggManifest.ecosystemRoot !== rootManifest.sddId) {
78
- throw new Error(`aggregation manifest ecosystem_root="${aggManifest.ecosystemRoot}"\n` +
79
- ` does not match ecosystem-root SDD's sdd_id="${rootManifest.sddId}"\n` +
82
+ throw new Error(`aggregation manifest root_sdd_id="${aggManifest.ecosystemRoot}"\n` +
83
+ ` does not match the owning SDD's sdd_id="${rootManifest.sddId}"\n` +
84
+ ` (rule: root_sdd_id_consistent, M-ADR 0208 §4.3)\n` +
80
85
  ` (manifest: ${manifestPath})`);
81
86
  }
82
87
  write(` aggregation manifest: ${relative(sddPath, manifestPath)}\n`);
@@ -92,7 +97,12 @@ export async function runCatalogueAggregate(inputs, write) {
92
97
  // 2. Pull (filesystem only in v1) — pre-checked at manifest load. Now
93
98
  // cross-check each sibling's own .sdd/sdd-manifest.yaml + its
94
99
  // vendored schema vs the aggregator's.
95
- await crossCheckSiblings(sddPath, aggManifest.siblings, schema, write);
100
+ const crossCheck = await crossCheckSiblings(sddPath, aggManifest.siblings, schema, write);
101
+ if (aggManifest.rootFieldDeprecated) {
102
+ findings.add("convention", `aggregation manifest uses deprecated key \`ecosystem_root\` — rename to \`root_sdd_id\` (M-ADR 0208 §4.3; alias removed at 2.0.0)`);
103
+ }
104
+ // M-ADR 0208 §4.2/§4.4: the tier decides the platform_sdd_id fill rule.
105
+ const rootIsOrganisation = rootManifest.profile === "organisation-profile";
96
106
  // 3. Schema-version alignment was checked in crossCheckSiblings.
97
107
  // 4 + 5. Concatenate per-catalogue rows from the ecosystem-root + every
98
108
  // sibling, stamping source_sdd_id on node catalogues as we go.
@@ -101,9 +111,47 @@ export async function runCatalogueAggregate(inputs, write) {
101
111
  for (const cat of schema.catalogues) {
102
112
  rowsByCatalogue.set(cat.name, []);
103
113
  }
104
- perSiblingCounts.set(rootManifest.sddId, await mergeCatalogues(schema, rowsByCatalogue, join(sddPath, "catalogues"), rootManifest.sddId));
114
+ perSiblingCounts.set(rootManifest.sddId, await mergeCatalogues(schema, rowsByCatalogue, join(sddPath, "catalogues"), rootManifest.sddId, rootIsOrganisation ? null : rootManifest.sddId));
115
+ // source_sdd_id -> the platform the §4.4 fill rule assigned it. Kept for the final
116
+ // pass (fillDerivedPlatformStamps): rows the aggregator itself constructs AFTER the
117
+ // node loader (0189 adjudication conversions, re-derived edges) set source_sdd_id
118
+ // explicitly and would otherwise leave platform_sdd_id empty under FK PASS —
119
+ // the same silent shape as the empty platform leg on cross_sdd_platform_capability.
120
+ const platformBySource = new Map();
121
+ if (!rootIsOrganisation)
122
+ platformBySource.set(rootManifest.sddId, rootManifest.sddId);
105
123
  for (const sib of aggManifest.siblings) {
106
- perSiblingCounts.set(sib.sddId, await mergeCatalogues(schema, rowsByCatalogue, sib.cataloguesPath, sib.sddId));
124
+ // M-ADR 0208 §4.4 fill rule, by pull path. An empty stamp is UNRESOLVED and is
125
+ // reported below by checkPlatformKindUniqueness; it is never a key value.
126
+ let platformSddId;
127
+ if (!rootIsOrganisation) {
128
+ platformSddId = rootManifest.sddId; // every row in a platform-tier aggregate belongs to it
129
+ }
130
+ else if (sib.level === "organisation") {
131
+ platformSddId = null; // arrived stamped from the child's own platform-tier run
132
+ }
133
+ else {
134
+ // CATEGORY decides root-ness, not profile: platform-subsystem is platform-profile
135
+ // too, but it is a MEMBER contained by exactly one platform (M-ADR 0208 §4.1).
136
+ const sibCategory = crossCheck.siblingCategories.get(sib.sddId);
137
+ const declared = crossCheck.siblingDeclaredPlatform.get(sib.sddId);
138
+ if (sibCategory === "platform-root") {
139
+ platformSddId = sib.sddId; // a platform-root is its own platform
140
+ }
141
+ else if (declared !== undefined) {
142
+ platformSddId = declared; // a member that declares its platform gets it
143
+ }
144
+ else {
145
+ platformSddId = null;
146
+ findings.add("coverage", `platform_sdd_id: sibling "${sib.sddId}" (${sibCategory ?? crossCheck.siblingProfiles.get(sib.sddId) ?? "?"}) is a MEMBER pulled directly at organisation tier with no declared platform — rows left unstamped (M-ADR 0208 §4.4; federate it under its platform's aggregate, or set platform_sdd_id in its manifest)`);
147
+ }
148
+ }
149
+ if (crossCheck.deprecatedPlatformField.has(sib.sddId)) {
150
+ findings.add("convention", `sibling "${sib.sddId}" manifest uses deprecated key \`ecosystem_root\` — rename to \`platform_sdd_id\` (M-ADR 0208 §4.3; alias removed at 2.0.0)`);
151
+ }
152
+ if (platformSddId !== null)
153
+ platformBySource.set(sib.sddId, platformSddId);
154
+ perSiblingCounts.set(sib.sddId, await mergeCatalogues(schema, rowsByCatalogue, sib.cataloguesPath, sib.sddId, platformSddId));
107
155
  }
108
156
  // 5a. Derive cross_sdd_platform_capability — federated edge, schema 2.9.0.
109
157
  // Joins each consuming SDD's product_platform_dependencies rows against
@@ -113,7 +161,7 @@ export async function runCatalogueAggregate(inputs, write) {
113
161
  // emit nothing for this catalogue; the aggregator owns it entirely.
114
162
  // See content/plans/v0-9-0-cohort-sweep-derived-bundle.md
115
163
  // §"Cross-SDD platform-capability-edge" + ADR 0134.
116
- const crossSddRows = deriveCrossSddPlatformCapability(rowsByCatalogue.get("product_platform_dependencies") ?? [], rowsByCatalogue.get("capability_service") ?? [], findings);
164
+ const crossSddRows = deriveCrossSddPlatformCapability(rowsByCatalogue.get("product_platform_dependencies") ?? [], rowsByCatalogue.get("capability_service") ?? [], findings, rowsByCatalogue.get("capabilities") ?? []);
117
165
  rowsByCatalogue.set("cross_sdd_platform_capability", crossSddRows);
118
166
  // Every SDD root in this aggregation, by sdd_id: the ecosystem root plus each
119
167
  // sibling. This is precisely what the app tier does not have and cannot obtain.
@@ -190,15 +238,96 @@ export async function runCatalogueAggregate(inputs, write) {
190
238
  // it, and appending finished per-member joins never creates a row no input produced.
191
239
  //
192
240
  // §4.1's rule (a catalogue qualifies when it is derived AND at least one join input can
193
- // carry a cross-SDD reference) currently selects four: repo_capability, repo_contract,
241
+ // carry a cross-SDD reference) selects five: feature_repo, repo_capability, repo_contract,
194
242
  // feature_contract, capability_event. One shared mechanism (rederiveAppend below) runs
195
- // all four so a fifth qualifying catalogue is a new call, not new merge logic.
243
+ // all five so a sixth qualifying catalogue is a new call, not new merge logic.
244
+ //
245
+ // feature_repo moved into scope under §4.1's "automatic movement" clause: its derived leg
246
+ // resolves free-text evidence tokens (M-ADR 0143 §4.4) against `repositories.id`, and the
247
+ // merged repositories table carries every sibling's repos — a platform spec that names a
248
+ // repo registered by a MEMBER SDD can only resolve here. The spec text is not a catalogue,
249
+ // so the derived leg is re-run the way §4.2 describes for every builder: the two feature
250
+ // extractors are invoked over each manifest-read contributor's docs with the MERGED
251
+ // repository / service / capability id sets as their resolution inputs. Only their
252
+ // feature_repo output is kept; feature rows and their per-row findings were already
253
+ // emitted at member tier and are not repeated. level:organisation siblings expose no docs
254
+ // (pre-built catalogues-aggregated/ only) and are skipped — their rows arrived from their
255
+ // own platform-tier run, where this pass has already happened.
196
256
  //
197
257
  // §4.2: only the derived leg is re-run — each builder is invoked with its authored-leg
198
258
  // inputs (configs / manifests) empty, which runs zero iterations of that path and yields
199
259
  // the derived rows alone; the authored rows are already correct via concatenation and are
200
260
  // left untouched. §4.2/§4.3: rederiveAppend adds the result to what concatenation already
201
261
  // produced, keeping the concatenated row on any primary-key collision — never a replace.
262
+ {
263
+ const mergedRepoIds = new Set((rowsByCatalogue.get("repositories") ?? []).map((r) => String(r["id"])));
264
+ const mergedServiceIds = new Set((rowsByCatalogue.get("services") ?? []).map((r) => String(r["id"])));
265
+ const mergedCapabilityIds = new Set((rowsByCatalogue.get("capabilities") ?? []).map((r) => String(r["id"])));
266
+ const mergedFeatureIds = new Set((rowsByCatalogue.get("features") ?? []).map((r) => String(r["id"])));
267
+ // Re-derivation FILLS ABSENCES; it does not widen. A feature its own member
268
+ // build already anchored keeps exactly those anchors: re-running bare-token
269
+ // resolution against every sibling's repo ids over an already-resolved row
270
+ // mostly finds collisions (measured on one estate: 13 of the 43 rows it would
271
+ // have added to anchored features resolved to repo ids like `web`, `proxy`,
272
+ // `service`, `batch` owned by unrelated SDDs; of the 261 rows added to
273
+ // UNANCHORED features, 4 did). The motivating case — and §4.1's clause — is a
274
+ // row no member could produce, which is exactly an unanchored feature.
275
+ const anchoredFeatureIds = new Set((rowsByCatalogue.get("feature_repo") ?? []).map((r) => String(r["feature_id"])));
276
+ const contributors = [
277
+ { sddPath, profile: rootManifest.profile, sddId: rootManifest.sddId },
278
+ ];
279
+ for (const sib of aggManifest.siblings) {
280
+ if (sib.level === "organisation")
281
+ continue;
282
+ const profile = crossCheck.siblingProfiles.get(sib.sddId);
283
+ if (profile === undefined)
284
+ continue;
285
+ contributors.push({ sddPath: sib.sddRoot, profile, sddId: sib.sddId });
286
+ }
287
+ const rederivedFeatureRepo = [];
288
+ // Both extractors, over several contributors, can yield the same edge (the same
289
+ // feature grounded in the same repo from two tables); rederiveAppend dedups only
290
+ // against the CONCATENATED rows, so dedup the re-derived set itself here or the
291
+ // duplicate-PK check fails the aggregate on two copies of one correct fact.
292
+ const rederivedIds = new Set();
293
+ const scratch = new FindingsLog(); // member-tier findings already emitted; not repeated
294
+ for (const c of contributors) {
295
+ const aliases = await loadFeatureGroundingAliases(c.sddPath);
296
+ const common = {
297
+ sddPath: c.sddPath,
298
+ findings: scratch,
299
+ profile: c.profile,
300
+ productId: c.sddId,
301
+ knownCapabilityIds: mergedCapabilityIds,
302
+ knownServiceIds: mergedServiceIds,
303
+ knownRepoKeys: mergedRepoIds,
304
+ existingFeatureIds: new Set(),
305
+ aliases,
306
+ };
307
+ const [grounding, spec] = await Promise.all([
308
+ buildFeatureGrounding(common),
309
+ buildCapabilitySpec(common),
310
+ ]);
311
+ for (const fr of [...grounding.featureRepoRows, ...spec.featureRepoRows]) {
312
+ const featureId = String(fr["feature_id"] ?? "");
313
+ if (!mergedFeatureIds.has(featureId))
314
+ continue;
315
+ if (anchoredFeatureIds.has(featureId))
316
+ continue;
317
+ const id = String(fr["id"] ?? "");
318
+ if (id === "" || rederivedIds.has(id))
319
+ continue;
320
+ rederivedIds.add(id);
321
+ rederivedFeatureRepo.push(fr);
322
+ }
323
+ }
324
+ const before = (rowsByCatalogue.get("feature_repo") ?? []).length;
325
+ rederiveAppend(rowsByCatalogue, "feature_repo", rederivedFeatureRepo);
326
+ const added = (rowsByCatalogue.get("feature_repo") ?? []).length - before;
327
+ if (added > 0) {
328
+ findings.add("coverage", `feature_repo: ${added} row(s) re-derived at aggregate tier for features that had NO anchor at member tier — evidence tokens naming a repo registered by another contributor resolve only against the merged repositories; already-anchored features are not widened (M-ADR 0198 §4.1 as amended; ${contributors.length} contributor doc trees scanned)`);
329
+ }
330
+ }
202
331
  const repoEdges = buildRepoEdges({
203
332
  findings,
204
333
  configs: [],
@@ -266,7 +395,7 @@ export async function runCatalogueAggregate(inputs, write) {
266
395
  // tier alongside an existing one), each contributes the same method ADR ids. Merge
267
396
  // them (first-seen wins) rather than failing the duplicate-PK check. Platform
268
397
  // / domain ADRs are NOT deduped — a collision there is a genuine error.
269
- dedupMethodScopedAdrs(rowsByCatalogue, findings);
398
+ await dedupMethodScopedAdrs(rowsByCatalogue, findings, sddRootsById);
270
399
  // 5c. Federated reverse-metadata enrichment (ADR 0163 P2). Both reference
271
400
  // adopters centralise the service inventory in the platform-root, but for
272
401
  // reverse-method federations the per-service evidence (service-architecture.md)
@@ -305,8 +434,13 @@ export async function runCatalogueAggregate(inputs, write) {
305
434
  `${adjudication.containerConverted} container conversions (${adjudication.containerEdgesRewritten} edges → container_dependency, ` +
306
435
  `${adjudication.systemFallbackEdges} stuck edges → system_system_dependency at L1), ` +
307
436
  `${adjudication.exempted} exempted, ${adjudication.unverified} unverified\n`);
437
+ // 5f-bis. Rows constructed at THIS tier after the node loader (adjudication
438
+ // conversions, re-derived edges) carry source_sdd_id but no platform stamp.
439
+ // Fill them now that every derivation has run (M-ADR 0208 §4.4 as applied to
440
+ // aggregator-constructed rows); whatever stays empty is reported, never keyed on.
441
+ fillDerivedPlatformStamps(schema, rowsByCatalogue, platformBySource, findings);
308
442
  // 5g. Platform-kind uniqueness (M-ADR 0133 §4.2c open hygiene).
309
- checkPlatformKindUniqueness(rowsByCatalogue.get("c4_systems") ?? [], findings);
443
+ checkPlatformKindUniqueness(rowsByCatalogue.get("c4_systems") ?? [], findings, rootIsOrganisation ? "organisation" : "platform");
310
444
  // 5h. M-ADR 0203 §4.4 cross-SDD re-key: the contract-id migration derives
311
445
  // per-SDD, but contract-FK edges cross SDD boundaries — a federated SDD
312
446
  // that owns no contracts has an empty map, so its references keep the 2.x
@@ -511,20 +645,58 @@ export function dedupExternalSystems(rowsByCatalogue, findings) {
511
645
  }
512
646
  rowsByCatalogue.set("c4_external_systems", [...byId.values()]);
513
647
  }
514
- // Method-scoped ADRs (doc_path under docs/method/adr/) are the shared method
515
- // baseline, identical across method-based SDDs. Dedupe by id (first-seen wins),
516
- // preserving order and leaving platform/domain ADRs untouched. A conflicting
517
- // title/status on the same id emits a finding. Mirrors dedupExternalSystems.
518
- function dedupMethodScopedAdrs(rowsByCatalogue, findings) {
648
+ // Method-DISTRIBUTED ADRs are the shared method baseline, identical across
649
+ // method-based SDDs. Dedupe by id (first-seen wins), preserving order and leaving
650
+ // adopter-AUTHORED platform/domain ADRs untouched — a collision there is a genuine
651
+ // error and MUST still fail the duplicate-PK check. Mirrors dedupExternalSystems.
652
+ //
653
+ // WHAT COUNTS AS METHOD-DISTRIBUTED (M-ADR 0208 / gov F-E): a row whose doc_path is
654
+ // BUNDLE-MANAGED in the contributing SDD's method-baseline.lock. Provenance, not
655
+ // path prefix. The earlier test `startsWith("docs/method/adr/")` was a proxy for
656
+ // the same property and it failed the first time two platform-roots aggregated:
657
+ // the baseline also distributes docs/method/platform-adr/ (13 ADR-emitting files
658
+ // in platform-baseline v2.3.28), which the prefix missed, so identical
659
+ // bundle-managed rows from two roots collided as if adopter-authored. A prefix
660
+ // extension would have passed every test available that day and merged genuinely
661
+ // authored platform ADRs the next time an adopter wrote a colliding one; the lock
662
+ // already records exactly the property we mean.
663
+ //
664
+ // FALLBACK: a contributing SDD with no lock (fixtures; a repo that has never been
665
+ // synced) keeps the historical prefix test, so it is never LESS deduped than before.
666
+ async function dedupMethodScopedAdrs(rowsByCatalogue, findings, sddRootsById) {
519
667
  const rows = rowsByCatalogue.get("adrs");
520
668
  if (rows === undefined || rows.length === 0)
521
669
  return;
670
+ // source_sdd_id -> set of bundle-managed paths, or null when that SDD has no lock.
671
+ const managedBySdd = new Map();
672
+ for (const [sddId, root] of sddRootsById) {
673
+ const lock = await readLockFile(root);
674
+ if (lock === null) {
675
+ managedBySdd.set(sddId, null);
676
+ continue;
677
+ }
678
+ const managed = new Set();
679
+ for (const b of lock.bundles)
680
+ for (const mp of b.managedPaths)
681
+ managed.add(mp);
682
+ managedBySdd.set(sddId, managed);
683
+ }
684
+ const isMethodDistributed = (row) => {
685
+ const docPath = String(row["doc_path"] ?? "");
686
+ if (docPath === "")
687
+ return false;
688
+ const managed = managedBySdd.get(String(row["source_sdd_id"] ?? ""));
689
+ if (managed === undefined || managed === null) {
690
+ // No lock for this contributor: historical prefix test.
691
+ return docPath.startsWith("docs/method/adr/");
692
+ }
693
+ return managed.has(docPath);
694
+ };
522
695
  const seen = new Map();
523
696
  const out = [];
524
697
  for (const row of rows) {
525
- const isMethod = String(row["doc_path"] ?? "").startsWith("docs/method/adr/");
526
698
  const id = String(row["id"] ?? "");
527
- if (!isMethod || id === "") {
699
+ if (!isMethodDistributed(row) || id === "") {
528
700
  out.push(row);
529
701
  continue;
530
702
  }
@@ -538,10 +710,10 @@ function dedupMethodScopedAdrs(rowsByCatalogue, findings) {
538
710
  const a = String(existing[attr] ?? "");
539
711
  const b = String(row[attr] ?? "");
540
712
  if (a !== "" && b !== "" && a !== b) {
541
- findings.add("convention", `adrs: method-scoped id "${id}" has conflicting ${attr} across SDDs [${String(existing["source_sdd_id"] ?? "?")}, ${String(row["source_sdd_id"] ?? "?")}] — first-seen kept`);
713
+ findings.add("convention", `adrs: method-distributed id "${id}" has conflicting ${attr} across SDDs [${String(existing["source_sdd_id"] ?? "?")}, ${String(row["source_sdd_id"] ?? "?")}] — first-seen kept`);
542
714
  }
543
715
  }
544
- // duplicate method ADR — already kept first-seen; drop this copy.
716
+ // duplicate method-distributed ADR — already kept first-seen; drop this copy.
545
717
  }
546
718
  rowsByCatalogue.set("adrs", out);
547
719
  }
@@ -686,7 +858,9 @@ function findDuplicatePrimaryKeys(schema, rowsByCatalogue) {
686
858
  }
687
859
  return out;
688
860
  }
689
- async function mergeCatalogues(schema, rowsByCatalogue, cataloguesDir, sourceSddId) {
861
+ async function mergeCatalogues(schema, rowsByCatalogue, cataloguesDir, sourceSddId,
862
+ /** M-ADR 0208 §4.4: value to fill into `platform_sdd_id` when empty; null = do not fill. */
863
+ platformSddId) {
690
864
  const counts = new Map();
691
865
  let dirExists = true;
692
866
  try {
@@ -717,26 +891,35 @@ async function mergeCatalogues(schema, rowsByCatalogue, cataloguesDir, sourceSdd
717
891
  }
718
892
  throw err;
719
893
  }
720
- const stamped = stampSourceSddId(cat, parsed.rows, sourceSddId);
894
+ const stamped = stampColumn(cat, stampColumn(cat, parsed.rows, "source_sdd_id", sourceSddId), "platform_sdd_id", platformSddId);
721
895
  rowsByCatalogue.get(cat.name).push(...stamped);
722
896
  counts.set(cat.name, stamped.length);
723
897
  }
724
898
  return counts;
725
899
  }
726
- function stampSourceSddId(catalogue, rows, sourceSddId) {
727
- const hasColumn = catalogue.columns.some((c) => c.name === "source_sdd_id");
728
- if (!hasColumn)
900
+ /**
901
+ * Stamp `column` = `value` on every row that does not already carry one
902
+ * (preserve-if-set — chained aggregation, M-ADR 0134 §4.5 step 4). Used for
903
+ * `source_sdd_id` and, per M-ADR 0208 §4.4, for `platform_sdd_id`. A null value
904
+ * means "do not fill" (organisation tier, where the stamp arrived from below or
905
+ * is legitimately unresolved). No-op when the catalogue lacks the column.
906
+ */
907
+ function stampColumn(catalogue, rows, column, value) {
908
+ const hasColumn = catalogue.columns.some((c) => c.name === column);
909
+ if (!hasColumn || value === null)
729
910
  return rows.map((r) => ({ ...r }));
730
911
  return rows.map((r) => {
731
- if (r["source_sdd_id"] && r["source_sdd_id"].trim() !== "") {
732
- // Already stamped — preserve (chained aggregation).
912
+ if (String(r[column] ?? "").trim() !== "")
733
913
  return { ...r };
734
- }
735
- return { ...r, source_sdd_id: sourceSddId };
914
+ return { ...r, [column]: value };
736
915
  });
737
916
  }
738
917
  async function crossCheckSiblings(ecosystemRootPath, siblings, aggregatorSchema, write) {
739
918
  const findings = new FindingsLog();
919
+ const siblingProfiles = new Map();
920
+ const siblingCategories = new Map();
921
+ const siblingDeclaredPlatform = new Map();
922
+ const deprecatedPlatformField = new Set();
740
923
  for (const sib of siblings) {
741
924
  let sibProfile;
742
925
  if (sib.level === "organisation") {
@@ -761,7 +944,13 @@ async function crossCheckSiblings(ecosystemRootPath, siblings, aggregatorSchema,
761
944
  ` (rule: sibling_id_matches_sibling_manifest)`);
762
945
  }
763
946
  sibProfile = sibManifest.profile;
947
+ siblingCategories.set(sib.sddId, sibManifest.category);
948
+ if (sibManifest.platformSddId)
949
+ siblingDeclaredPlatform.set(sib.sddId, sibManifest.platformSddId);
950
+ if (sibManifest.platformFieldDeprecated)
951
+ deprecatedPlatformField.add(sib.sddId);
764
952
  }
953
+ siblingProfiles.set(sib.sddId, sibProfile);
765
954
  // Cross-check sibling's vendored catalogue-schema.yaml against the
766
955
  // declared compat range. The aggregator's own canonical schema is the
767
956
  // source of truth; the sibling's vendored copy is informational and
@@ -784,7 +973,7 @@ async function crossCheckSiblings(ecosystemRootPath, siblings, aggregatorSchema,
784
973
  }
785
974
  write(` ✓ ${sib.sddId} (${sibProfile})\n`);
786
975
  }
787
- return { findings };
976
+ return { findings, siblingProfiles, siblingCategories, siblingDeclaredPlatform, deprecatedPlatformField };
788
977
  }
789
978
  async function findVendoredSchema(sddRoot) {
790
979
  // Adopters typically vendor canonical schema indirectly via sdd-cli.
@@ -1681,18 +1870,113 @@ export function rekeyLegacyContractRefs(schema, rowsByCatalogue, findings) {
1681
1870
  return { rekeyed, deduped, ambiguous };
1682
1871
  }
1683
1872
  /**
1684
- * M-ADR 0133 §4.2c open hygiene, shipped with M-ADR 0189 §4.4: the
1685
- * `platform` kind is documented "exactly one per ecosystem after federation
1686
- * aggregation" and nothing enforced it. Exported for testing.
1873
+ * Fill `platform_sdd_id` on rows the aggregator constructed after the node
1874
+ * loader stamped its inputs (M-ADR 0208 §4.4). The node loader stamps every
1875
+ * row it MERGES; rows built at this tier from those inputs — 0189 §4.4
1876
+ * adjudication conversions into system_system_dependency / container_dependency,
1877
+ * re-derived edges — set source_sdd_id explicitly and were emitted with an empty
1878
+ * platform stamp under FK PASS (measured: 38 of 38 system_system_dependency rows
1879
+ * and 2 of 208 container_dependency rows on the forward reference estate, the
1880
+ * other 206 stamped by the loader; 26 of 26 and 13 of 194 on the reverse estate's
1881
+ * platform tier). Resolution order per row: the §4.4 fill-rule platform recorded
1882
+ * for its source_sdd_id; else the single stamp already observed on that source's
1883
+ * rows anywhere in the aggregate (a source whose rows carry two different stamps
1884
+ * is ambiguous and is NOT guessed). Whatever remains empty is reported as
1885
+ * coverage — an empty stamp is unresolved, never a key value. Exported for testing.
1886
+ */
1887
+ export function fillDerivedPlatformStamps(schema, rowsByCatalogue, platformBySource, findings) {
1888
+ const stamped = schema.catalogues.filter((c) => c.columns.some((col) => col.name === "platform_sdd_id") &&
1889
+ c.columns.some((col) => col.name === "source_sdd_id"));
1890
+ const observed = new Map();
1891
+ for (const cat of stamped) {
1892
+ for (const r of rowsByCatalogue.get(cat.name) ?? []) {
1893
+ const src = String(r["source_sdd_id"] ?? "").trim();
1894
+ const pf = String(r["platform_sdd_id"] ?? "").trim();
1895
+ if (src === "" || pf === "")
1896
+ continue;
1897
+ if (!observed.has(src))
1898
+ observed.set(src, new Set());
1899
+ observed.get(src).add(pf);
1900
+ }
1901
+ }
1902
+ const resolve = (src) => {
1903
+ const ruled = platformBySource.get(src);
1904
+ if (ruled !== undefined)
1905
+ return ruled;
1906
+ const seen = observed.get(src);
1907
+ if (seen !== undefined && seen.size === 1)
1908
+ return [...seen][0];
1909
+ return null;
1910
+ };
1911
+ let filled = 0;
1912
+ const unresolved = [];
1913
+ for (const cat of stamped) {
1914
+ const rows = rowsByCatalogue.get(cat.name);
1915
+ if (rows === undefined || rows.length === 0)
1916
+ continue;
1917
+ let catEmpty = 0;
1918
+ const next = rows.map((r) => {
1919
+ if (String(r["platform_sdd_id"] ?? "").trim() !== "")
1920
+ return r;
1921
+ const src = String(r["source_sdd_id"] ?? "").trim();
1922
+ const value = src === "" ? null : resolve(src);
1923
+ if (value === null) {
1924
+ catEmpty++;
1925
+ return r;
1926
+ }
1927
+ filled++;
1928
+ return { ...r, platform_sdd_id: value };
1929
+ });
1930
+ rowsByCatalogue.set(cat.name, next);
1931
+ if (catEmpty > 0)
1932
+ unresolved.push(`${cat.name} ${catEmpty}`);
1933
+ }
1934
+ if (filled > 0) {
1935
+ findings.add("inferred", `platform_sdd_id: ${filled} aggregator-constructed row(s) stamped from their source_sdd_id after derivation (M-ADR 0208 §4.4) — the node loader stamps only the rows it merges`);
1936
+ }
1937
+ if (unresolved.length > 0) {
1938
+ findings.add("coverage", `platform_sdd_id: rows still EMPTY after the final pass [${unresolved.join(", ")}] — source unknown to the fill rule, or its rows carry more than one stamp; unresolved, not a key (M-ADR 0208 §4.4)`);
1939
+ }
1940
+ }
1941
+ /**
1942
+ * M-ADR 0133 §4.2c open hygiene, shipped with M-ADR 0189 §4.4, TIER-GATED by
1943
+ * M-ADR 0208 §4.2 — gated, not skipped: a skipped check fails toward success.
1944
+ * platform tier: exactly one row with kind "platform" in the aggregate.
1945
+ * organisation tier: exactly one "platform" row PER DISTINCT platform_sdd_id,
1946
+ * and every "platform" row must carry a stamp — an empty
1947
+ * stamp is unresolved, never a key (0208 §4.4).
1948
+ * Exported for testing.
1687
1949
  */
1688
- export function checkPlatformKindUniqueness(systems, findings) {
1689
- const plats = systems
1690
- .filter((s) => String(s["kind"]) === "platform")
1950
+ export function checkPlatformKindUniqueness(systems, findings, tier = "platform") {
1951
+ const plats = systems.filter((s) => String(s["kind"]) === "platform");
1952
+ if (tier === "platform") {
1953
+ const ids = plats.map((s) => String(s["id"])).sort((a, b) => a.localeCompare(b));
1954
+ if (ids.length > 1) {
1955
+ findings.add("convention", `c4_systems: ${ids.length} rows with kind "platform" [${ids.join(", ")}] — ` +
1956
+ `exactly one per platform (M-ADR 0208 §4.2); this is a platform-tier aggregate`);
1957
+ }
1958
+ return;
1959
+ }
1960
+ const unstamped = plats
1961
+ .filter((s) => String(s["platform_sdd_id"] ?? "").trim() === "")
1691
1962
  .map((s) => String(s["id"]))
1692
1963
  .sort((a, b) => a.localeCompare(b));
1693
- if (plats.length > 1) {
1694
- findings.add("convention", `c4_systems: ${plats.length} rows with kind "platform" [${plats.join(", ")}] — ` +
1695
- `the metamodel expects exactly one per ecosystem after aggregation`);
1964
+ if (unstamped.length > 0) {
1965
+ findings.add("coverage", `c4_systems: ${unstamped.length} "platform" row(s) with EMPTY platform_sdd_id at organisation tier [${unstamped.join(", ")}] — unresolved, not a key (M-ADR 0208 §4.4)`);
1966
+ }
1967
+ const byPlatform = new Map();
1968
+ for (const s of plats) {
1969
+ const key = String(s["platform_sdd_id"] ?? "").trim();
1970
+ if (key === "")
1971
+ continue;
1972
+ const list = byPlatform.get(key) ?? [];
1973
+ list.push(String(s["id"]));
1974
+ byPlatform.set(key, list);
1975
+ }
1976
+ for (const [platform, ids] of byPlatform) {
1977
+ if (ids.length > 1) {
1978
+ findings.add("convention", `c4_systems: platform "${platform}" has ${ids.length} rows with kind "platform" [${ids.sort((a, b) => a.localeCompare(b)).join(", ")}] — exactly one per platform (M-ADR 0208 §4.2)`);
1979
+ }
1696
1980
  }
1697
1981
  }
1698
1982
  /**
@@ -1767,7 +2051,32 @@ async function fileExists(absPath) {
1767
2051
  *
1768
2052
  * Exported for testing.
1769
2053
  */
1770
- export function deriveCrossSddPlatformCapability(productPlatformDeps, capabilityServices, findings) {
2054
+ export function deriveCrossSddPlatformCapability(productPlatformDeps, capabilityServices, findings, capabilities = []) {
2055
+ // The edge's PLATFORM LEG. `capability_service` has no source_sdd_id column
2056
+ // (schema: id, capability_id, service_id, role), so the row that knows which
2057
+ // platform owns a capability is `capabilities` — its platform_sdd_id
2058
+ // (M-ADR 0208 §4.4) or, on pre-0208 output, its source_sdd_id. From
2059
+ // 2.9.0 (v0.22.0) until 1.19.2 this read `match["source_sdd_id"]` off the
2060
+ // capability_service row and was therefore "" on every emitted row, in
2061
+ // every estate — the id carried a visible empty slot (`a__b____cap__svc`).
2062
+ const platformByCapability = new Map();
2063
+ for (const cap of capabilities) {
2064
+ const id = String(cap["id"] ?? "").trim();
2065
+ if (id === "")
2066
+ continue;
2067
+ const leg = String(cap["platform_sdd_id"] ?? "").trim() ||
2068
+ String(cap["source_sdd_id"] ?? "").trim();
2069
+ if (leg !== "" && !platformByCapability.has(id))
2070
+ platformByCapability.set(id, leg);
2071
+ }
2072
+ const unresolvedLegs = new Set();
2073
+ const resolvePlatformLeg = (match) => {
2074
+ const capId = String(match["capability_id"] ?? "");
2075
+ const leg = platformByCapability.get(capId) ?? String(match["source_sdd_id"] ?? "").trim();
2076
+ if (leg === "" && capId !== "")
2077
+ unresolvedLegs.add(capId);
2078
+ return leg;
2079
+ };
1771
2080
  // Index capability_service rows by service_id for O(1) lookup. A service
1772
2081
  // may belong to multiple capabilities — keep them all so a dependency
1773
2082
  // can resolve into every capability that owns its service. Filter out
@@ -1827,7 +2136,7 @@ export function deriveCrossSddPlatformCapability(productPlatformDeps, capability
1827
2136
  continue;
1828
2137
  }
1829
2138
  for (const match of capMatches) {
1830
- const platformSddId = String(match["source_sdd_id"] ?? "");
2139
+ const platformSddId = resolvePlatformLeg(match);
1831
2140
  const id = [
1832
2141
  consumingSddId,
1833
2142
  consumingProductId,
@@ -1875,7 +2184,7 @@ export function deriveCrossSddPlatformCapability(productPlatformDeps, capability
1875
2184
  }
1876
2185
  }
1877
2186
  for (const match of matches) {
1878
- const platformSddId = String(match["source_sdd_id"] ?? "");
2187
+ const platformSddId = resolvePlatformLeg(match);
1879
2188
  const platformCapabilityId = String(match["capability_id"] ?? "");
1880
2189
  if (platformCapabilityId === "")
1881
2190
  continue;
@@ -1899,9 +2208,13 @@ export function deriveCrossSddPlatformCapability(productPlatformDeps, capability
1899
2208
  });
1900
2209
  }
1901
2210
  }
2211
+ if (unresolvedLegs.size > 0) {
2212
+ const caps = [...unresolvedLegs].sort((a, b) => a.localeCompare(b));
2213
+ findings.add("coverage", `cross_sdd_platform_capability: platform leg (platform_sdd_id) unresolved for ${caps.length} capability id(s) [${caps.join(", ")}] — no capabilities row carries platform_sdd_id or source_sdd_id for them; the edge id has an empty slot`);
2214
+ }
1902
2215
  return rows;
1903
2216
  }
1904
2217
  // Used by tests that want to round-trip CSV through the aggregator's
1905
2218
  // stamping logic without writing files.
1906
- export const __test_only__ = { stampSourceSddId, parseCatalogueCsv };
2219
+ export const __test_only__ = { stampColumn, parseCatalogueCsv };
1907
2220
  //# sourceMappingURL=aggregate.js.map