@sdd-method/author-cli 0.0.0-stage → 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 (203) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +17 -0
  3. package/README.md +6 -2
  4. package/dist/author-bin.js +8 -0
  5. package/dist/author-program.js +99 -0
  6. package/dist/lib/adr-origin.js +59 -0
  7. package/dist/lib/agent-harness.js +37 -0
  8. package/dist/lib/api-surface/__fixtures__/serverless-repo/serverless/serverless.yml +18 -0
  9. package/dist/lib/api-surface/analyzers/library.js +95 -0
  10. package/dist/lib/api-surface/analyzers/serverless.js +336 -0
  11. package/dist/lib/api-surface/analyzers/spring.js +237 -0
  12. package/dist/lib/api-surface/analyzers/terraform.js +109 -0
  13. package/dist/lib/api-surface/detect.js +25 -0
  14. package/dist/lib/api-surface/emit.js +109 -0
  15. package/dist/lib/api-surface/file-set.js +43 -0
  16. package/dist/lib/api-surface/index.js +134 -0
  17. package/dist/lib/api-surface/types.js +17 -0
  18. package/dist/lib/briefing/index.js +13 -0
  19. package/dist/lib/briefing/render.js +132 -0
  20. package/dist/lib/briefing/variables.js +92 -0
  21. package/dist/lib/catalogue/application-archetype/vocabulary.js +177 -0
  22. package/dist/lib/catalogue/archetype/maps/android.yaml +66 -0
  23. package/dist/lib/catalogue/archetype/maps/swiftui.yaml +58 -0
  24. package/dist/lib/catalogue/archetype/maps/uikit.yaml +48 -0
  25. package/dist/lib/catalogue/archetype/maps/web-generic.yaml +86 -0
  26. package/dist/lib/catalogue/archetype/openui-archetype-vocabulary.yaml +46 -0
  27. package/dist/lib/catalogue/archetype/resolver.js +103 -0
  28. package/dist/lib/catalogue/archetype/wireframe.js +418 -0
  29. package/dist/lib/catalogue/baseline-check.js +151 -0
  30. package/dist/lib/catalogue/build.js +1502 -0
  31. package/dist/lib/catalogue/builders/adr-supersedes.js +103 -0
  32. package/dist/lib/catalogue/builders/adrs.js +222 -0
  33. package/dist/lib/catalogue/builders/application-conformance-coverage.js +47 -0
  34. package/dist/lib/catalogue/builders/application-data-plane-bindings.js +115 -0
  35. package/dist/lib/catalogue/builders/c4-components.js +229 -0
  36. package/dist/lib/catalogue/builders/c4-containers.js +240 -0
  37. package/dist/lib/catalogue/builders/c4-entity-rollups.js +87 -0
  38. package/dist/lib/catalogue/builders/c4-external-systems.js +194 -0
  39. package/dist/lib/catalogue/builders/c4-l1-candidates.js +107 -0
  40. package/dist/lib/catalogue/builders/c4-systems.js +98 -0
  41. package/dist/lib/catalogue/builders/capabilities.js +488 -0
  42. package/dist/lib/catalogue/builders/capability-domain-model.js +74 -0
  43. package/dist/lib/catalogue/builders/capability-edges.js +256 -0
  44. package/dist/lib/catalogue/builders/capability-spec-walker.js +99 -0
  45. package/dist/lib/catalogue/builders/capability-spec.js +348 -0
  46. package/dist/lib/catalogue/builders/container-dependency.js +183 -0
  47. package/dist/lib/catalogue/builders/contracts.js +402 -0
  48. package/dist/lib/catalogue/builders/data-items.js +344 -0
  49. package/dist/lib/catalogue/builders/data-store-mapping.js +113 -0
  50. package/dist/lib/catalogue/builders/domain-models.js +175 -0
  51. package/dist/lib/catalogue/builders/domains.js +119 -0
  52. package/dist/lib/catalogue/builders/edge-label-merge.js +19 -0
  53. package/dist/lib/catalogue/builders/entities-manifest.js +160 -0
  54. package/dist/lib/catalogue/builders/entities-mermaid.js +189 -0
  55. package/dist/lib/catalogue/builders/entity-persistence.js +102 -0
  56. package/dist/lib/catalogue/builders/entity-slug.js +47 -0
  57. package/dist/lib/catalogue/builders/event-catalog-walker.js +186 -0
  58. package/dist/lib/catalogue/builders/event-edges.js +327 -0
  59. package/dist/lib/catalogue/builders/events.js +168 -0
  60. package/dist/lib/catalogue/builders/evidence-class.js +43 -0
  61. package/dist/lib/catalogue/builders/evidence-target-repo.js +67 -0
  62. package/dist/lib/catalogue/builders/external-system-exemption.js +120 -0
  63. package/dist/lib/catalogue/builders/feature-contract.js +77 -0
  64. package/dist/lib/catalogue/builders/feature-domain-model.js +50 -0
  65. package/dist/lib/catalogue/builders/feature-grounding-walker.js +254 -0
  66. package/dist/lib/catalogue/builders/feature-grounding.js +391 -0
  67. package/dist/lib/catalogue/builders/feature-groups.js +63 -0
  68. package/dist/lib/catalogue/builders/feature-subscription-channels.js +228 -0
  69. package/dist/lib/catalogue/builders/feature-tree-walker.js +295 -0
  70. package/dist/lib/catalogue/builders/features.js +300 -0
  71. package/dist/lib/catalogue/builders/glossary-xref.js +378 -0
  72. package/dist/lib/catalogue/builders/glossary.js +204 -0
  73. package/dist/lib/catalogue/builders/implementations.js +214 -0
  74. package/dist/lib/catalogue/builders/index.js +64 -0
  75. package/dist/lib/catalogue/builders/integration-components.js +344 -0
  76. package/dist/lib/catalogue/builders/integrations.js +181 -0
  77. package/dist/lib/catalogue/builders/persona-c4system.js +149 -0
  78. package/dist/lib/catalogue/builders/persona-container.js +133 -0
  79. package/dist/lib/catalogue/builders/persona-journey-edges.js +133 -0
  80. package/dist/lib/catalogue/builders/personas.js +152 -0
  81. package/dist/lib/catalogue/builders/product-edges.js +106 -0
  82. package/dist/lib/catalogue/builders/product-platform-dependencies.js +405 -0
  83. package/dist/lib/catalogue/builders/products.js +162 -0
  84. package/dist/lib/catalogue/builders/quality-scores.js +635 -0
  85. package/dist/lib/catalogue/builders/repo-capability-exemption.js +61 -0
  86. package/dist/lib/catalogue/builders/repo-dependency.js +127 -0
  87. package/dist/lib/catalogue/builders/repo-edges.js +340 -0
  88. package/dist/lib/catalogue/builders/repositories.js +458 -0
  89. package/dist/lib/catalogue/builders/scaffolds.js +197 -0
  90. package/dist/lib/catalogue/builders/screen-tree.js +222 -0
  91. package/dist/lib/catalogue/builders/screens.js +396 -0
  92. package/dist/lib/catalogue/builders/service-capability-exemption.js +63 -0
  93. package/dist/lib/catalogue/builders/service-contract-consumes.js +186 -0
  94. package/dist/lib/catalogue/builders/service-contract.js +74 -0
  95. package/dist/lib/catalogue/builders/service-entity-tables.js +187 -0
  96. package/dist/lib/catalogue/builders/services.js +232 -0
  97. package/dist/lib/catalogue/builders/system-external-dependency.js +115 -0
  98. package/dist/lib/catalogue/builders/user-journeys.js +253 -0
  99. package/dist/lib/catalogue/canonical-schema.yaml +4117 -0
  100. package/dist/lib/catalogue/cli-version.js +10 -0
  101. package/dist/lib/catalogue/contract-id.js +49 -0
  102. package/dist/lib/catalogue/coverage-predicates.js +603 -0
  103. package/dist/lib/catalogue/coverage-render.js +552 -0
  104. package/dist/lib/catalogue/coverage-run.js +115 -0
  105. package/dist/lib/catalogue/csv-reader.js +97 -0
  106. package/dist/lib/catalogue/csv-writer.js +96 -0
  107. package/dist/lib/catalogue/data-plane-coherence.js +77 -0
  108. package/dist/lib/catalogue/ddl-generator.js +233 -0
  109. package/dist/lib/catalogue/dedupe-keyed-tables.js +0 -0
  110. package/dist/lib/catalogue/engine-validator.js +239 -0
  111. package/dist/lib/catalogue/ext-id.js +76 -0
  112. package/dist/lib/catalogue/feature-grounding-aliases-loader.js +65 -0
  113. package/dist/lib/catalogue/feature-repo-zero-resolution-summary.js +31 -0
  114. package/dist/lib/catalogue/finding-identity.js +183 -0
  115. package/dist/lib/catalogue/findings.js +46 -0
  116. package/dist/lib/catalogue/fk-validator.js +136 -0
  117. package/dist/lib/catalogue/freshness.js +70 -0
  118. package/dist/lib/catalogue/gate-checks.js +338 -0
  119. package/dist/lib/catalogue/graph-projection.js +346 -0
  120. package/dist/lib/catalogue/impact-run.js +228 -0
  121. package/dist/lib/catalogue/manifest-loader.js +435 -0
  122. package/dist/lib/catalogue/platform-scope.js +7 -0
  123. package/dist/lib/catalogue/population-checks.js +87 -0
  124. package/dist/lib/catalogue/relationship-semantics.js +66 -0
  125. package/dist/lib/catalogue/reverse-product-quality.js +99 -0
  126. package/dist/lib/catalogue/rubric-coverage-map.yaml +871 -0
  127. package/dist/lib/catalogue/runway-vocabulary-loader.js +56 -0
  128. package/dist/lib/catalogue/scaffold-delta.js +261 -0
  129. package/dist/lib/catalogue/schema-compat.js +52 -0
  130. package/dist/lib/catalogue/schema-loader.js +87 -0
  131. package/dist/lib/catalogue/scores-coverage.js +573 -0
  132. package/dist/lib/catalogue/screen-validation-loader.js +89 -0
  133. package/dist/lib/catalogue/service-architecture-parser.js +193 -0
  134. package/dist/lib/catalogue/service-contract-aliases-loader.js +109 -0
  135. package/dist/lib/catalogue/staleness-verdicts.js +345 -0
  136. package/dist/lib/catalogue/traceability-loader.js +350 -0
  137. package/dist/lib/catalogue/validate.js +200 -0
  138. package/dist/lib/catalogue/version.js +18 -0
  139. package/dist/lib/conformance/binding.js +70 -0
  140. package/dist/lib/conformance/finding.js +69 -0
  141. package/dist/lib/conformance/index.js +578 -0
  142. package/dist/lib/context-pull/index.js +191 -0
  143. package/dist/lib/context-pull/resolve.js +70 -0
  144. package/dist/lib/context-pull/retired-mcp-setup.js +7 -0
  145. package/dist/lib/contracts-availability/index.js +267 -0
  146. package/dist/lib/generate-briefing/index.js +77 -0
  147. package/dist/lib/list/discover.js +100 -0
  148. package/dist/lib/list/format.js +84 -0
  149. package/dist/lib/list/index.js +16 -0
  150. package/dist/lib/lock-file.js +437 -0
  151. package/dist/lib/pack/log.js +18 -0
  152. package/dist/lib/pack/schema.js +121 -0
  153. package/dist/lib/pack/seed.js +182 -0
  154. package/dist/lib/pack/validate.js +49 -0
  155. package/dist/lib/package-version.js +92 -0
  156. package/dist/lib/plan/index.js +69 -0
  157. package/dist/lib/plan/read-sdd.js +102 -0
  158. package/dist/lib/plan/render-template.js +84 -0
  159. package/dist/lib/repo-kind.js +129 -0
  160. package/dist/lib/scaffold/domain.js +234 -0
  161. package/dist/lib/scaffold/registry.js +136 -0
  162. package/dist/lib/scripts.js +7 -0
  163. package/dist/lib/sdd-index/repo-config.js +245 -0
  164. package/dist/lib/sdd-reader.js +91 -0
  165. package/dist/lib/snapshot/advance.js +454 -0
  166. package/dist/lib/snapshot/diff.js +190 -0
  167. package/dist/lib/snapshot/fold.js +208 -0
  168. package/dist/lib/snapshot/land.js +178 -0
  169. package/dist/lib/snapshot/manifest.js +206 -0
  170. package/dist/lib/snapshot/targets.js +169 -0
  171. package/dist/lib/snapshot/verify.js +91 -0
  172. package/dist/lib/sync/briefing-merge.js +106 -0
  173. package/dist/lib/sync/bundle-cache.js +109 -0
  174. package/dist/lib/sync/yaml-block-merge.js +120 -0
  175. package/dist/lib/validate/aggregate.js +51 -0
  176. package/dist/lib/validate/bundle-coherence.js +74 -0
  177. package/dist/lib/validate/dispatch.js +243 -0
  178. package/dist/lib/validate/index.js +110 -0
  179. package/dist/lib/validate/plan.js +54 -0
  180. package/dist/lib/validate/runner.js +85 -0
  181. package/dist/lib/validate/yaml-parse.js +112 -0
  182. package/dist/maintain-version.json +4 -0
  183. package/dist/verbs/api-surface.js +71 -0
  184. package/dist/verbs/catalogue-check.js +28 -0
  185. package/dist/verbs/catalogue-verify.js +114 -0
  186. package/dist/verbs/conformance.js +34 -0
  187. package/dist/verbs/context.js +39 -0
  188. package/dist/verbs/contracts.js +34 -0
  189. package/dist/verbs/generate-briefing.js +29 -0
  190. package/dist/verbs/list.js +45 -0
  191. package/dist/verbs/pack-validate.js +17 -0
  192. package/dist/verbs/path-option.js +25 -0
  193. package/dist/verbs/plan.js +42 -0
  194. package/dist/verbs/render.js +87 -0
  195. package/dist/verbs/scaffold.js +83 -0
  196. package/dist/verbs/snapshot.js +134 -0
  197. package/dist/verbs/validate.js +48 -0
  198. package/dist/verify-program.js +122 -0
  199. package/dist/verify-version.json +4 -0
  200. package/package.json +32 -4
  201. package/scripts/validate-app-dependencies.sh +664 -0
  202. package/scripts/validate-app-docs.sh +199 -0
  203. package/scripts/validate-integration-docs.sh +505 -0
