@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.
- package/LICENSE +201 -0
- package/NOTICE +17 -0
- package/README.md +6 -2
- package/dist/author-bin.js +8 -0
- package/dist/author-program.js +99 -0
- package/dist/lib/adr-origin.js +59 -0
- package/dist/lib/agent-harness.js +37 -0
- package/dist/lib/api-surface/__fixtures__/serverless-repo/serverless/serverless.yml +18 -0
- package/dist/lib/api-surface/analyzers/library.js +95 -0
- package/dist/lib/api-surface/analyzers/serverless.js +336 -0
- package/dist/lib/api-surface/analyzers/spring.js +237 -0
- package/dist/lib/api-surface/analyzers/terraform.js +109 -0
- package/dist/lib/api-surface/detect.js +25 -0
- package/dist/lib/api-surface/emit.js +109 -0
- package/dist/lib/api-surface/file-set.js +43 -0
- package/dist/lib/api-surface/index.js +134 -0
- package/dist/lib/api-surface/types.js +17 -0
- package/dist/lib/briefing/index.js +13 -0
- package/dist/lib/briefing/render.js +132 -0
- package/dist/lib/briefing/variables.js +92 -0
- package/dist/lib/catalogue/application-archetype/vocabulary.js +177 -0
- package/dist/lib/catalogue/archetype/maps/android.yaml +66 -0
- package/dist/lib/catalogue/archetype/maps/swiftui.yaml +58 -0
- package/dist/lib/catalogue/archetype/maps/uikit.yaml +48 -0
- package/dist/lib/catalogue/archetype/maps/web-generic.yaml +86 -0
- package/dist/lib/catalogue/archetype/openui-archetype-vocabulary.yaml +46 -0
- package/dist/lib/catalogue/archetype/resolver.js +103 -0
- package/dist/lib/catalogue/archetype/wireframe.js +418 -0
- package/dist/lib/catalogue/baseline-check.js +151 -0
- package/dist/lib/catalogue/build.js +1502 -0
- package/dist/lib/catalogue/builders/adr-supersedes.js +103 -0
- package/dist/lib/catalogue/builders/adrs.js +222 -0
- package/dist/lib/catalogue/builders/application-conformance-coverage.js +47 -0
- package/dist/lib/catalogue/builders/application-data-plane-bindings.js +115 -0
- package/dist/lib/catalogue/builders/c4-components.js +229 -0
- package/dist/lib/catalogue/builders/c4-containers.js +240 -0
- package/dist/lib/catalogue/builders/c4-entity-rollups.js +87 -0
- package/dist/lib/catalogue/builders/c4-external-systems.js +194 -0
- package/dist/lib/catalogue/builders/c4-l1-candidates.js +107 -0
- package/dist/lib/catalogue/builders/c4-systems.js +98 -0
- package/dist/lib/catalogue/builders/capabilities.js +488 -0
- package/dist/lib/catalogue/builders/capability-domain-model.js +74 -0
- package/dist/lib/catalogue/builders/capability-edges.js +256 -0
- package/dist/lib/catalogue/builders/capability-spec-walker.js +99 -0
- package/dist/lib/catalogue/builders/capability-spec.js +348 -0
- package/dist/lib/catalogue/builders/container-dependency.js +183 -0
- package/dist/lib/catalogue/builders/contracts.js +402 -0
- package/dist/lib/catalogue/builders/data-items.js +344 -0
- package/dist/lib/catalogue/builders/data-store-mapping.js +113 -0
- package/dist/lib/catalogue/builders/domain-models.js +175 -0
- package/dist/lib/catalogue/builders/domains.js +119 -0
- package/dist/lib/catalogue/builders/edge-label-merge.js +19 -0
- package/dist/lib/catalogue/builders/entities-manifest.js +160 -0
- package/dist/lib/catalogue/builders/entities-mermaid.js +189 -0
- package/dist/lib/catalogue/builders/entity-persistence.js +102 -0
- package/dist/lib/catalogue/builders/entity-slug.js +47 -0
- package/dist/lib/catalogue/builders/event-catalog-walker.js +186 -0
- package/dist/lib/catalogue/builders/event-edges.js +327 -0
- package/dist/lib/catalogue/builders/events.js +168 -0
- package/dist/lib/catalogue/builders/evidence-class.js +43 -0
- package/dist/lib/catalogue/builders/evidence-target-repo.js +67 -0
- package/dist/lib/catalogue/builders/external-system-exemption.js +120 -0
- package/dist/lib/catalogue/builders/feature-contract.js +77 -0
- package/dist/lib/catalogue/builders/feature-domain-model.js +50 -0
- package/dist/lib/catalogue/builders/feature-grounding-walker.js +254 -0
- package/dist/lib/catalogue/builders/feature-grounding.js +391 -0
- package/dist/lib/catalogue/builders/feature-groups.js +63 -0
- package/dist/lib/catalogue/builders/feature-subscription-channels.js +228 -0
- package/dist/lib/catalogue/builders/feature-tree-walker.js +295 -0
- package/dist/lib/catalogue/builders/features.js +300 -0
- package/dist/lib/catalogue/builders/glossary-xref.js +378 -0
- package/dist/lib/catalogue/builders/glossary.js +204 -0
- package/dist/lib/catalogue/builders/implementations.js +214 -0
- package/dist/lib/catalogue/builders/index.js +64 -0
- package/dist/lib/catalogue/builders/integration-components.js +344 -0
- package/dist/lib/catalogue/builders/integrations.js +181 -0
- package/dist/lib/catalogue/builders/persona-c4system.js +149 -0
- package/dist/lib/catalogue/builders/persona-container.js +133 -0
- package/dist/lib/catalogue/builders/persona-journey-edges.js +133 -0
- package/dist/lib/catalogue/builders/personas.js +152 -0
- package/dist/lib/catalogue/builders/product-edges.js +106 -0
- package/dist/lib/catalogue/builders/product-platform-dependencies.js +405 -0
- package/dist/lib/catalogue/builders/products.js +162 -0
- package/dist/lib/catalogue/builders/quality-scores.js +635 -0
- package/dist/lib/catalogue/builders/repo-capability-exemption.js +61 -0
- package/dist/lib/catalogue/builders/repo-dependency.js +127 -0
- package/dist/lib/catalogue/builders/repo-edges.js +340 -0
- package/dist/lib/catalogue/builders/repositories.js +458 -0
- package/dist/lib/catalogue/builders/scaffolds.js +197 -0
- package/dist/lib/catalogue/builders/screen-tree.js +222 -0
- package/dist/lib/catalogue/builders/screens.js +396 -0
- package/dist/lib/catalogue/builders/service-capability-exemption.js +63 -0
- package/dist/lib/catalogue/builders/service-contract-consumes.js +186 -0
- package/dist/lib/catalogue/builders/service-contract.js +74 -0
- package/dist/lib/catalogue/builders/service-entity-tables.js +187 -0
- package/dist/lib/catalogue/builders/services.js +232 -0
- package/dist/lib/catalogue/builders/system-external-dependency.js +115 -0
- package/dist/lib/catalogue/builders/user-journeys.js +253 -0
- package/dist/lib/catalogue/canonical-schema.yaml +4117 -0
- package/dist/lib/catalogue/cli-version.js +10 -0
- package/dist/lib/catalogue/contract-id.js +49 -0
- package/dist/lib/catalogue/coverage-predicates.js +603 -0
- package/dist/lib/catalogue/coverage-render.js +552 -0
- package/dist/lib/catalogue/coverage-run.js +115 -0
- package/dist/lib/catalogue/csv-reader.js +97 -0
- package/dist/lib/catalogue/csv-writer.js +96 -0
- package/dist/lib/catalogue/data-plane-coherence.js +77 -0
- package/dist/lib/catalogue/ddl-generator.js +233 -0
- package/dist/lib/catalogue/dedupe-keyed-tables.js +0 -0
- package/dist/lib/catalogue/engine-validator.js +239 -0
- package/dist/lib/catalogue/ext-id.js +76 -0
- package/dist/lib/catalogue/feature-grounding-aliases-loader.js +65 -0
- package/dist/lib/catalogue/feature-repo-zero-resolution-summary.js +31 -0
- package/dist/lib/catalogue/finding-identity.js +183 -0
- package/dist/lib/catalogue/findings.js +46 -0
- package/dist/lib/catalogue/fk-validator.js +136 -0
- package/dist/lib/catalogue/freshness.js +70 -0
- package/dist/lib/catalogue/gate-checks.js +338 -0
- package/dist/lib/catalogue/graph-projection.js +346 -0
- package/dist/lib/catalogue/impact-run.js +228 -0
- package/dist/lib/catalogue/manifest-loader.js +435 -0
- package/dist/lib/catalogue/platform-scope.js +7 -0
- package/dist/lib/catalogue/population-checks.js +87 -0
- package/dist/lib/catalogue/relationship-semantics.js +66 -0
- package/dist/lib/catalogue/reverse-product-quality.js +99 -0
- package/dist/lib/catalogue/rubric-coverage-map.yaml +871 -0
- package/dist/lib/catalogue/runway-vocabulary-loader.js +56 -0
- package/dist/lib/catalogue/scaffold-delta.js +261 -0
- package/dist/lib/catalogue/schema-compat.js +52 -0
- package/dist/lib/catalogue/schema-loader.js +87 -0
- package/dist/lib/catalogue/scores-coverage.js +573 -0
- package/dist/lib/catalogue/screen-validation-loader.js +89 -0
- package/dist/lib/catalogue/service-architecture-parser.js +193 -0
- package/dist/lib/catalogue/service-contract-aliases-loader.js +109 -0
- package/dist/lib/catalogue/staleness-verdicts.js +345 -0
- package/dist/lib/catalogue/traceability-loader.js +350 -0
- package/dist/lib/catalogue/validate.js +200 -0
- package/dist/lib/catalogue/version.js +18 -0
- package/dist/lib/conformance/binding.js +70 -0
- package/dist/lib/conformance/finding.js +69 -0
- package/dist/lib/conformance/index.js +578 -0
- package/dist/lib/context-pull/index.js +191 -0
- package/dist/lib/context-pull/resolve.js +70 -0
- package/dist/lib/context-pull/retired-mcp-setup.js +7 -0
- package/dist/lib/contracts-availability/index.js +267 -0
- package/dist/lib/generate-briefing/index.js +77 -0
- package/dist/lib/list/discover.js +100 -0
- package/dist/lib/list/format.js +84 -0
- package/dist/lib/list/index.js +16 -0
- package/dist/lib/lock-file.js +437 -0
- package/dist/lib/pack/log.js +18 -0
- package/dist/lib/pack/schema.js +121 -0
- package/dist/lib/pack/seed.js +182 -0
- package/dist/lib/pack/validate.js +49 -0
- package/dist/lib/package-version.js +92 -0
- package/dist/lib/plan/index.js +69 -0
- package/dist/lib/plan/read-sdd.js +102 -0
- package/dist/lib/plan/render-template.js +84 -0
- package/dist/lib/repo-kind.js +129 -0
- package/dist/lib/scaffold/domain.js +234 -0
- package/dist/lib/scaffold/registry.js +136 -0
- package/dist/lib/scripts.js +7 -0
- package/dist/lib/sdd-index/repo-config.js +245 -0
- package/dist/lib/sdd-reader.js +91 -0
- package/dist/lib/snapshot/advance.js +454 -0
- package/dist/lib/snapshot/diff.js +190 -0
- package/dist/lib/snapshot/fold.js +208 -0
- package/dist/lib/snapshot/land.js +178 -0
- package/dist/lib/snapshot/manifest.js +206 -0
- package/dist/lib/snapshot/targets.js +169 -0
- package/dist/lib/snapshot/verify.js +91 -0
- package/dist/lib/sync/briefing-merge.js +106 -0
- package/dist/lib/sync/bundle-cache.js +109 -0
- package/dist/lib/sync/yaml-block-merge.js +120 -0
- package/dist/lib/validate/aggregate.js +51 -0
- package/dist/lib/validate/bundle-coherence.js +74 -0
- package/dist/lib/validate/dispatch.js +243 -0
- package/dist/lib/validate/index.js +110 -0
- package/dist/lib/validate/plan.js +54 -0
- package/dist/lib/validate/runner.js +85 -0
- package/dist/lib/validate/yaml-parse.js +112 -0
- package/dist/maintain-version.json +4 -0
- package/dist/verbs/api-surface.js +71 -0
- package/dist/verbs/catalogue-check.js +28 -0
- package/dist/verbs/catalogue-verify.js +114 -0
- package/dist/verbs/conformance.js +34 -0
- package/dist/verbs/context.js +39 -0
- package/dist/verbs/contracts.js +34 -0
- package/dist/verbs/generate-briefing.js +29 -0
- package/dist/verbs/list.js +45 -0
- package/dist/verbs/pack-validate.js +17 -0
- package/dist/verbs/path-option.js +25 -0
- package/dist/verbs/plan.js +42 -0
- package/dist/verbs/render.js +87 -0
- package/dist/verbs/scaffold.js +83 -0
- package/dist/verbs/snapshot.js +134 -0
- package/dist/verbs/validate.js +48 -0
- package/dist/verify-program.js +122 -0
- package/dist/verify-version.json +4 -0
- package/package.json +32 -4
- package/scripts/validate-app-dependencies.sh +664 -0
- package/scripts/validate-app-docs.sh +199 -0
- 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
|