@@ -0,0 +1,1502 @@
1
+ /**
2
+ * Per-SDD catalogue builder — `sdd-cli catalogue build` orchestrator.
3
+ *
4
+ * Per ADR 0134 §4.6, the canonical builder reads method-defined declarative
5
+ * inputs and emits `catalogues/*.csv` matching the canonical schema.
6
+ *
7
+ * This is Phase 1 of the canonical-builder implementation per
8
+ * adopt-canonical-catalogue-builder.md Step F. Phase 1 ships:
9
+ * - manifest loading + schema-version compat check,
10
+ * - schema loading,
11
+ * - headers-only emission for every catalogue declared in the schema,
12
+ * - internal FK validation,
13
+ * - findings + row-count reporting.
14
+ *
15
+ * Per-source discoverers (domains, repos, services, capabilities, features,
16
+ * etc.) land in subsequent phases. Until then the builder produces a valid
17
+ * but empty `catalogues/` directory — useful as a smoke test that the
18
+ * infrastructure works end-to-end on any SDD repo.
19
+ */
20
+ import { checkCapabilityServicePopulation } from "./population-checks.js";
21
+ import { basename, join, relative, resolve } from "node:path";
22
+ import { copyFile, mkdir, readFile, stat } from "node:fs/promises";
23
+ import { buildLegacyContractIdMap } from "./contract-id.js";
24
+ import { buildLegacyExtIdMap } from "./ext-id.js";
25
+ import { CLI_VERSION } from "./cli-version.js";
26
+ import { loadCanonicalSchema } from "./schema-loader.js";
27
+ import { loadSddManifest } from "./manifest-loader.js";
28
+ import { UNCLASSIFIED } from "./application-archetype/vocabulary.js";
29
+ import { checkDataPlanePlatformDepCoherence, checkPublishSurfaceCoherence, } from "./data-plane-coherence.js";
30
+ import { writeCatalogue } from "./csv-writer.js";
31
+ import { validatedPopulation, validateWithEngineToLog } from "./engine-validator.js";
32
+ import { dedupeKeyedTables } from "./dedupe-keyed-tables.js";
33
+ import { FindingsLog } from "./findings.js";
34
+ import { readCatalogueCsv } from "./csv-reader.js";
35
+ import { buildDataItems, buildFeatureDemand, buildIntegrationSupply } from "./builders/data-items.js";
36
+ import { buildEvidenceTargetRepos } from "./builders/evidence-target-repo.js";
37
+ import { buildRepoDependencies } from "./builders/repo-dependency.js";
38
+ 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";
39
+ import { buildIntegrationComponents } from "./builders/integration-components.js";
40
+ import { loadReverseProductQuality } from "./reverse-product-quality.js";
41
+ import { loadTraceabilityManifests } from "./traceability-loader.js";
42
+ import { findFeatureTreeFile, parseFeatureServicesTable, } from "./builders/feature-tree-walker.js";
43
+ import { loadRunwayVocabulary } from "./runway-vocabulary-loader.js";
44
+ import { loadFeatureGroundingAliases } from "./feature-grounding-aliases-loader.js";
45
+ import { loadServiceContractAliases } from "./service-contract-aliases-loader.js";
46
+ import { loadScreenValidationConfig } from "./screen-validation-loader.js";
47
+ import { zeroResolutionSummary } from "./feature-repo-zero-resolution-summary.js";
48
+ export async function runCatalogueBuild(inputs, write) {
49
+ const sddPath = resolve(inputs.sddPath ?? process.cwd());
50
+ const outDir = resolve(inputs.outDir ?? join(sddPath, "catalogues"));
51
+ // 1. Load canonical schema.
52
+ const schema = await loadCanonicalSchema();
53
+ write(`sdd-cli catalogue build (canonical schema ${schema.schemaVersion})\n`);
54
+ // 2. Load SDD manifest + compat check.
55
+ const manifest = await loadSddManifest(sddPath, { cliVersion: CLI_VERSION });
56
+ write(` sdd_id: ${manifest.sddId} profile: ${manifest.profile} category: ${manifest.category}\n`);
57
+ // 3. Build per-catalogue rows via discoverers.
58
+ const findings = new FindingsLog();
59
+ // A manifest key this build does not know is REPORTED rather than silently
60
+ // dropped. The usual cause is a manifest declaring a field from a newer schema
61
+ // than the installed sdd-cli — which previously validated clean and did
62
+ // nothing, so an adopter following the schema got no column and no complaint
63
+ // (M-ADR 0175's version history records exactly that happening).
64
+ if (manifest.unknownKeys && manifest.unknownKeys.length > 0) {
65
+ findings.add("convention", `.sdd/sdd-manifest.yaml: ${manifest.unknownKeys.length} key(s) not understood by ` +
66
+ `sdd-cli ${CLI_VERSION} and therefore IGNORED: ${manifest.unknownKeys.join(", ")}. ` +
67
+ `If the manifest follows a newer sdd-manifest-schema than this build, upgrade ` +
68
+ `sdd-cli; otherwise remove or correct the key.`);
69
+ }
70
+ const rowsByCatalogue = await collectRows(schema, manifest, findings, sddPath, inputs.snapshotStore);
71
+ // 3a. Normalise every keyed catalogue to PK-unique rows. Multi-source edge
72
+ // tables (feature_service, feature_repo) can have the same fact asserted by
73
+ // more than one walker; each walker's own seen-set only dedupes intra-walker.
74
+ // This union-dedupes identical rows across sources and hard-fails the build
75
+ // on same-PK/different-payload, so the per-SDD catalogue is set-correct over
76
+ // its declared PK before aggregation ever sees it. See
77
+ // `coordination/feature-edge-cross-source-duplicate-coordination.md`.
78
+ const dedupe = dedupeKeyedTables(schema, rowsByCatalogue, findings);
79
+ // 3b. Population collapse, before anything is overwritten. A whole score family can
80
+ // vanish while its catalogue keeps other rows: one estate's 71 traceability-manifest
81
+ // scores left quality_scores for a week (an input that stopped parsing) while FK,
82
+ // shape and validate all passed, because they pass on absence. Compare each family's
83
+ // row count with the committed file this build is about to replace; one that had rows
84
+ // and now has none fails the build (severity error): both estates read zero for it at the
85
+ // 1.38.0 wave, which was the stated condition for promotion (ruled 2026-10-01).
86
+ await reportCollapsedFamilies(outDir, join(sddPath, "catalogues"), rowsByCatalogue, findings);
87
+ // 4. Write CSVs (headers + rows).
88
+ const rowCounts = new Map();
89
+ const ownCatalogues = resolve(join(sddPath, "catalogues"));
90
+ for (const cat of schema.catalogues) {
91
+ const rows = rowsByCatalogue.get(cat.name) ?? [];
92
+ // A catalogue this build produces nothing for keeps the file on disk (below). Built
93
+ // with --out into another directory, there is no file there to keep, so a scratch
94
+ // build reported conformance_finding as emptied while an in-place build kept it —
95
+ // a measurement tool misreporting the thing measured. Seed it from the SDD's own
96
+ // catalogues/ first, so --out writes what an in-place build would.
97
+ if (rows.length === 0 && outDir !== ownCatalogues) {
98
+ const own = join(ownCatalogues, `${cat.name}.csv`);
99
+ const there = join(outDir, `${cat.name}.csv`);
100
+ const exists = async (p) => stat(p).then(() => true, () => false);
101
+ if ((await exists(own)) && !(await exists(there))) {
102
+ await mkdir(outDir, { recursive: true });
103
+ await copyFile(own, there);
104
+ }
105
+ }
106
+ const result = await writeCatalogue({ outDir, catalogue: cat, rows });
107
+ rowCounts.set(cat.name, result.rowCount);
108
+ // A preserved file is data this build could not reproduce. Saying nothing
109
+ // would move the silence rather than close it: the previous behaviour was
110
+ // also silent, and that is why 26 SDDs lost their conformance evidence
111
+ // while their registries went on citing it.
112
+ if (result.preserved) {
113
+ findings.add("convention", `catalogues/${cat.name}.csv: this build produced NO rows for it, and the file on disk ` +
114
+ `holds ${result.preservedRows}. The existing file was LEFT UNTOUCHED rather than ` +
115
+ `replaced with a bare header. Either the catalogue has no producer in this CLI — in ` +
116
+ `which case its rows are authored data, not build output, and nothing here will ever ` +
117
+ `regenerate them — or a builder that used to emit rows has stopped. Both are worth ` +
118
+ `resolving; neither is worth deleting the rows over.`);
119
+ }
120
+ }
121
+ // 5. Internal FK validation (external FKs logged as findings). Pass the
122
+ // SDD profile so profile-external FKs (schema 2.23.0, e.g. an app/integration
123
+ // capability's platform-resident domain_id per ADR 0136) resolve at
124
+ // aggregation instead of failing this local build.
125
+ const { fk, enums } = await validateWithEngineToLog(schema, rowsByCatalogue, findings, { profile: manifest.profile });
126
+ // 5b. Bound-enum validation (M-ADR 0203 §4.1): every column_enums-bound
127
+ // column hard-fails on an out-of-set value, like a dangling internal FK.
128
+ // The canonical builder cannot emit a non-canonical c4_containers.type
129
+ // (type derives from the macro), so at build time this is the defensive
130
+ // guard the 2.x advisory module used to be — with teeth.
131
+ // 6. Report.
132
+ write("\nRow counts:\n");
133
+ for (const [name, count] of rowCounts) {
134
+ if (count > 0)
135
+ write(` ${name}: ${count}\n`);
136
+ }
137
+ const emptyCount = [...rowCounts.values()].filter((c) => c === 0).length;
138
+ write(` (${emptyCount} headers-only)\n`);
139
+ if (dedupe.unionedRows > 0) {
140
+ write(`\nKeyed-table dedup: unioned ${dedupe.unionedRows} duplicate row(s) across multi-source edge tables\n`);
141
+ }
142
+ if (dedupe.conflicts.length > 0) {
143
+ write(`\nPrimary-key conflicts (${dedupe.conflicts.length}) — same PK, different payload:\n`);
144
+ for (const c of dedupe.conflicts) {
145
+ write(` ${c.catalogue}.${c.primaryKey}="${c.value}"\n`);
146
+ }
147
+ }
148
+ const findingsText = findings.render();
149
+ if (findingsText) {
150
+ write(`\n${findingsText}\n`);
151
+ }
152
+ if (fk.internalPass) {
153
+ write(`\nFK validation (internal): PASS (${validatedPopulation(rowsByCatalogue)})\n`);
154
+ }
155
+ else {
156
+ write(`\nFK validation (internal): FAIL (${fk.internalFailures.length} dangling)\n`);
157
+ for (const f of fk.internalFailures) {
158
+ write(` ${f.catalogue}.${f.column}: row pk=${f.referencingRow} → ${f.target}=${f.value || "<empty>"}\n`);
159
+ }
160
+ }
161
+ if (enums.pass) {
162
+ write(`Enum validation (column_enums): PASS (${validatedPopulation(rowsByCatalogue)})\n`);
163
+ }
164
+ else {
165
+ write(`Enum validation (column_enums): FAIL (${enums.violations.length} out-of-set)\n`);
166
+ for (const v of enums.violations) {
167
+ write(` ${v.catalogue}.${v.column}: row pk=${v.rowPk} value "${v.value}" (allowed: ${v.allowed})\n`);
168
+ }
169
+ }
170
+ write(`External findings: ${fk.externalFindings} (validated by aggregator)\n`);
171
+ const strictFail = inputs.strict === true && findings.count() > 0;
172
+ // A PK conflict means the build produced a catalogue that cannot satisfy the
173
+ // declared primary-key uniqueness — always a hard failure, like a dangling
174
+ // internal FK. (The silent-union path is not a failure: it is the expected
175
+ // outcome of two sources asserting the same fact.)
176
+ // Error-severity findings (e.g. the ADR 0168 screen-spec status gate) fail the
177
+ // build by default, independent of --strict.
178
+ const exitCode = !fk.internalPass || dedupe.conflicts.length > 0 || findings.errorCount() > 0 || strictFail
179
+ ? 1
180
+ : 0;
181
+ return {
182
+ exitCode,
183
+ schemaVersion: schema.schemaVersion,
184
+ sddId: manifest.sddId,
185
+ profile: manifest.profile,
186
+ category: manifest.category,
187
+ rowCounts,
188
+ internalFkPass: fk.internalPass,
189
+ externalFindings: fk.externalFindings,
190
+ findingsCount: findings.count(),
191
+ pkConflicts: dedupe.conflicts.length,
192
+ unionedRows: dedupe.unionedRows,
193
+ outDir,
194
+ };
195
+ }
196
+ /**
197
+ * Collect rows per catalogue. Each per-source discoverer populates one
198
+ * (or a small group) of catalogues; everything else stays headers-only
199
+ * until later phases land their builders.
200
+ *
201
+ * Order matters for catalogues that depend on each other:
202
+ * 1. domains (from docs/domains/*) — populates known-domain set
203
+ * 2. repositories (from orchestration/repo-config) — needs the domain
204
+ * set + the SDD's product_id
205
+ * 3. products (from manifest) — needs repo_count and a domain that
206
+ * resolves
207
+ * 4. final pass: backfill domains.repo_count from the discovered repos
208
+ */
209
+ async function collectRows(schema, manifest, findings, sddPath, snapshotStore) {
210
+ const out = new Map();
211
+ for (const cat of schema.catalogues) {
212
+ out.set(cat.name, []);
213
+ }
214
+ // 1. Domains — pure filesystem read, no dependencies.
215
+ const domains = await buildDomains({ sddPath, manifest, findings });
216
+ out.set("domains", domains);
217
+ const knownDomainIds = new Set(domains.map((d) => String(d["id"])));
218
+ // 2. Repositories — needs domain set + the SDD's product_id (= sddId).
219
+ const reposResult = await buildRepositories({
220
+ sddPath,
221
+ manifest,
222
+ schema,
223
+ findings,
224
+ knownDomainIds,
225
+ productId: manifest.sddId,
226
+ });
227
+ const repos = reposResult.rows;
228
+ out.set("repositories", repos);
229
+ // M-ADR 0214 §4.10: evidence naming repositories another SDD owns (external at build).
230
+ out.set("evidence_target_repo", await buildEvidenceTargetRepos(sddPath, new Set(repos.map((r) => String(r["id"]))), findings));
231
+ // 2a. Implementations, their members and snapshots (M-ADR 0214) — from the
232
+ // binding and its snapshot area; members resolve against the repositories above.
233
+ const impl = await buildImplementations({
234
+ sddPath,
235
+ knownRepoIds: new Set(repos.map((r) => String(r["id"]))),
236
+ ...(snapshotStore !== undefined ? { snapshotStore } : {}),
237
+ findings,
238
+ });
239
+ out.set("implementations", impl.implementations);
240
+ out.set("implementation_members", impl.implementationMembers);
241
+ out.set("snapshots", impl.snapshots);
242
+ out.set("snapshot_members", impl.snapshotMembers);
243
+ // 3. Products — emits ONE row for this SDD, with repo_count from above.
244
+ const products = buildProducts({
245
+ manifest,
246
+ repoCount: repos.length,
247
+ findings,
248
+ knownDomainIds,
249
+ });
250
+ // Product maturity columns from the reverse gate registry. Applied here rather
251
+ // than inside buildProducts because it needs the SDD path and is async, and
252
+ // because it is a BACKFILL: it only fills cells the builder left empty, so a
253
+ // score can never overwrite an emitted value. Empty on a forward estate by
254
+ // design — the columns are documented reverse-method only.
255
+ const quality = await loadReverseProductQuality(sddPath, manifest.sddId, findings);
256
+ const productsWithQuality = products.map((row) => ({
257
+ ...row,
258
+ // Backfill only: `?? row[...]` keeps whatever the builder emitted, so a score
259
+ // can never overwrite an existing value — it fills a blank or does nothing.
260
+ synthesis_quality: quality.quality ?? row["synthesis_quality"],
261
+ synthesis_band: quality.band ?? row["synthesis_band"],
262
+ last_assessed: quality.assessedAt ?? row["last_assessed"],
263
+ }));
264
+ out.set("products", productsWithQuality);
265
+ // Application-archetype coverage (M-ADR 0191 §4.1). At per-SDD build the
266
+ // `application_archetype` value is emitted OPEN — exactly as integration
267
+ // `profileType` is (an open enum at build; the closed set is enforced at
268
+ // authoring/init, the extensible tiers at aggregation). The per-SDD build
269
+ // does NOT resolve the value against the reference-architecture vocabulary
270
+ // and does NOT fail on an unknown value: that vocabulary is a platform/
271
+ // aggregation-tier concern (0173 tiers), and an application SDD legitimately
272
+ // may not consume the RA bundle at all. Coupling the app build to RA-bundle
273
+ // presence was wrong (it broke estates that deliver vocabulary at the
274
+ // aggregation tier). Only the "declare your shape" coverage nudge lives
275
+ // here — it is vocabulary-independent. Membership validation against the
276
+ // resolved vocabulary is an aggregation-tier check (fast-follow, where the
277
+ // RA bundle IS consumed), alongside the expectations overlay.
278
+ if (manifest.profile === "application-product" &&
279
+ (manifest.applicationArchetype === undefined ||
280
+ manifest.applicationArchetype === UNCLASSIFIED)) {
281
+ findings.add("coverage", `products: application_archetype not declared for ${manifest.sddId} — ` +
282
+ `defaulted to "unclassified". Declare the product's shape (M-ADR 0191).`);
283
+ }
284
+ // `applications.csv` is a filtered view: profile=application-product.
285
+ out.set("applications", products.filter((p) => p["profile"] === "application-product"));
286
+ // Application data-plane axis (M-ADR 0193) — bindings + declared deviations,
287
+ // emitted from the manifest. Malformed bindings/deviations already failed at
288
+ // manifest load; these shape valid ones into rows. The archetype-default WARN
289
+ // and the conformance surface (M-ADR 0191 §4.3) run at AGGREGATION tier, where
290
+ // the reference-architecture `expected_bindings` resolve — NOT here: an app
291
+ // SDD may consume an adopter context-pack rather than the RA bundle, so a
292
+ // per-SDD WARN would be inert for it and coupling the build to bundle presence
293
+ // is the mistake M-ADR 0191 §4.1 / the 0.92.1 membership move corrected.
294
+ out.set("application_data_plane_bindings", buildApplicationDataPlaneBindings({ manifest, findings }));
295
+ out.set("application_data_plane_deviations", buildApplicationDataPlaneDeviations({ manifest, findings }));
296
+ // Conformance coverage (M-ADR 0191 §4.3 WS4) — the product's declared coverage
297
+ // of / deviations from its archetype's conformance expectations. The
298
+ // conformance GATE that reads these runs at aggregation tier (like the binding
299
+ // overlay), where the reference-architecture `conformance` tags resolve.
300
+ out.set("application_conformance_coverage", buildApplicationConformanceCoverage({ manifest, findings }));
301
+ // 4. Backfill domains.repo_count from repos.
302
+ if (domains.length > 0) {
303
+ const repoCountByDomain = new Map();
304
+ for (const r of repos) {
305
+ const d = String(r["domain_id"] ?? "");
306
+ if (d === "")
307
+ continue;
308
+ repoCountByDomain.set(d, (repoCountByDomain.get(d) ?? 0) + 1);
309
+ }
310
+ out.set("domains", domains.map((d) => ({
311
+ ...d,
312
+ repo_count: repoCountByDomain.get(String(d["id"])) ?? 0,
313
+ })));
314
+ }
315
+ // 5. Services derived 1:1 from runtime-typed repositories.
316
+ // Forward `description` source (ADR 0163 P1): repo-config `description:`,
317
+ // parsed but previously dropped by the repositories builder.
318
+ const repoDescriptions = new Map();
319
+ const declaredServicesByRepo = new Map();
320
+ for (const cfg of reposResult.configs) {
321
+ const desc = (cfg.data.description ?? "").trim();
322
+ if (desc !== "")
323
+ repoDescriptions.set(cfg.repoId, desc);
324
+ if (cfg.data.services !== undefined) {
325
+ declaredServicesByRepo.set(cfg.repoId, cfg.data.services);
326
+ }
327
+ }
328
+ const servicesResult = await buildServices({
329
+ schema,
330
+ manifest,
331
+ findings,
332
+ repositories: repos,
333
+ productId: manifest.sddId,
334
+ sddPath,
335
+ repoDescriptions,
336
+ declaredServicesByRepo,
337
+ });
338
+ const services = servicesResult.rows;
339
+ const declaredNonRepoServiceIds = servicesResult.declaredNonRepoIds;
340
+ out.set("services", services);
341
+ // 6. ADRs + adr_supersedes — runs before capabilities edges so
342
+ // capability_adr can resolve.
343
+ const adrResult = await buildAdrs({
344
+ sddPath,
345
+ findings,
346
+ knownDomainIds,
347
+ profile: manifest.profile,
348
+ category: manifest.category,
349
+ });
350
+ const supersedes = buildAdrSupersedes({ findings, adrs: adrResult.parsed });
351
+ out.set("adr_supersedes", supersedes.rows);
352
+ // Backfill `superseded_by_id` on adrs.csv from the edge index.
353
+ const adrsWithBackfill = adrResult.rows.map((row) => {
354
+ const id = String(row["id"]);
355
+ const supersededBy = supersedes.supersededByIndex.get(id);
356
+ if (supersededBy === undefined)
357
+ return row;
358
+ return { ...row, superseded_by_id: supersededBy };
359
+ });
360
+ out.set("adrs", adrsWithBackfill);
361
+ const knownAdrIds = new Set(adrResult.parsed.map((a) => a.id));
362
+ // 7. Capabilities + capability_* edges from traceability YAMLs.
363
+ const traceability = await loadTraceabilityManifests({ sddPath, findings });
364
+ const capabilities = await buildCapabilities({
365
+ sddPath,
366
+ findings,
367
+ manifests: traceability,
368
+ knownDomainIds,
369
+ });
370
+ out.set("capabilities", capabilities);
371
+ // One feature tree per SDD (M-ADR 0056, amended 2026-09-29). An SDD that
372
+ // CLASSIFIES capabilities must carry docs/platform/feature-tree.md or declare
373
+ // the SDD whose tree holds its classifications. Neither is reported, so the
374
+ // absence is never silent — measured: three of one estate's four repos
375
+ // classified 16 capabilities with no tree and nothing said so. COVERAGE
376
+ // class: report-only, like every other coverage finding.
377
+ {
378
+ const classified = capabilities.filter((r) => String(r["runway_classification"] ?? "").trim() !== "").length;
379
+ if (classified > 0 &&
380
+ manifest.featureTreeAnchor === undefined &&
381
+ (await findFeatureTreeFile(sddPath)) === null) {
382
+ findings.add("coverage", `feature tree: this SDD classifies ${classified} capabilit${classified === 1 ? "y" : "ies"} ` +
383
+ `but carries no docs/platform/feature-tree.md and declares no feature_tree_anchor — ` +
384
+ `author the tree, or declare in .sdd/sdd-manifest.yaml the SDD whose tree holds these ` +
385
+ `classifications (M-ADR 0056, one tree per SDD)`);
386
+ }
387
+ }
388
+ const knownCapabilityIds = new Set(capabilities.map((c) => String(c["id"])));
389
+ // Slug → capability-id aliases for feature-grounding path resolution.
390
+ // The grounding walker derives a capability slug from the design dir
391
+ // name (docs/domains/<dom>/design/<slug>/), but a capability's declared
392
+ // id may legitimately differ from its path slug — e.g. design/alerts-rules
393
+ // maps to id "alerts-rules-engine". Map each capability's spec +
394
+ // feature-directory basename to its id so the walker resolves these
395
+ // without forcing a directory rename. Only non-identity, non-colliding
396
+ // slugs are aliased (first manifest wins).
397
+ const capabilitySlugToId = new Map();
398
+ for (const m of traceability) {
399
+ const id = m.capability.id;
400
+ for (const p of [m.capability.capability_spec, ...(m.capability.feature_directory ?? [])]) {
401
+ if (typeof p !== "string" || p.length === 0)
402
+ continue;
403
+ const slug = basename(p.replace(/\/+$/, "")).replace(/\.md$/, "");
404
+ if (slug !== "" &&
405
+ slug !== id &&
406
+ !knownCapabilityIds.has(slug) &&
407
+ !capabilitySlugToId.has(slug)) {
408
+ capabilitySlugToId.set(slug, id);
409
+ }
410
+ }
411
+ }
412
+ const knownServiceIds = new Set(services.map((s) => String(s["id"])));
413
+ // C4 L1 external systems are built here (rather than at step 15b) so the
414
+ // capability_service builder can categorise unresolved `services[].id`
415
+ // findings — an id naming a known external system (authzed, workos, …) is
416
+ // a convention finding, not an orphan. The rows are emitted at their usual
417
+ // place in step 15b; only the computation is hoisted.
418
+ const c4External = await buildC4ExternalSystems({
419
+ sddPath,
420
+ manifest,
421
+ findings,
422
+ });
423
+ const knownExternalSystemIds = new Set();
424
+ for (const row of c4External.rows) {
425
+ const extId = String(row["id"]);
426
+ knownExternalSystemIds.add(extId);
427
+ if (extId.startsWith("ext-"))
428
+ knownExternalSystemIds.add(extId.slice(4));
429
+ }
430
+ for (const entryId of c4External.idByEntryId.keys())
431
+ knownExternalSystemIds.add(entryId);
432
+ out.set("capability_service", buildCapabilityService({
433
+ findings,
434
+ manifests: traceability,
435
+ knownCapabilityIds,
436
+ knownServiceIds,
437
+ knownExternalSystemIds,
438
+ }));
439
+ out.set("capability_adr", buildCapabilityAdr({
440
+ findings,
441
+ manifests: traceability,
442
+ knownCapabilityIds,
443
+ knownAdrIds,
444
+ }));
445
+ // 8. Features + feature_groups (per Q7.2 first-class groups).
446
+ const featureGroups = await buildFeatureGroups({
447
+ sddPath,
448
+ findings,
449
+ productId: manifest.sddId,
450
+ });
451
+ out.set("feature_groups", featureGroups);
452
+ // Schema 2.2.0+: optional adopter-owned runway vocabulary at SDD root.
453
+ // Absent → no validation runs; bullet runway values accepted verbatim.
454
+ const runwayVocab = await loadRunwayVocabulary(sddPath);
455
+ const featuresResult = await buildFeatures({
456
+ sddPath,
457
+ findings,
458
+ manifests: traceability,
459
+ knownCapabilityIds,
460
+ profile: manifest.profile,
461
+ productId: manifest.sddId,
462
+ runwayVocabulary: runwayVocab?.values ?? null,
463
+ });
464
+ out.set("features", featuresResult.rows);
465
+ out.set("capability_feature", buildCapabilityFeature({
466
+ findings,
467
+ manifests: traceability,
468
+ knownCapabilityIds,
469
+ featureIdByPath: featuresResult.idByPath,
470
+ }));
471
+ // 9. Contracts + capability_contract edge.
472
+ const contractsResult = await buildContracts({
473
+ sddPath,
474
+ findings,
475
+ knownRepositoryIds: new Set(repos.map((r) => String(r["id"]))),
476
+ });
477
+ // D13 — declared-external contracts. A capability manifest may declare a
478
+ // contract living outside this SDD's discovery paths (the M-ADR 0110
479
+ // sibling-repo pattern that `.sdd-cli.yaml`'s `contracts_root` names). Only an
480
+ // ENTRY EXPLICITLY MARKED `external: true` qualifies, and the file is never
481
+ // read — reading it would make catalogue output depend on whether sibling
482
+ // repos are checked out, which breaks the committed-catalogue model.
483
+ //
484
+ // Runs immediately after discovery so its ids merge into the same path index
485
+ // capability_contract resolves against; inert until an adopter marks an entry.
486
+ const declaredExternal = buildDeclaredExternalContracts({
487
+ findings,
488
+ manifests: traceability.map((m) => ({
489
+ relPath: m.relPath,
490
+ domain: m.capability.domain || m.domainFromPath || "",
491
+ contracts: m.traceability.contracts,
492
+ })),
493
+ discoveredIdByPath: contractsResult.idByPath,
494
+ existingIds: new Set(contractsResult.rows.map((r) => String(r["id"]))),
495
+ });
496
+ const mergedContractIdByPath = new Map(contractsResult.idByPath);
497
+ for (const [k, v] of declaredExternal.idByPath)
498
+ mergedContractIdByPath.set(k, v);
499
+ out.set("contracts", [...contractsResult.rows, ...declaredExternal.rows]);
500
+ out.set("capability_contract", buildCapabilityContract({
501
+ findings,
502
+ manifests: traceability,
503
+ knownCapabilityIds,
504
+ contractIdByPath: mergedContractIdByPath,
505
+ }));
506
+ // 10. Repo edges — repo_capability, repo_contract (feature_repo empty
507
+ // here; populated by feature-grounding extractor below per ADR 0143).
508
+ const capabilityIdByPath = new Map();
509
+ for (const cap of capabilities) {
510
+ const sp = String(cap["spec_path"] ?? "");
511
+ if (sp !== "")
512
+ capabilityIdByPath.set(sp, String(cap["id"]));
513
+ }
514
+ // Per ADR 0157, repo-edges has both forward-declared (additional_context,
515
+ // repo-config.contracts) and reverse-derived (capability_service ⋈
516
+ // services.primary_repo_id, contracts.source_repo_id denorm) paths. Note
517
+ // that the capability_feature ⋈ feature_repo path (Y) needs feature_repo
518
+ // populated, which the ADR 0143 extractor below (10a) does — but repo-edges
519
+ // runs first. So Path Y picks up zero rows here; the second pass after
520
+ // grounding extracts (below) appends the missing edges.
521
+ const knownRepoIds = new Set(repos.map((r) => String(r["id"])));
522
+ const repoEdges = buildRepoEdges({
523
+ findings,
524
+ configs: reposResult.configs,
525
+ capabilityIdByPath,
526
+ contractIdByPath: mergedContractIdByPath,
527
+ contractRows: contractsResult.rows,
528
+ serviceRows: services,
529
+ capabilityServiceRows: out.get("capability_service") ?? [],
530
+ capabilityFeatureRows: out.get("capability_feature") ?? [],
531
+ featureRepoRows: [], // populated by grounding extractor below; second pass after
532
+ knownRepoIds,
533
+ });
534
+ out.set("repo_capability", repoEdges.repoCapability);
535
+ out.set("repo_contract", repoEdges.repoContract);
536
+ out.set("feature_repo", repoEdges.featureRepo);
537
+ // 10-bis. repo_capability_exemption per M-ADR 0188 §4.3 — the declared "no"
538
+ // that makes an uncovered repository distinguishable from an unmodelled one.
539
+ // The reason vocabulary comes from the schema enum, not a literal here, so
540
+ // the closed vocabulary has exactly one definition (canonical-schema.yaml).
541
+ const exemptionReasons = new Set((schema.enums["capability_exemption_reason"]?.values ?? []).map((v) => v.value));
542
+ out.set("repo_capability_exemption", buildRepoCapabilityExemption({
543
+ findings,
544
+ configs: reposResult.configs,
545
+ knownRepoIds,
546
+ allowedReasons: exemptionReasons,
547
+ }));
548
+ // 10-quater. service_capability_exemption per M-ADR 0133 §4.2b — the
549
+ // services-tier annotation catalogue. Two reasons only (schema-enforced):
550
+ // platform-internal, deprecated. Keyed on the repo's own default/primary
551
+ // service id; a repo whose primary service was dropped from a declared
552
+ // `services:` block cannot be exempted through this surface yet (no
553
+ // per-entry authoring surface exists — M-ADR 0197 §4.7, design-only).
554
+ const knownServiceIdsForExemption = new Set(services.map((r) => String(r["id"])));
555
+ const serviceExemptionReasons = new Set((schema.enums["service_capability_exemption_reason"]?.values ?? []).map((v) => v.value));
556
+ out.set("service_capability_exemption", buildServiceCapabilityExemption({
557
+ findings,
558
+ configs: reposResult.configs,
559
+ knownServiceIds: knownServiceIdsForExemption,
560
+ allowedReasons: serviceExemptionReasons,
561
+ }));
562
+ // 10a. Feature-grounding extractor per ADR 0143 (schema 2.5.0).
563
+ // Walks docs/domains/*/design/*/feature-grounding.md (platform-profile)
564
+ // or docs/design/integration/feature-grounding.md (integration-product)
565
+ // and emits feature rows (source: feature-grounding) plus capability_feature,
566
+ // feature_repo, feature_service edges. Runs after the other feature
567
+ // sources so precedence (feature-tree / feature-spec win on id
568
+ // collision) is enforced. Output is appended to the existing rows.
569
+ const groundingAliases = await loadFeatureGroundingAliases(sddPath);
570
+ const knownRepoKeys = new Set(repos.map((r) => String(r["id"])));
571
+ const existingFeatureIds = new Set(featuresResult.rows.map((r) => String(r["id"])));
572
+ const grounding = await buildFeatureGrounding({
573
+ sddPath,
574
+ findings,
575
+ profile: manifest.profile,
576
+ productId: manifest.sddId,
577
+ knownCapabilityIds,
578
+ knownServiceIds,
579
+ knownRepoKeys,
580
+ existingFeatureIds,
581
+ aliases: groundingAliases,
582
+ capabilitySlugAliases: capabilitySlugToId,
583
+ });
584
+ if (grounding.featureRows.length > 0) {
585
+ out.set("features", [...(out.get("features") ?? []), ...grounding.featureRows]);
586
+ out.set("capability_feature", [
587
+ ...(out.get("capability_feature") ?? []),
588
+ ...grounding.capabilityFeatureRows,
589
+ ]);
590
+ }
591
+ // feature_repo + feature_service edges are stored unconditionally —
592
+ // per ADR 0143 §2 they're grounding's unique contribution and must
593
+ // emit even when every grounding row collides on feature id with an
594
+ // earlier source (`feature-tree` / `feature-spec`). Pre-0.30.2 the
595
+ // conditional above gated feature_repo on the row emission, dropping
596
+ // the edges in the all-collide case. Fixed per
597
+ // `coordination/feature-grounding-collision-edges-coordination.md`.
598
+ out.set("feature_repo", [
599
+ ...(out.get("feature_repo") ?? []),
600
+ ...grounding.featureRepoRows,
601
+ ]);
602
+ // feature_service starts here — earlier passes don't populate it.
603
+ out.set("feature_service", grounding.featureServiceRows);
604
+ // 10b. Capability-spec extractor per ADR 0145 (schema 2.7.0).
605
+ // Walks docs/domains/*/product/*.md (platform-profile only) and emits
606
+ // feature rows (source: capability-spec) plus capability_feature,
607
+ // feature_repo, feature_service edges from the
608
+ // `## Feature Grounding (code-path trace)` table. Runs after the
609
+ // feature-grounding extractor so precedence (feature-grounding wins
610
+ // on id collision) is enforced. Reuses ADR 0143's alias map for
611
+ // service / repo resolution.
612
+ const existingFeatureIdsAfterGrounding = new Set((out.get("features") ?? []).map((r) => String(r["id"])));
613
+ const capabilitySpec = await buildCapabilitySpec({
614
+ sddPath,
615
+ findings,
616
+ profile: manifest.profile,
617
+ knownCapabilityIds,
618
+ knownServiceIds,
619
+ knownRepoKeys,
620
+ existingFeatureIds: existingFeatureIdsAfterGrounding,
621
+ aliases: groundingAliases,
622
+ });
623
+ if (capabilitySpec.featureRows.length > 0) {
624
+ out.set("features", [
625
+ ...(out.get("features") ?? []),
626
+ ...capabilitySpec.featureRows,
627
+ ]);
628
+ out.set("capability_feature", [
629
+ ...(out.get("capability_feature") ?? []),
630
+ ...capabilitySpec.capabilityFeatureRows,
631
+ ]);
632
+ }
633
+ // feature_repo + feature_service edges are stored unconditionally —
634
+ // per ADR 0145 they're capability-spec's unique contribution and must
635
+ // emit even when every capability-spec row collides on feature id with an
636
+ // earlier source. Pre-fix the conditional above gated these edges on the
637
+ // row emission, dropping them in the all-collide case (same two-level bug
638
+ // feature-grounding had pre-0.30.2). Fixed per
639
+ // `coordination/capability-spec-collision-edges-coordination.md`.
640
+ out.set("feature_repo", [
641
+ ...(out.get("feature_repo") ?? []),
642
+ ...capabilitySpec.featureRepoRows,
643
+ ]);
644
+ out.set("feature_service", [
645
+ ...(out.get("feature_service") ?? []),
646
+ ...capabilitySpec.featureServiceRows,
647
+ ]);
648
+ // 10b-ter. feature_service — application-product Feature Services table.
649
+ // WS0c (catalogue-coverage-invariants.md) found this table authored and
650
+ // unread: 12 SDDs carry a populated `## Feature Services` table in
651
+ // feature-tree.md, 155 declared rows / 385 feature×service pairs, 0
652
+ // emitted, because the two extractors above only ever walk
653
+ // feature-grounding.md / capability-spec paths (platform / integration
654
+ // profiles). Runs last so knownFeatureIds reflects every prior source.
655
+ // No `source` column exists on feature_service (schema has none), so rows
656
+ // are emitted in the same bare {id, feature_id, service_id} shape as the
657
+ // extractors above — adding one is a separate, unscoped schema decision.
658
+ if (manifest.profile === "application-product") {
659
+ const featureTreePath = await findFeatureTreeFile(sddPath);
660
+ if (featureTreePath !== null) {
661
+ const relPath = relative(sddPath, featureTreePath);
662
+ const text = await readFile(featureTreePath, "utf-8");
663
+ const knownFeatureIdsFinal = new Set((out.get("features") ?? []).map((r) => String(r["id"])));
664
+ const existingFeatureServiceIds = new Set((out.get("feature_service") ?? []).map((r) => String(r["id"])));
665
+ const tableRows = [];
666
+ for (const row of parseFeatureServicesTable(text)) {
667
+ const featureId = `${manifest.sddId}-${row.featureSlug}`;
668
+ const featureKnown = knownFeatureIdsFinal.has(featureId);
669
+ if (!featureKnown) {
670
+ findings.add("orphan", `${relPath}: Feature Services row "${row.featureSlug}" doesn't resolve to a known feature id "${featureId}"`);
671
+ }
672
+ for (const span of row.rejectedSpans ?? []) {
673
+ findings.add("convention", `${relPath}: Feature Services row "${row.featureSlug}" has a backticked span "${span}" that is not a legal service id ([a-z0-9][a-z0-9_-]*); it was not read as a service`);
674
+ }
675
+ for (const serviceId of row.serviceIds) {
676
+ if (!knownServiceIds.has(serviceId)) {
677
+ findings.add("orphan", `${relPath}: Feature Services row "${row.featureSlug}" names service "${serviceId}" which doesn't resolve against services.csv`);
678
+ continue;
679
+ }
680
+ if (!featureKnown)
681
+ continue;
682
+ const id = `${featureId}__${serviceId}`;
683
+ if (existingFeatureServiceIds.has(id))
684
+ continue;
685
+ existingFeatureServiceIds.add(id);
686
+ tableRows.push({ id, feature_id: featureId, service_id: serviceId });
687
+ }
688
+ }
689
+ if (tableRows.length > 0) {
690
+ out.set("feature_service", [
691
+ ...(out.get("feature_service") ?? []),
692
+ ...tableRows,
693
+ ]);
694
+ }
695
+ }
696
+ }
697
+ // 10b-quater. feature_repo derivation — feature_service ⋈
698
+ // services.primary_repo_id (D8 of catalogue-coverage-invariants).
699
+ //
700
+ // MUST run after 10b-ter, because feature_service is not final until the
701
+ // app-product Feature Services table has been read — and that table is the
702
+ // reason this derivation is worth anything in application-product, the profile
703
+ // where feature_repo previously had no source at all (its only extractor is
704
+ // platform-gated and its forward source is a `TBD` placeholder).
705
+ //
706
+ // MUST run before 10b-bis, so repo_capability Path Y (capability_feature ⋈
707
+ // feature_repo) sees these rows too. That cascade is deliberate and was
708
+ // measured before shipping rather than assumed.
709
+ {
710
+ const existingFeatureRepo = out.get("feature_repo") ?? [];
711
+ const derived = deriveFeatureRepoFromServices({
712
+ featureServiceRows: out.get("feature_service") ?? [],
713
+ serviceRows: out.get("services") ?? [],
714
+ knownRepoIds: new Set((out.get("repositories") ?? []).map((r) => String(r["id"]))),
715
+ existingIds: new Set(existingFeatureRepo.map((r) => String(r["id"]))),
716
+ });
717
+ if (derived.length > 0) {
718
+ out.set("feature_repo", [...existingFeatureRepo, ...derived]);
719
+ }
720
+ }
721
+ // 10b-bis. Second pass on repo_capability Path Y (per ADR 0157 §4.3) —
722
+ // capability_feature ⋈ feature_repo. The initial repo-edges pass (step 10)
723
+ // ran before the feature-grounding extractor populated feature_repo, so
724
+ // Path Y picked up zero rows there. Now that feature_repo is fully built,
725
+ // re-run just Path Y and merge into the existing repo_capability rows
726
+ // with deduplication.
727
+ const existingRepoCap = out.get("repo_capability") ?? [];
728
+ const repoCapSeen = new Set(existingRepoCap.map((r) => String(r["id"])));
729
+ const featureRepoFinal = out.get("feature_repo") ?? [];
730
+ const capFeatureFinal = out.get("capability_feature") ?? [];
731
+ const knownRepoIdsFinal = new Set(repos.map((r) => String(r["id"])));
732
+ const featuresToReposFinal = new Map();
733
+ for (const fr of featureRepoFinal) {
734
+ const fid = String(fr["feature_id"] ?? "");
735
+ const rid = String(fr["repo_id"] ?? "");
736
+ if (fid === "" || rid === "")
737
+ continue;
738
+ if (!featuresToReposFinal.has(fid))
739
+ featuresToReposFinal.set(fid, new Set());
740
+ featuresToReposFinal.get(fid).add(rid);
741
+ }
742
+ const pathYRows = [];
743
+ for (const cf of capFeatureFinal) {
744
+ const fid = String(cf["feature_id"] ?? "");
745
+ const cid = String(cf["capability_id"] ?? "");
746
+ if (fid === "" || cid === "")
747
+ continue;
748
+ const reposForFeature = featuresToReposFinal.get(fid);
749
+ if (!reposForFeature)
750
+ continue;
751
+ for (const repoId of reposForFeature) {
752
+ if (!knownRepoIdsFinal.has(repoId))
753
+ continue;
754
+ const id = `${repoId}__${cid}`;
755
+ if (repoCapSeen.has(id))
756
+ continue;
757
+ repoCapSeen.add(id);
758
+ pathYRows.push({ id, repo_id: repoId, capability_id: cid });
759
+ }
760
+ }
761
+ if (pathYRows.length > 0) {
762
+ out.set("repo_capability", [...existingRepoCap, ...pathYRows]);
763
+ }
764
+ // 10b-bis². feature_repo zero-resolution SUMMARIES (M-ADR 0143 §4.4), one
765
+ // per evidence surface, split against the FINAL feature_repo. The per-row
766
+ // lines fire inside the builders; some name features that another surface
767
+ // (feature-tree `## Feature Services`, feature-spec, the 10b-quater
768
+ // derivation) DID anchor. Measured on the reference platform estate:
769
+ // 641 lines naming 571 features, 120 anchored via another surface — 21%
770
+ // of the headline was cell quality, not coverage. Only the build can
771
+ // tell the two apart, which is why the summary lives here.
772
+ for (const [cellLabel, r] of [
773
+ ["Evidence", grounding],
774
+ ["Implementation", capabilitySpec],
775
+ ]) {
776
+ const summary = zeroResolutionSummary(cellLabel, r.repoEvidenceRows, r.repoZeroResolutionFeatureIds, featuresToReposFinal);
777
+ if (summary !== null)
778
+ findings.add("coverage", summary);
779
+ }
780
+ // 10c. feature_subscription_channels per schema 2.9.0 — walks any
781
+ // `reverse-evidence/feature-grounding-evidence.yaml` in the SDD and
782
+ // emits one row per `subscription_channels:` entry on each feature.
783
+ // Runs after all feature sources (10, 10a, 10b) so feature-id
784
+ // resolution sees the full features.csv id set.
785
+ const featureIdsAfterAllSources = new Set((out.get("features") ?? []).map((r) => String(r["id"])));
786
+ out.set("feature_subscription_channels", await buildFeatureSubscriptionChannels({
787
+ sddPath,
788
+ findings,
789
+ existingFeatureIds: featureIdsAfterAllSources,
790
+ productId: manifest.sddId,
791
+ }));
792
+ // 10d. product_platform_dependencies per schema 2.9.0 — walks the same
793
+ // `reverse-evidence/feature-grounding-evidence.yaml` files for the
794
+ // product-scoped `platform_dependencies:` block and emits per-SDD edge
795
+ // rows. The aggregator joins these against platform-side
796
+ // capability_service at aggregation time to produce
797
+ // cross_sdd_platform_capability rows.
798
+ out.set("product_platform_dependencies", await buildProductPlatformDependencies({
799
+ sddPath,
800
+ findings,
801
+ productId: manifest.sddId,
802
+ }));
803
+ // data_plane ↔ product_platform_dependencies coherence (M-ADR 0193 §4.2, as
804
+ // amended): the two surfaces are complementary, cross-checked on their overlap
805
+ // rather than derived. A synchronous internal `retrieve` binding whose target
806
+ // service has no platform-dependency row is a drift-visible finding.
807
+ {
808
+ const dpBindings = out.get("application_data_plane_bindings") ?? [];
809
+ const platformDeps = out.get("product_platform_dependencies") ?? [];
810
+ if (dpBindings.length > 0) {
811
+ checkDataPlanePlatformDepCoherence({
812
+ productId: manifest.sddId,
813
+ bindings: dpBindings,
814
+ platformDeps,
815
+ findings,
816
+ });
817
+ // publish binding ↔ surface: api coherence (M-ADR 0193 §4.7 / 0167):
818
+ // complementary views cross-checked, not derived.
819
+ checkPublishSurfaceCoherence({
820
+ productId: manifest.sddId,
821
+ surface: manifest.surface,
822
+ bindings: dpBindings,
823
+ findings,
824
+ });
825
+ }
826
+ }
827
+ // 11. Events + service_event + capability_event.
828
+ // Per ADR 0156 (schema 2.12.0), events ingest from both the forward-method
829
+ // avsc path and the reverse-method `event-catalog.yaml` path. YAML wins on
830
+ // name/domain/publisher/transport; avsc augments schema_path/version.
831
+ // service_event edges populate from both repo-config events.produces/consumes
832
+ // (forward) and event-catalog.yaml publisher + consumers[] (reverse).
833
+ const avroContracts = contractsResult.rows.filter((c) => c["kind"] === "event");
834
+ const eventsResult = await buildEvents({
835
+ sddPath,
836
+ findings,
837
+ avroContracts,
838
+ contractIdByPath: mergedContractIdByPath,
839
+ configs: reposResult.configs,
840
+ knownServiceIds,
841
+ knownDomainIds,
842
+ });
843
+ out.set("events", eventsResult.rows);
844
+ const knownEventIds = new Set(eventsResult.rows.map((e) => String(e["id"])));
845
+ out.set("service_event", buildServiceEvent({
846
+ findings,
847
+ configs: reposResult.configs,
848
+ contractIdByPath: mergedContractIdByPath,
849
+ knownServiceIds,
850
+ knownEventIds,
851
+ yamlEvents: eventsResult.yamlEvents,
852
+ }));
853
+ out.set("capability_event", buildCapabilityEvent({
854
+ findings,
855
+ manifests: traceability,
856
+ knownCapabilityIds,
857
+ knownEventIds,
858
+ capabilityServiceRows: out.get("capability_service") ?? [],
859
+ serviceEventRows: out.get("service_event") ?? [],
860
+ }));
861
+ // 12. service_contract — exposes rows from repo-config contracts block,
862
+ // plus consumes rows derived from each service's
863
+ // repos/<service>/docs/service-architecture.md External Interfaces table.
864
+ // The consumes-emission path is gated on an adopter-supplied
865
+ // .sdd/service-contract-aliases.yaml mapping interface display text to
866
+ // canonical contract names. Pattern documented in ADR 0147.
867
+ const exposesRows = buildServiceContract({
868
+ findings,
869
+ configs: reposResult.configs,
870
+ contractIdByPath: mergedContractIdByPath,
871
+ knownServiceIds,
872
+ });
873
+ const serviceContractAliases = await loadServiceContractAliases(sddPath);
874
+ const consumesRows = await buildServiceContractConsumes({
875
+ sddPath,
876
+ findings,
877
+ knownServiceIds,
878
+ aliases: serviceContractAliases,
879
+ // M-ADR 0203 §4.4 cut window: alias targets authored as 2.x kindless
880
+ // contract ids re-key against this build's kind-suffixed rows.
881
+ legacyContractIdMap: buildLegacyContractIdMap(out.get("contracts") ?? []),
882
+ });
883
+ out.set("service_contract", [...exposesRows, ...consumesRows]);
884
+ // Vacuous-signal (M-ADR 0197 §4.7's fourth failure mode, distinct from
885
+ // silent loss/loud failure/wrong value): a declared entry whose id
886
+ // differs from its repository's own can never receive service_event or
887
+ // service_contract rows today — §4.6's per-entry attribution mechanism
888
+ // isn't built yet, so nothing downstream even attempts it. That is not
889
+ // an authoring gap to chase (nothing failed to resolve) and not
890
+ // evidence the service genuinely publishes/consumes/exposes nothing —
891
+ // it is a fact that cannot yet be expressed. Named explicitly, once per
892
+ // declared entry, rather than left to read as a quiet, complete-looking
893
+ // absence indistinguishable from "this service really has none".
894
+ if (declaredNonRepoServiceIds.size > 0) {
895
+ const serviceEventIds = new Set((out.get("service_event") ?? []).map((r) => String(r["service_id"])));
896
+ const serviceContractIds = new Set((out.get("service_contract") ?? []).map((r) => String(r["service_id"])));
897
+ for (const id of declaredNonRepoServiceIds) {
898
+ if (!serviceEventIds.has(id) && !serviceContractIds.has(id)) {
899
+ findings.add("coverage", `services: declared entry "${id}" has no events/contracts of its own — vacuous, not evidence it has none. No per-entry authoring surface exists yet (M-ADR 0197 §4.6); this is a known modelling gap, not an authoring gap to fix.`);
900
+ }
901
+ }
902
+ }
903
+ // 12-bis. Second pass on repo_contract Path X (per ADR 0157 §4.2 amendment) —
904
+ // service_contract ⋈ services.primary_repo_id. The initial repo-edges pass
905
+ // (step 10) ran before service_contract was built, so Path X picked up zero
906
+ // rows there. Now that service_contract is fully populated, derive the
907
+ // missing edges and append with deduplication against existing rows.
908
+ const existingRepoContract = out.get("repo_contract") ?? [];
909
+ const repoContractSeen = new Set(existingRepoContract.map((r) => String(r["id"])));
910
+ const repoBySvcIdRC = new Map();
911
+ for (const s of services) {
912
+ const sid = String(s["id"]);
913
+ const repoId = String(s["primary_repo_id"] ?? "");
914
+ if (sid !== "" && repoId !== "")
915
+ repoBySvcIdRC.set(sid, repoId);
916
+ }
917
+ const pathXRowsRC = [];
918
+ const knownRepoIdsRC = new Set(repos.map((r) => String(r["id"])));
919
+ for (const sc of out.get("service_contract") ?? []) {
920
+ const svcId = String(sc["service_id"] ?? "");
921
+ const ctrId = String(sc["contract_id"] ?? "");
922
+ if (svcId === "" || ctrId === "")
923
+ continue;
924
+ const repoId = repoBySvcIdRC.get(svcId);
925
+ if (repoId === undefined)
926
+ continue;
927
+ if (!knownRepoIdsRC.has(repoId))
928
+ continue;
929
+ const id = `${repoId}__${ctrId}`;
930
+ if (repoContractSeen.has(id))
931
+ continue;
932
+ repoContractSeen.add(id);
933
+ pathXRowsRC.push({ id, repo_id: repoId, contract_id: ctrId });
934
+ }
935
+ if (pathXRowsRC.length > 0) {
936
+ out.set("repo_contract", [...existingRepoContract, ...pathXRowsRC]);
937
+ }
938
+ // 12a. feature_contract — derived edge per schema 2.3.0. Runs after every
939
+ // input row set is populated. feature_service is not yet built by the
940
+ // canonical builder (deferred), so the reverse path produces zero rows
941
+ // today; the builder is wired for both paths so coverage grows
942
+ // automatically once feature_service lands.
943
+ out.set("feature_contract", buildFeatureContract({
944
+ findings,
945
+ capabilityFeatureRows: out.get("capability_feature") ?? [],
946
+ capabilityContractRows: out.get("capability_contract") ?? [],
947
+ featureServiceRows: out.get("feature_service") ?? [],
948
+ serviceContractRows: out.get("service_contract") ?? [],
949
+ }));
950
+ // 12a-bis. feature_event — derived edge per schema 2.15.0 / ADR 0161.
951
+ // Pure join of feature_service ⋈ service_event; no authored shape. Mirrors
952
+ // ADR 0160's capability_event derived path one rung lower in the hierarchy.
953
+ out.set("feature_event", buildFeatureEvent({
954
+ knownFeatureIds: featureIdsAfterAllSources,
955
+ knownEventIds,
956
+ featureServiceRows: out.get("feature_service") ?? [],
957
+ serviceEventRows: out.get("service_event") ?? [],
958
+ }));
959
+ // 12b. domain_models + capability_domain_model + feature_domain_model
960
+ // per schema 2.4.0 / ADR 0142. Platform-profile only — non-platform
961
+ // builds emit headers-only. domain_models runs first; the edges depend
962
+ // on its known-id set.
963
+ const domainModels = await buildDomainModels({
964
+ sddPath,
965
+ manifest,
966
+ findings,
967
+ manifests: traceability,
968
+ knownDomainIds,
969
+ });
970
+ out.set("domain_models", domainModels);
971
+ const knownDomainModelIds = new Set(domainModels.map((r) => String(r["id"])));
972
+ out.set("capability_domain_model", buildCapabilityDomainModel({
973
+ findings,
974
+ manifest,
975
+ manifests: traceability,
976
+ capabilityRows: capabilities,
977
+ knownCapabilityIds,
978
+ knownDomainModelIds,
979
+ }));
980
+ out.set("feature_domain_model", buildFeatureDomainModel({
981
+ findings,
982
+ manifest,
983
+ capabilityFeatureRows: out.get("capability_feature") ?? [],
984
+ capabilityDomainModelRows: out.get("capability_domain_model") ?? [],
985
+ }));
986
+ // 13. App-product profile builders (personas, journeys, persona/journey
987
+ // edges, product-edges). Per ADR 0134, app-product SDDs emit these
988
+ // catalogues; other profiles emit zero rows. Per ADR 0136, product_*
989
+ // edges' capability_id and service_id are external FKs validated by
990
+ // the aggregator, not here.
991
+ const featureIdByPath = featuresResult.idByPath;
992
+ const featureIdBySlug = featuresResult.idBySlug;
993
+ const personasResult = await buildPersonas({
994
+ sddPath,
995
+ findings,
996
+ profile: manifest.profile,
997
+ productId: manifest.sddId,
998
+ });
999
+ out.set("personas", personasResult.rows);
1000
+ const journeysResult = await buildUserJourneys({
1001
+ sddPath,
1002
+ findings,
1003
+ profile: manifest.profile,
1004
+ productId: manifest.sddId,
1005
+ });
1006
+ out.set("user_journeys", journeysResult.rows);
1007
+ out.set("persona_journey", buildPersonaJourney({
1008
+ findings,
1009
+ personaIdByName: personasResult.idByName,
1010
+ personaIdBySlug: personasResult.idBySlug,
1011
+ journeyPrimaryPersonaById: journeysResult.primaryPersonaById,
1012
+ journeySupportingPersonasById: journeysResult.supportingPersonasById,
1013
+ }));
1014
+ out.set("persona_feature", buildPersonaFeature({
1015
+ findings,
1016
+ relatedFeaturesById: personasResult.relatedFeaturesById,
1017
+ featureIdByPath,
1018
+ featureIdBySlug,
1019
+ }));
1020
+ out.set("journey_feature", buildJourneyFeature({
1021
+ findings,
1022
+ journeyFeaturePathsById: journeysResult.featurePathsById,
1023
+ journeyRelatedFeatureSlugsById: journeysResult.relatedFeatureSlugsById,
1024
+ featureIdByPath,
1025
+ featureIdBySlug,
1026
+ }));
1027
+ // UI screens family (per ADR 0166). Application-product profile; the walker
1028
+ // reads `screens/*.md` screen-spec artefacts (frontmatter + ```openui-lang
1029
+ // block) and emits the six catalogues. Other profiles have no screens/ dir →
1030
+ // empty. FK resolution uses the feature + persona id maps already built.
1031
+ // Optional adopter-tunable screen-validation parameters (ADR 0168 §4.5 /
1032
+ // ADR 0169 §4.3); absent → method defaults (the vocabulary-free checks run).
1033
+ const screenValidation = await loadScreenValidationConfig(sddPath);
1034
+ const screensResult = await buildScreens({
1035
+ sddPath,
1036
+ findings,
1037
+ profile: manifest.profile,
1038
+ productId: manifest.sddId,
1039
+ featureIdBySlug,
1040
+ featureIdByPath,
1041
+ personaIdBySlug: personasResult.idBySlug,
1042
+ personaIdByName: personasResult.idByName,
1043
+ flatThreshold: screenValidation?.flatThreshold,
1044
+ decorativePrimitives: screenValidation?.decorativePrimitives,
1045
+ archetypeOverlays: screenValidation?.archetypeOverlays,
1046
+ });
1047
+ out.set("screens", screensResult.screens);
1048
+ out.set("ui_components", screensResult.uiComponents);
1049
+ out.set("screen_component", screensResult.screenComponent);
1050
+ out.set("screen_feature", screensResult.screenFeature);
1051
+ out.set("persona_screen", screensResult.personaScreen);
1052
+ out.set("screen_action", screensResult.screenAction);
1053
+ // Rubric scores join the catalogue metamodel (M-ADR 0205 §4.5, schema
1054
+ // 3.2.0). Reads `orchestration/gate-registry/*-scores.yaml` — profile-
1055
+ // agnostic, because any SDD may carry a gate registry and most carry none.
1056
+ // Absent directory → headers-only and no finding: the surface is optional,
1057
+ // and an empty registry is a deliberate state the B2 coverage predicate
1058
+ // reads, not a defect this builder should shout about.
1059
+ // §3i: hand the scores builder the target catalogues' own path columns so a
1060
+ // registry entry with no authored file_path can still gain a (coarse,
1061
+ // scored_at) staleness basis. Built from rows already in `out` — the scores
1062
+ // builder deliberately runs after every path-bearing target catalogue.
1063
+ const pathIndexOf = (cat, pathCol) => new Map((out.get(cat) ?? [])
1064
+ .filter((r) => typeof r["id"] === "string" && typeof r[pathCol] === "string" && r[pathCol] !== "")
1065
+ .map((r) => [r["id"], r[pathCol]]));
1066
+ const cataloguePathIndex = new Map([
1067
+ ["adrs", pathIndexOf("adrs", "doc_path")],
1068
+ ["contracts", pathIndexOf("contracts", "spec_path")],
1069
+ ["capabilities", pathIndexOf("capabilities", "spec_path")],
1070
+ ]);
1071
+ const qualityScoresResult = await buildQualityScores({
1072
+ sddPath,
1073
+ findings,
1074
+ sddId: manifest.sddId,
1075
+ cataloguePathIndex,
1076
+ });
1077
+ out.set("quality_scores", qualityScoresResult.qualityScores);
1078
+ out.set("quality_score_dimensions", qualityScoresResult.qualityScoreDimensions);
1079
+ // Forward scaffold declarations (M-ADR 0207 §4.1, schema 3.4.0). Reads
1080
+ // `orchestration/scaffolds/*.yaml`; absent directory → headers-only and NO
1081
+ // finding (zero adoption is the designed state, §4.4). Runs after every
1082
+ // target-bearing catalogue because its resolution pass derives each item's
1083
+ // `state` from the rows already in `out` — the conditional-FK rule holds by
1084
+ // construction because resolution is what computes state.
1085
+ const scaffoldsResult = await buildScaffolds({
1086
+ sddPath,
1087
+ findings,
1088
+ sddId: manifest.sddId,
1089
+ schema,
1090
+ rowsByCatalogue: out,
1091
+ });
1092
+ out.set("scaffolds", scaffoldsResult.scaffolds);
1093
+ out.set("scaffold_items", scaffoldsResult.scaffoldItems);
1094
+ // Screen↔capability context cross-check (ADR 0170 §4.6, schema 2.21.0):
1095
+ // a screen's `context:` should equal the context of the capability it
1096
+ // realises (screen → screen_feature → feature.capability_id →
1097
+ // capability.context). Convention finding, never an FK failure — the
1098
+ // divergences are the model's seam screens, and `cross-cutting` on
1099
+ // either side is an expected non-match (chrome / straddling features).
1100
+ checkScreenCapabilityContext({
1101
+ findings,
1102
+ screens: out.get("screens") ?? [],
1103
+ screenFeature: out.get("screen_feature") ?? [],
1104
+ features: out.get("features") ?? [],
1105
+ capabilities: out.get("capabilities") ?? [],
1106
+ });
1107
+ const productEdges = await buildProductEdges({
1108
+ sddPath,
1109
+ findings,
1110
+ profile: manifest.profile,
1111
+ productId: manifest.sddId,
1112
+ });
1113
+ out.set("product_capability", productEdges.productCapability);
1114
+ out.set("product_service", productEdges.productService);
1115
+ // 14. Integration-product profile: emit one integrations.csv row per
1116
+ // integration-provider SDD from `integration-manifest.yaml`.
1117
+ const integrationsRows = await buildIntegrations({
1118
+ sddPath,
1119
+ findings,
1120
+ profile: manifest.profile,
1121
+ sddId: manifest.sddId,
1122
+ repoCount: repos.length,
1123
+ // Threaded from the SDD manifest so `products.exemplar` and
1124
+ // `integrations.exemplar` derive from the one authored field (2.46.0).
1125
+ ...(manifest.exemplar !== undefined ? { exemplar: manifest.exemplar } : {}),
1126
+ ...(manifest.sharedLibrary !== undefined ? { sharedLibrary: manifest.sharedLibrary } : {}),
1127
+ });
1128
+ out.set("integrations", integrationsRows);
1129
+ // 14-di. Data items, supply and demand (M-ADR 0216). Coverage is derived at aggregation,
1130
+ // where supply and demand from different SDDs meet; a member build leaves it empty.
1131
+ out.set("data_items", await buildDataItems(sddPath, findings));
1132
+ out.set("integration_data_item", await buildIntegrationSupply({
1133
+ sddPath,
1134
+ findings,
1135
+ integrationId: String(integrationsRows[0]?.["id"] ?? ""),
1136
+ }));
1137
+ out.set("feature_data_requirement", await buildFeatureDemand({
1138
+ sddPath,
1139
+ findings,
1140
+ idByPath: featuresResult.idByPath,
1141
+ idBySlug: featuresResult.idBySlug,
1142
+ knownFeatureIds: new Set((out.get("features") ?? []).map((r) => String(r["id"]))),
1143
+ }));
1144
+ // 14a. integration_component (M-ADR 0171 §9, catalogued per §10.1): the
1145
+ // component tier the manifest declares, with §10.2 id resolution against this
1146
+ // SDD's repositories/services and the §9.3 aggregate cross-check. Runs after
1147
+ // repos + services so both resolution sets are populated.
1148
+ out.set("integration_component", await buildIntegrationComponents({
1149
+ sddPath,
1150
+ findings,
1151
+ profile: manifest.profile,
1152
+ sddId: manifest.sddId,
1153
+ knownRepoIds: knownRepoIdsFinal,
1154
+ knownServiceIds,
1155
+ }));
1156
+ // M-ADR 0217: build-time and generation-time dependencies between repositories,
1157
+ // declared (repo-config depends_on) and derived (per-component sharedLibraries).
1158
+ out.set("repo_dependency", await buildRepoDependencies({ sddPath, configs: reposResult.configs, knownRepoIds: knownRepoIdsFinal, findings }));
1159
+ // 15. C4 systems + containers + components (L1 + L2 + L3). The L3 walker
1160
+ // shipped per `coordination/c4-l3-walker-and-path-convention-coordination.md`
1161
+ // (sdd-method-gov, 2026-05-29) — forward-method authoring path reading
1162
+ // `c4-l3-*.mermaid.md` files alongside the L2 layout. The earlier Path-B
1163
+ // deferral assumed reverse-method code-walking; this walker is the
1164
+ // forward-method analogue, granularity-agnostic (per-container or
1165
+ // per-domain rollup both supported).
1166
+ const c4Systems = await buildC4Systems({
1167
+ sddPath,
1168
+ manifest,
1169
+ findings,
1170
+ });
1171
+ out.set("c4_systems", c4Systems);
1172
+ const knownSystemIds = new Set(c4Systems.map((r) => String(r["id"])));
1173
+ const containerIdByEntryId = new Map();
1174
+ const containerNameByEntryId = new Map();
1175
+ const containerTechByEntryId = new Map();
1176
+ const c4Containers = await buildC4Containers({
1177
+ sddPath,
1178
+ manifest,
1179
+ findings,
1180
+ knownSystemIds,
1181
+ knownServiceIds,
1182
+ knownDomainIds,
1183
+ containerIdByEntryIdOut: containerIdByEntryId,
1184
+ containerNameByEntryIdOut: containerNameByEntryId,
1185
+ containerTechByEntryIdOut: containerTechByEntryId,
1186
+ });
1187
+ out.set("c4_containers", c4Containers);
1188
+ const c4Components = await buildC4Components({
1189
+ sddPath,
1190
+ manifest,
1191
+ findings,
1192
+ c4ContainerRows: c4Containers,
1193
+ });
1194
+ out.set("c4_components", c4Components);
1195
+ // 15b. C4 L1 context elements (ADR 0155): external systems + the
1196
+ // persona→system and system→external edges, extracted from System_Ext /
1197
+ // Person / Rel macros on the L1/L2 diagrams. (The rows were computed at
1198
+ // step 7, where capability_service consults them — see above.)
1199
+ out.set("c4_external_systems", c4External.rows);
1200
+ // external_system_exemption per M-ADR 0189 §4.6 — the declared "genuinely
1201
+ // external" that makes an unverified candidate distinguishable from an
1202
+ // unexamined one. Reason vocabulary from the schema enum (one definition).
1203
+ const externalExemptionReasons = new Set((schema.enums["external_system_exemption_reason"]?.values ?? []).map((v) => v.value));
1204
+ out.set("external_system_exemption", await buildExternalSystemExemption({
1205
+ sddPath,
1206
+ findings,
1207
+ knownExternalIds: new Set(c4External.rows.map((r) => String(r["id"]))),
1208
+ allowedReasons: externalExemptionReasons,
1209
+ // M-ADR 0203 §4.2 cut window: authored exemptions keyed by the 2.x
1210
+ // name-slug ids re-key against this build's declared identities.
1211
+ legacyIdMap: buildLegacyExtIdMap(c4External.rows),
1212
+ }));
1213
+ out.set("system_external_dependency", await buildSystemExternalDependency({
1214
+ sddPath,
1215
+ manifest,
1216
+ findings,
1217
+ knownSystemIds,
1218
+ externalIdByEntryId: c4External.idByEntryId,
1219
+ containerIdByEntryId,
1220
+ }));
1221
+ // container_dependency member tier (F19): the C4-correct authoring —
1222
+ // Rel(container_a, container_b) — emits its edge here; the aggregator's
1223
+ // adjudication leg merges rather than being the only source.
1224
+ out.set("container_dependency", await buildContainerDependency({
1225
+ sddPath,
1226
+ manifest,
1227
+ findings,
1228
+ containerIdByEntryId,
1229
+ externalIdByEntryId: c4External.idByEntryId,
1230
+ }));
1231
+ // persona_container per M-ADR 0189 F26 §3 (schema 2.34.0) — the
1232
+ // L2-granularity companion to persona_c4system.
1233
+ out.set("persona_container", await buildPersonaContainer({
1234
+ sddPath,
1235
+ manifest,
1236
+ findings,
1237
+ containerIdByEntryId,
1238
+ externalIdByEntryId: c4External.idByEntryId,
1239
+ personaIdByName: personasResult.idByName,
1240
+ personaIdBySlug: personasResult.idBySlug,
1241
+ }));
1242
+ reportEntryIdReferentCollisions({
1243
+ findings,
1244
+ containerNameByEntryId,
1245
+ containerTechByEntryId,
1246
+ externalNameByEntryId: c4External.nameByEntryId,
1247
+ externalTechByEntryId: c4External.technologyByEntryId,
1248
+ externalDescByEntryId: c4External.descriptionByEntryId,
1249
+ });
1250
+ out.set("persona_c4system", await buildPersonaC4System({
1251
+ sddPath,
1252
+ manifest,
1253
+ findings,
1254
+ knownSystemIds,
1255
+ personaIdByName: personasResult.idByName,
1256
+ personaIdBySlug: personasResult.idBySlug,
1257
+ }));
1258
+ // 16. Glossary items + cross-references.
1259
+ const glossaryResult = await buildGlossaryItems({
1260
+ sddPath,
1261
+ findings,
1262
+ knownDomainIds,
1263
+ });
1264
+ out.set("glossary_items", glossaryResult.rows);
1265
+ const knownGlossaryIds = new Set(glossaryResult.rows.map((r) => String(r["id"])));
1266
+ // Per ADR 0158 (schema 2.12.0), the glossary-xref builder ingests from
1267
+ // five walkers and emits two catalogues: glossary_xref (term-to-term
1268
+ // relations) and entity_glossary (new sibling — data-model entity to
1269
+ // canonical term mappings).
1270
+ const xref = await buildGlossaryXref({
1271
+ sddPath,
1272
+ findings,
1273
+ profile: manifest.profile,
1274
+ idByDomainTerm: glossaryResult.idByDomainTerm,
1275
+ knownGlossaryIds,
1276
+ knownDomainIds,
1277
+ });
1278
+ out.set("glossary_xref", xref.glossaryXref);
1279
+ out.set("entity_glossary", xref.entityGlossary);
1280
+ // Backfill canonical_term_id + aliases onto glossary_items from the xref
1281
+ // edges (same alignment manifest, resolved downstream).
1282
+ out.set("glossary_items", backfillGlossaryRelations(glossaryResult.rows, xref.glossaryXref));
1283
+ // 17. Data-model entities + service_entity + C4 roll-ups per ADR 0159
1284
+ // (schema 2.13.0). Four new catalogues, five walkers:
1285
+ //
1286
+ // - entities-manifest: forward path, reads models[].entities[] from
1287
+ // glossary-alignment-manifest.yaml.
1288
+ // - entities-mermaid: reverse path, parses erDiagram blocks under each
1289
+ // domain_models row's doc_path.
1290
+ // - service-entity-tables: reads `## Data Store Mapping` markdown
1291
+ // tables (relation=owns).
1292
+ // - c4-container-entity: pure derivation over service_entity ⋈
1293
+ // c4_containers.service_id.
1294
+ // - c4-system-entity: aggregation roll-up over c4_container_entity ⋈
1295
+ // c4_containers.system_id with container_count.
1296
+ const entitiesFromManifest = await buildEntitiesFromManifest({
1297
+ sddPath,
1298
+ findings,
1299
+ profile: manifest.profile,
1300
+ knownDomainIds,
1301
+ knownGlossaryIds,
1302
+ });
1303
+ const entitiesFromMermaid = await buildEntitiesFromMermaid({
1304
+ sddPath,
1305
+ findings,
1306
+ domainModels: domainModels.map((r) => ({
1307
+ id: String(r["id"]),
1308
+ domain_id: String(r["domain_id"]),
1309
+ doc_path: String(r["doc_path"]),
1310
+ })),
1311
+ knownGlossaryIds,
1312
+ });
1313
+ // Forward wins on attribute conflicts (matches per-adopter authoring intent
1314
+ // per ADR 0159 §4.4). Reverse rows seed the map; forward overwrites.
1315
+ const entitiesById = new Map();
1316
+ for (const r of entitiesFromMermaid)
1317
+ entitiesById.set(String(r["id"]), r);
1318
+ for (const r of entitiesFromManifest)
1319
+ entitiesById.set(String(r["id"]), r);
1320
+ const entityRows = [...entitiesById.values()];
1321
+ out.set("entities", entityRows);
1322
+ // Per ADR 0159 §4.9: now that entities.csv exists, append entity_glossary
1323
+ // rows for the reverse-method side. The forward-method emission via
1324
+ // glossary-xref's models[].entities[] walker (ADR 0158) is already in
1325
+ // xref.entityGlossary; here we extend with rows derived from
1326
+ // entities-mermaid's slug-matched glossary_term FKs.
1327
+ const existingEntityGlossaryPks = new Set(xref.entityGlossary.map((r) => String(r["id"])));
1328
+ const extendedEntityGlossary = [...xref.entityGlossary];
1329
+ for (const e of entityRows) {
1330
+ const domainId = String(e["domain_id"] ?? "");
1331
+ const glossaryId = String(e["glossary_term"] ?? "");
1332
+ const name = String(e["name"] ?? "");
1333
+ if (!domainId || !glossaryId || !name)
1334
+ continue;
1335
+ const pk = `${domainId}__${entitySlug(name)}__${glossaryId}`;
1336
+ if (existingEntityGlossaryPks.has(pk))
1337
+ continue;
1338
+ existingEntityGlossaryPks.add(pk);
1339
+ extendedEntityGlossary.push({
1340
+ id: pk,
1341
+ domain_id: domainId,
1342
+ entity_name: name,
1343
+ glossary_item_id: glossaryId,
1344
+ status: "matched_direct",
1345
+ source_sdd_id: "",
1346
+ });
1347
+ }
1348
+ out.set("entity_glossary", extendedEntityGlossary);
1349
+ // Indexes for service_entity-tables resolution.
1350
+ const entityIdByDomainSlug = new Map();
1351
+ const entityIdBySlug = new Map();
1352
+ for (const e of entityRows) {
1353
+ const id = String(e["id"]);
1354
+ const name = String(e["name"]);
1355
+ const dom = String(e["domain_id"] ?? "");
1356
+ const slug = entitySlug(name);
1357
+ if (dom)
1358
+ entityIdByDomainSlug.set(`${dom}|${slug}`, id);
1359
+ if (!entityIdBySlug.has(slug))
1360
+ entityIdBySlug.set(slug, id);
1361
+ }
1362
+ // ADR 0164: backfill entities.persistence_kind from the Data Store Mapping
1363
+ // Store column (reverse-only; forward store-less tables leave it empty).
1364
+ const enrichedEntityRows = await enrichEntityPersistenceKind({
1365
+ sddPath,
1366
+ findings,
1367
+ domainModels: domainModels.map((r) => ({
1368
+ id: String(r["id"]),
1369
+ domain_id: String(r["domain_id"]),
1370
+ doc_path: String(r["doc_path"]),
1371
+ })),
1372
+ entityIdByDomainSlug,
1373
+ entityIdBySlug,
1374
+ entityRows,
1375
+ });
1376
+ out.set("entities", enrichedEntityRows);
1377
+ const serviceEntityRows = await buildServiceEntityFromTables({
1378
+ sddPath,
1379
+ findings,
1380
+ domainModels: domainModels.map((r) => ({
1381
+ id: String(r["id"]),
1382
+ domain_id: String(r["domain_id"]),
1383
+ doc_path: String(r["doc_path"]),
1384
+ })),
1385
+ entityIdByDomainSlug,
1386
+ entityIdBySlug,
1387
+ services,
1388
+ knownServiceIds,
1389
+ });
1390
+ out.set("service_entity", serviceEntityRows);
1391
+ const containerEntityRows = buildC4ContainerEntity({
1392
+ serviceEntityRows,
1393
+ c4Containers,
1394
+ });
1395
+ out.set("c4_container_entity", containerEntityRows);
1396
+ const systemEntityRows = buildC4SystemEntity({
1397
+ containerEntityRows,
1398
+ c4Containers,
1399
+ });
1400
+ out.set("c4_system_entity", systemEntityRows);
1401
+ // M-ADR 0211 §4.3 — the population check that disagrees, run LAST because it needs
1402
+ // c4_containers (built after capability_service) to classify unresolved ids three ways.
1403
+ checkCapabilityServicePopulation({
1404
+ findings,
1405
+ manifests: traceability,
1406
+ capabilityServiceRows: out.get("capability_service") ?? [],
1407
+ knownCapabilityIds,
1408
+ knownServiceIds,
1409
+ knownExternalSystemIds,
1410
+ knownContainers: c4Containers.map((r) => ({ id: String(r["id"]), name: String(r["name"] ?? ""), system_id: String(r["system_id"] ?? "") })),
1411
+ });
1412
+ return out;
1413
+ }
1414
+ const CONTEXT_CROSS_CUTTING = "cross-cutting";
1415
+ /**
1416
+ * Convention check (schema 2.21.0): where a screen carries `context` and the
1417
+ * capability it realises (via screen_feature -> features.capability_id)
1418
+ * also carries `context`, a difference is surfaced as a `convention`
1419
+ * finding. `cross-cutting` on either side is exempt: straddling
1420
+ * features/capabilities and app chrome legitimately diverge — those
1421
+ * screens are the model's documented seam screens.
1422
+ */
1423
+ function checkScreenCapabilityContext(args) {
1424
+ const capContext = new Map();
1425
+ for (const cap of args.capabilities) {
1426
+ const ctx = String(cap["context"] ?? "").trim();
1427
+ if (ctx !== "")
1428
+ capContext.set(String(cap["id"]), ctx);
1429
+ }
1430
+ if (capContext.size === 0)
1431
+ return;
1432
+ const featureCap = new Map();
1433
+ for (const f of args.features) {
1434
+ const capId = String(f["capability_id"] ?? "").trim();
1435
+ if (capId !== "")
1436
+ featureCap.set(String(f["id"]), capId);
1437
+ }
1438
+ const screenContext = new Map();
1439
+ for (const scr of args.screens) {
1440
+ const ctx = String(scr["context"] ?? "").trim();
1441
+ if (ctx !== "")
1442
+ screenContext.set(String(scr["id"]), ctx);
1443
+ }
1444
+ for (const edge of args.screenFeature) {
1445
+ const screenId = String(edge["screen_id"] ?? "");
1446
+ const sCtx = screenContext.get(screenId);
1447
+ if (sCtx === undefined || sCtx === CONTEXT_CROSS_CUTTING)
1448
+ continue;
1449
+ const capId = featureCap.get(String(edge["feature_id"] ?? ""));
1450
+ if (capId === undefined)
1451
+ continue;
1452
+ const cCtx = capContext.get(capId);
1453
+ if (cCtx === undefined || cCtx === CONTEXT_CROSS_CUTTING)
1454
+ continue;
1455
+ if (sCtx !== cCtx) {
1456
+ args.findings.add("convention", `screen-capability context mismatch: screen "${screenId}" ` +
1457
+ `(context=${sCtx}) realises capability "${capId}" ` +
1458
+ `(context=${cCtx}) — a seam screen or a mistag (ADR 0170 §4.6)`);
1459
+ }
1460
+ }
1461
+ }
1462
+ /** Catalogues whose rows come in families an input file can drop wholesale, and the column naming the family. */
1463
+ const FAMILY_COLUMN = {
1464
+ quality_scores: "rubric_id",
1465
+ quality_score_dimensions: "rubric_id",
1466
+ };
1467
+ export async function reportCollapsedFamilies(outDir, ownDir, rowsByCatalogue, findings) {
1468
+ for (const [catalogue, column] of Object.entries(FAMILY_COLUMN)) {
1469
+ let before;
1470
+ for (const dir of [outDir, ownDir]) {
1471
+ try {
1472
+ const parsed = await readCatalogueCsv(join(dir, `${catalogue}.csv`));
1473
+ before = new Map();
1474
+ for (const r of parsed.rows) {
1475
+ const k = String(r[column] ?? "");
1476
+ before.set(k, (before.get(k) ?? 0) + 1);
1477
+ }
1478
+ break;
1479
+ }
1480
+ catch {
1481
+ // no committed file there: nothing to compare against
1482
+ }
1483
+ }
1484
+ if (before === undefined)
1485
+ continue;
1486
+ // A catalogue this build emits NOTHING for is not rewritten: the preserve rule keeps the committed
1487
+ // file and says so. Judging it here would fire on every build, since the lost rows stay on disk.
1488
+ if ((rowsByCatalogue.get(catalogue) ?? []).length === 0)
1489
+ continue;
1490
+ const after = new Map();
1491
+ for (const r of rowsByCatalogue.get(catalogue) ?? []) {
1492
+ const k = String(r[column] ?? "");
1493
+ after.set(k, (after.get(k) ?? 0) + 1);
1494
+ }
1495
+ for (const [family, n] of before) {
1496
+ if (family === "" || n === 0 || (after.get(family) ?? 0) > 0)
1497
+ continue;
1498
+ findings.add("coverage", `${catalogue}: family ${column}=${family} had ${n} row(s) in the committed catalogue and this build emits none — an input that produced it has stopped (e.g. a registry that no longer parses); FK, shape and validate all pass on absence, so this is the only signal. If the family was retired ON PURPOSE, commit the rebuilt catalogue: it no longer holds the family, and the next build passes`, "error", `quality_family:${family}`);
1499
+ }
1500
+ }
1501
+ }
1502
+ //# sourceMappingURL=build.js.map