@sdd-method/maintain-cli 0.0.0-stage → 0.1.0-rc.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/lib/adr-origin.js +59 -0
- package/dist/lib/agent-harness.js +28 -0
- package/dist/lib/briefing/index.js +13 -0
- package/dist/lib/briefing/render.js +123 -0
- package/dist/lib/briefing/variables.js +92 -0
- package/dist/lib/bundle.js +123 -0
- package/dist/lib/catalogue/aggregate.js +2404 -0
- package/dist/lib/catalogue/aggregation-manifest-loader.js +173 -0
- package/dist/lib/catalogue/application-archetype/expectations.js +119 -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/contract-path-resolution.js +149 -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-item-coverage.js +142 -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 +69 -0
- package/dist/lib/catalogue/gate-checks.js +338 -0
- package/dist/lib/catalogue/graph-projection.js +346 -0
- package/dist/lib/catalogue/graph-run.js +64 -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/ci-binding/index.js +209 -0
- package/dist/lib/ci-binding/module.js +204 -0
- package/dist/lib/conformance/binding.js +70 -0
- package/dist/lib/context-pull/index.js +151 -0
- package/dist/lib/context-pull/resolve.js +70 -0
- package/dist/lib/generate-briefing/index.js +77 -0
- package/dist/lib/hooks-seed/copilot.js +167 -0
- package/dist/lib/hooks-seed/entries.js +47 -0
- package/dist/lib/hooks-seed/index.js +17 -0
- package/dist/lib/hooks-seed/lifecycle-mapping.js +28 -0
- package/dist/lib/hooks-seed/merge.js +128 -0
- package/dist/lib/hooks-seed/types.js +20 -0
- package/dist/lib/init/ci-workflow.js +89 -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 +432 -0
- package/dist/lib/mcp-seed/entries.js +14 -0
- package/dist/lib/mcp-seed/index.js +28 -0
- package/dist/lib/mcp-seed/merge.js +99 -0
- package/dist/lib/mcp-seed/types.js +13 -0
- package/dist/lib/pack/builder.js +303 -0
- package/dist/lib/pack/index.js +87 -0
- package/dist/lib/pack/leakage.js +144 -0
- package/dist/lib/pack/log.js +18 -0
- package/dist/lib/pack/publish-transform.js +233 -0
- package/dist/lib/pack/reference-closure.js +125 -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/repo-kind.js +129 -0
- package/dist/lib/scripts.js +7 -0
- package/dist/lib/snapshot/catalogue-diff.js +219 -0
- package/dist/lib/snapshot/manifest.js +206 -0
- package/dist/lib/sync/adr-transitions.js +250 -0
- package/dist/lib/sync/apply.js +91 -0
- package/dist/lib/sync/briefing-merge.js +90 -0
- package/dist/lib/sync/bundle-cache.js +109 -0
- package/dist/lib/sync/bundle-source.js +61 -0
- package/dist/lib/sync/config.js +72 -0
- package/dist/lib/sync/copilot-hooks-merge.js +108 -0
- package/dist/lib/sync/diff.js +251 -0
- package/dist/lib/sync/index.js +809 -0
- package/dist/lib/sync/settings-merge.js +216 -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-bin.js +8 -0
- package/dist/maintain-program.js +91 -0
- package/dist/maintain-version.json +4 -0
- package/dist/verbs/catalogue-maintain.js +110 -0
- package/dist/verbs/catalogue-verify.js +114 -0
- package/dist/verbs/context.js +39 -0
- package/dist/verbs/generate-briefing.js +29 -0
- package/dist/verbs/list.js +45 -0
- package/dist/verbs/pack-build.js +31 -0
- package/dist/verbs/pack-validate.js +17 -0
- package/dist/verbs/path-option.js +25 -0
- package/dist/verbs/render.js +87 -0
- package/dist/verbs/sync.js +98 -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,2404 @@
|
|
|
1
|
+
// Catalogue aggregator — `sdd-cli catalogue aggregate`.
|
|
2
|
+
//
|
|
3
|
+
// Implements the 8-step contract in
|
|
4
|
+
// docs/method/methods/catalogue-builder-specification.md
|
|
5
|
+
// §"Aggregator contract":
|
|
6
|
+
//
|
|
7
|
+
// 1. Discover sibling SDDs via aggregation-manifest.yaml.
|
|
8
|
+
// 2. Pull each sibling's catalogues/ (filesystem only in v1).
|
|
9
|
+
// 3. Verify schema-version alignment across SDDs.
|
|
10
|
+
// 4. Concatenate per-catalogue rows.
|
|
11
|
+
// 5. Stamp source_sdd_id on every node-catalogue row.
|
|
12
|
+
// 6. Fail on duplicate primary keys.
|
|
13
|
+
// 7. Run full FK validation including external references.
|
|
14
|
+
// 8. Emit catalogues/ + aggregation-manifest.json.
|
|
15
|
+
//
|
|
16
|
+
// The ecosystem-root SDD itself contributes its own catalogues to the
|
|
17
|
+
// aggregation alongside the siblings — it doesn't list itself in the
|
|
18
|
+
// manifest. That's how single-SDD ecosystems and federated ecosystems
|
|
19
|
+
// share the same code path: with zero siblings the aggregator just
|
|
20
|
+
// re-emits the ecosystem-root's catalogues stamped with source_sdd_id.
|
|
21
|
+
import { readFile, stat, writeFile } from "node:fs/promises";
|
|
22
|
+
import { basename, join, relative, resolve } from "node:path";
|
|
23
|
+
import { CLI_VERSION } from "./cli-version.js";
|
|
24
|
+
import { loadAggregationManifest, DEFAULT_AGGREGATION_MANIFEST_REL, } from "./aggregation-manifest-loader.js";
|
|
25
|
+
import { isInRange, loadSddManifest } from "./manifest-loader.js";
|
|
26
|
+
import { SCHEMA_COMPAT } from "./schema-compat.js";
|
|
27
|
+
import { loadCanonicalSchema } from "./schema-loader.js";
|
|
28
|
+
import { readCatalogueCsv, parseCatalogueCsv } from "./csv-reader.js";
|
|
29
|
+
import { writeCatalogue } from "./csv-writer.js";
|
|
30
|
+
import { buildLegacyContractIdMap } from "./contract-id.js";
|
|
31
|
+
import { validatedPopulation, validateWithEngineToLog } from "./engine-validator.js";
|
|
32
|
+
import { PLATFORM_STAMP_COLUMN, isPlatformScopedFk } from "./fk-validator.js";
|
|
33
|
+
import { FindingsLog } from "./findings.js";
|
|
34
|
+
import { readLockFile } from "../lock-file.js";
|
|
35
|
+
import { mergeEdgeLabel } from "./builders/edge-label-merge.js";
|
|
36
|
+
import { isAsyncContractType } from "./builders/product-platform-dependencies.js";
|
|
37
|
+
import { resolveContractPaths } from "./contract-path-resolution.js";
|
|
38
|
+
import { buildRepoEdges } from "./builders/repo-edges.js";
|
|
39
|
+
import { buildCapabilitySpec } from "./builders/capability-spec.js";
|
|
40
|
+
import { buildFeatureGrounding } from "./builders/feature-grounding.js";
|
|
41
|
+
import { loadFeatureGroundingAliases } from "./feature-grounding-aliases-loader.js";
|
|
42
|
+
import { buildFeatureContract } from "./builders/feature-contract.js";
|
|
43
|
+
import { buildCapabilityEvent } from "./builders/event-edges.js";
|
|
44
|
+
import { discoverVocabulary } from "./application-archetype/vocabulary.js";
|
|
45
|
+
import { checkDataPlaneOverlayAtAggregation } from "./application-archetype/expectations.js";
|
|
46
|
+
import { indexServiceArchitectures, readServiceMetadata, } from "./service-architecture-parser.js";
|
|
47
|
+
export async function runCatalogueAggregate(inputs, write) {
|
|
48
|
+
const sddPath = resolve(inputs.sddPath ?? process.cwd());
|
|
49
|
+
const findings = new FindingsLog();
|
|
50
|
+
// 0. Load canonical schema.
|
|
51
|
+
const schema = await loadCanonicalSchema();
|
|
52
|
+
write(`sdd-cli catalogue aggregate (canonical schema ${schema.schemaVersion})\n`);
|
|
53
|
+
// 0a. Load ecosystem-root manifest.
|
|
54
|
+
const rootManifest = await loadSddManifest(sddPath, {
|
|
55
|
+
cliVersion: CLI_VERSION,
|
|
56
|
+
});
|
|
57
|
+
write(` root: ${rootManifest.sddId} profile: ${rootManifest.profile} tier: ${rootManifest.profile === "organisation-profile" ? "organisation" : "platform"}\n`);
|
|
58
|
+
// 1. Resolve aggregation-manifest path + load.
|
|
59
|
+
//
|
|
60
|
+
// Two anchors, deliberately, because the terms are different KINDS of path:
|
|
61
|
+
//
|
|
62
|
+
// * `inputs.manifestPath` is TYPED BY AN OPERATOR on the command line
|
|
63
|
+
// (`--manifest`), so it anchors to the process cwd like every other CLI
|
|
64
|
+
// operand. This is ON PURPOSE — do not "fix" it to follow --sdd-path.
|
|
65
|
+
// * `aggregationManifestPath` is DECLARED in <sdd-path>/.sdd/sdd-manifest.yaml
|
|
66
|
+
// and is documented relative to the SDD root, so it anchors to `sddPath`.
|
|
67
|
+
// Anchoring it to cwd was the B3 defect: every real ecosystem root declares
|
|
68
|
+
// it (the forward estate declares the default name verbatim), so running
|
|
69
|
+
// `aggregate --sdd-path <root>` from anywhere else exited 1 naming a path
|
|
70
|
+
// under the CALLER's directory — which reads as the default branch being
|
|
71
|
+
// broken, though that branch was always anchored correctly (below, and
|
|
72
|
+
// guarded by its own test).
|
|
73
|
+
const manifestPath = inputs.manifestPath !== undefined
|
|
74
|
+
? resolve(inputs.manifestPath)
|
|
75
|
+
: rootManifest.aggregationManifestPath !== undefined
|
|
76
|
+
? resolve(sddPath, rootManifest.aggregationManifestPath)
|
|
77
|
+
: join(sddPath, DEFAULT_AGGREGATION_MANIFEST_REL);
|
|
78
|
+
const aggManifest = await loadAggregationManifest(manifestPath, {
|
|
79
|
+
cliVersion: CLI_VERSION,
|
|
80
|
+
ecosystemRootPath: sddPath,
|
|
81
|
+
});
|
|
82
|
+
if (aggManifest.ecosystemRoot !== rootManifest.sddId) {
|
|
83
|
+
throw new Error(`aggregation manifest root_sdd_id="${aggManifest.ecosystemRoot}"\n` +
|
|
84
|
+
` does not match the owning SDD's sdd_id="${rootManifest.sddId}"\n` +
|
|
85
|
+
` (rule: root_sdd_id_consistent, M-ADR 0208 §4.3)\n` +
|
|
86
|
+
` (manifest: ${manifestPath})`);
|
|
87
|
+
}
|
|
88
|
+
write(` aggregation manifest: ${relative(sddPath, manifestPath)}\n`);
|
|
89
|
+
write(` siblings: ${aggManifest.siblings.length}\n`);
|
|
90
|
+
// The work queue, reported rather than merely parseable: an audit asking "what is
|
|
91
|
+
// still pending authoring?" gets it from a normal aggregate run (M-ADR 0195 §4.3).
|
|
92
|
+
if (aggManifest.pendingMembers.length > 0) {
|
|
93
|
+
write(` pending authoring: ${aggManifest.pendingMembers.length}\n`);
|
|
94
|
+
for (const p of aggManifest.pendingMembers) {
|
|
95
|
+
write(` - ${p.sddId}: ${p.reason}${p.blockedOn ? ` [blocked on: ${p.blockedOn}]` : ""}\n`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// 2. Pull (filesystem only in v1) — pre-checked at manifest load. Now
|
|
99
|
+
// cross-check each sibling's own .sdd/sdd-manifest.yaml + its
|
|
100
|
+
// vendored schema vs the aggregator's.
|
|
101
|
+
const crossCheck = await crossCheckSiblings(sddPath, aggManifest.siblings, schema, write);
|
|
102
|
+
if (aggManifest.rootFieldDeprecated) {
|
|
103
|
+
findings.add("convention", `aggregation manifest uses deprecated key \`ecosystem_root\` — rename to \`root_sdd_id\` (M-ADR 0208 §4.3; alias removed at 2.0.0)`);
|
|
104
|
+
}
|
|
105
|
+
// M-ADR 0208 §4.2/§4.4: the tier decides the platform_sdd_id fill rule.
|
|
106
|
+
const rootIsOrganisation = rootManifest.profile === "organisation-profile";
|
|
107
|
+
// 3. Schema-version alignment was checked in crossCheckSiblings.
|
|
108
|
+
// 4 + 5. Concatenate per-catalogue rows from the ecosystem-root + every
|
|
109
|
+
// sibling, stamping source_sdd_id on node catalogues as we go.
|
|
110
|
+
const rowsByCatalogue = new Map();
|
|
111
|
+
const perSiblingCounts = new Map();
|
|
112
|
+
for (const cat of schema.catalogues) {
|
|
113
|
+
rowsByCatalogue.set(cat.name, []);
|
|
114
|
+
}
|
|
115
|
+
perSiblingCounts.set(rootManifest.sddId, await mergeCatalogues(schema, rowsByCatalogue, join(sddPath, "catalogues"), rootManifest.sddId, rootIsOrganisation ? null : rootManifest.sddId));
|
|
116
|
+
// source_sdd_id -> the platform the §4.4 fill rule assigned it. Kept for the final
|
|
117
|
+
// pass (fillDerivedPlatformStamps): rows the aggregator itself constructs AFTER the
|
|
118
|
+
// node loader (0189 adjudication conversions, re-derived edges) set source_sdd_id
|
|
119
|
+
// explicitly and would otherwise leave platform_sdd_id empty under FK PASS —
|
|
120
|
+
// the same silent shape as the empty platform leg on cross_sdd_platform_capability.
|
|
121
|
+
const platformBySource = new Map();
|
|
122
|
+
if (!rootIsOrganisation)
|
|
123
|
+
platformBySource.set(rootManifest.sddId, rootManifest.sddId);
|
|
124
|
+
for (const sib of aggManifest.siblings) {
|
|
125
|
+
// M-ADR 0208 §4.4 fill rule, by pull path. An empty stamp is UNRESOLVED and is
|
|
126
|
+
// reported below by checkPlatformKindUniqueness; it is never a key value.
|
|
127
|
+
let platformSddId;
|
|
128
|
+
if (!rootIsOrganisation) {
|
|
129
|
+
platformSddId = rootManifest.sddId; // every row in a platform-tier aggregate belongs to it
|
|
130
|
+
}
|
|
131
|
+
else if (sib.level === "organisation") {
|
|
132
|
+
platformSddId = null; // arrived stamped from the child's own platform-tier run
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
// CATEGORY decides root-ness, not profile: platform-subsystem is platform-profile
|
|
136
|
+
// too, but it is a MEMBER contained by exactly one platform (M-ADR 0208 §4.1).
|
|
137
|
+
const sibCategory = crossCheck.siblingCategories.get(sib.sddId);
|
|
138
|
+
const declared = crossCheck.siblingDeclaredPlatform.get(sib.sddId);
|
|
139
|
+
if (sibCategory === "platform-root") {
|
|
140
|
+
platformSddId = sib.sddId; // a platform-root is its own platform
|
|
141
|
+
}
|
|
142
|
+
else if (declared !== undefined) {
|
|
143
|
+
platformSddId = declared; // a member that declares its platform gets it
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
platformSddId = null;
|
|
147
|
+
findings.add("coverage", `platform_sdd_id: sibling "${sib.sddId}" (${sibCategory ?? crossCheck.siblingProfiles.get(sib.sddId) ?? "?"}) is a MEMBER pulled directly at organisation tier with no declared platform — rows left unstamped (M-ADR 0208 §4.4; federate it under its platform's aggregate, or set platform_sdd_id in its manifest)`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (crossCheck.deprecatedPlatformField.has(sib.sddId)) {
|
|
151
|
+
findings.add("convention", `sibling "${sib.sddId}" manifest uses deprecated key \`ecosystem_root\` — rename to \`platform_sdd_id\` (M-ADR 0208 §4.3; alias removed at 2.0.0)`);
|
|
152
|
+
}
|
|
153
|
+
if (platformSddId !== null)
|
|
154
|
+
platformBySource.set(sib.sddId, platformSddId);
|
|
155
|
+
perSiblingCounts.set(sib.sddId, await mergeCatalogues(schema, rowsByCatalogue, sib.cataloguesPath, sib.sddId, platformSddId));
|
|
156
|
+
}
|
|
157
|
+
// @rule aggregate-cross-sdd-platform-capability-derivation
|
|
158
|
+
// 5a. Derive cross_sdd_platform_capability — federated edge, schema 2.9.0.
|
|
159
|
+
// Joins each consuming SDD's product_platform_dependencies rows against
|
|
160
|
+
// every platform SDD's capability_service rows on target_service_id ==
|
|
161
|
+
// capability_service.service_id, emitting one row per resolved
|
|
162
|
+
// (consuming-product, platform-capability) pair. Per-SDD builders
|
|
163
|
+
// emit nothing for this catalogue; the aggregator owns it entirely.
|
|
164
|
+
// See content/plans/v0-9-0-cohort-sweep-derived-bundle.md
|
|
165
|
+
// §"Cross-SDD platform-capability-edge" + ADR 0134.
|
|
166
|
+
const crossSddRows = deriveCrossSddPlatformCapability(rowsByCatalogue.get("product_platform_dependencies") ?? [], rowsByCatalogue.get("capability_service") ?? [], findings, rowsByCatalogue.get("capabilities") ?? []);
|
|
167
|
+
rowsByCatalogue.set("cross_sdd_platform_capability", crossSddRows);
|
|
168
|
+
// @rule aggregate-feature-integration-support
|
|
169
|
+
// 5a-di. Derive feature_integration_support (M-ADR 0216 §4.4): supply and demand come from
|
|
170
|
+
// different SDDs, so coverage is computed only here. The demand-declared fraction is printed
|
|
171
|
+
// beside the verdicts — a coverage table is only as complete as its demand side (§5).
|
|
172
|
+
const coverage = deriveFeatureIntegrationSupport(rowsByCatalogue);
|
|
173
|
+
rowsByCatalogue.set("feature_integration_support", coverage.rows);
|
|
174
|
+
write(` data-item coverage (M-ADR 0216): ${coverage.stats.featuresWithRequirements} of ${coverage.stats.features} feature(s) declare a requirement; ` +
|
|
175
|
+
`${coverage.stats.integrationsWithSupply} of ${coverage.stats.integrations} integration(s) declare supply; ` +
|
|
176
|
+
`${coverage.rows.length} pair(s): ${Object.entries(coverage.stats.byVerdict).sort().map(([k, v]) => `${k} ${v}`).join(", ") || "none"} ` +
|
|
177
|
+
`(supported = reaches the canonical boundary, not end to end)\n`);
|
|
178
|
+
// Every SDD root in this aggregation, by sdd_id: the ecosystem root plus each
|
|
179
|
+
// sibling. This is precisely what the app tier does not have and cannot obtain.
|
|
180
|
+
const sddRootsById = new Map([
|
|
181
|
+
[rootManifest.sddId, sddPath],
|
|
182
|
+
...aggManifest.siblings.map((sib) => [sib.sddId, sib.sddRoot]),
|
|
183
|
+
]);
|
|
184
|
+
// The same roots, additionally reachable by DIRECTORY BASENAME. An author who
|
|
185
|
+
// writes a `platform/<name>/...` contract path is reading that name off the
|
|
186
|
+
// filesystem, so it is the directory — which is routinely not the sdd_id (one
|
|
187
|
+
// estate declares `experience-platform` in a directory named
|
|
188
|
+
// `sdd-experience-platform`). Kept separate from sddRootsById on purpose: the
|
|
189
|
+
// try-every-root pass must iterate each root ONCE, or a root reachable under two
|
|
190
|
+
// names would count as two hits and report a false ambiguity.
|
|
191
|
+
const sddRootAliases = new Map(sddRootsById);
|
|
192
|
+
for (const root of sddRootsById.values()) {
|
|
193
|
+
sddRootAliases.set(basename(root), root);
|
|
194
|
+
}
|
|
195
|
+
// 5a-quater. M-ADR 0204 §4.2–§4.3: corroborate off-platform rows. Their
|
|
196
|
+
// external-system id SHOULD match a c4_external_systems entry — WARNING when
|
|
197
|
+
// it does not, because the C4 surface may legitimately lag discovery, and a
|
|
198
|
+
// declared FK could only fail. A capability id surviving on such a row (a
|
|
199
|
+
// member built by a pre-0204 CLI can still carry one) is reported per §4.3.
|
|
200
|
+
{
|
|
201
|
+
const extIds = new Set((rowsByCatalogue.get("c4_external_systems") ?? []).map((r) => String(r["id"] ?? "").trim()));
|
|
202
|
+
let offPlatform = 0;
|
|
203
|
+
let corroborated = 0;
|
|
204
|
+
for (const ppd of rowsByCatalogue.get("product_platform_dependencies") ?? []) {
|
|
205
|
+
const locus = String(ppd["target_locus"] ?? "").trim().toLowerCase();
|
|
206
|
+
if (locus !== "external" && locus !== "legacy")
|
|
207
|
+
continue;
|
|
208
|
+
offPlatform++;
|
|
209
|
+
const who = `${String(ppd["source_sdd_id"] ?? "")}/${String(ppd["product_id"] ?? "")}`;
|
|
210
|
+
const extId = String(ppd["target_external_system_id"] ?? "").trim();
|
|
211
|
+
if (extId !== "" && extIds.has(extId)) {
|
|
212
|
+
corroborated++;
|
|
213
|
+
}
|
|
214
|
+
else if (extId !== "") {
|
|
215
|
+
findings.add("external", `product_platform_dependencies: ${who} targets ${locus} system "${extId}" with no corroborating c4_external_systems entry (M-ADR 0204 §4.2) — attested by the consumer only`);
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
findings.add("external", `product_platform_dependencies: ${who} declares a ${locus} target with no external-system id — keyed by contract_path only`);
|
|
219
|
+
}
|
|
220
|
+
const capLeft = String(ppd["target_capability_id"] ?? "").trim();
|
|
221
|
+
if (capLeft !== "") {
|
|
222
|
+
findings.add("convention", `product_platform_dependencies: ${who} carries target_capability_id="${capLeft}" on a ${locus} row — an off-platform target has no platform capability (M-ADR 0204 §4.3)`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (offPlatform > 0) {
|
|
226
|
+
write(` off-platform dependencies: ${offPlatform} declared, ${corroborated} corroborated by c4_external_systems\n`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
// 5a-ter. Resolve every declared contract_path (A2 step 5 / check 3b). The
|
|
230
|
+
// app-tier gate can only resolve a path against the ONE platform root it was
|
|
231
|
+
// given, so a contract owned by a sibling SDD is reported as missing when it
|
|
232
|
+
// exists — a false failure no app-tier fix can reach. Here every SDD root is
|
|
233
|
+
// present, so the resolution is possible for the first time.
|
|
234
|
+
//
|
|
235
|
+
// Findings only, no rows and no rewriting: an ambiguous path is reported and
|
|
236
|
+
// left alone rather than resolved to a guess.
|
|
237
|
+
const contractPathSummary = await resolveContractPaths(rowsByCatalogue.get("product_platform_dependencies") ?? [], sddRootsById, sddRootAliases, fileExists, findings);
|
|
238
|
+
// Stated, not merely computed. "0 unresolved" and "nothing was resolvable" are
|
|
239
|
+
// different results and must not print the same (M-ADR 0200 §4.3).
|
|
240
|
+
{
|
|
241
|
+
const c = contractPathSummary;
|
|
242
|
+
const declared = c.resolved + c.missing + c.ambiguous + c.nonPath;
|
|
243
|
+
write(` contract paths: ${c.resolved} of ${declared} declared resolved` +
|
|
244
|
+
` (${c.missing} missing, ${c.ambiguous} ambiguous, ${c.nonPath} not a path;` +
|
|
245
|
+
` ${c.absent} dependency(ies) declare none) across ${sddRootsById.size} SDD root(s)\n`);
|
|
246
|
+
}
|
|
247
|
+
// 5a-bis. M-ADR 0198: re-derive the derived edges whose join can legitimately span
|
|
248
|
+
// sibling SDDs, over the merged rows above. Concatenation alone cannot produce a
|
|
249
|
+
// cross-SDD row for these — neither member holds both join inputs, so neither computes
|
|
250
|
+
// it, and appending finished per-member joins never creates a row no input produced.
|
|
251
|
+
//
|
|
252
|
+
// §4.1's rule (a catalogue qualifies when it is derived AND at least one join input can
|
|
253
|
+
// carry a cross-SDD reference) selects five: feature_repo, repo_capability, repo_contract,
|
|
254
|
+
// feature_contract, capability_event. One shared mechanism (rederiveAppend below) runs
|
|
255
|
+
// all five so a sixth qualifying catalogue is a new call, not new merge logic.
|
|
256
|
+
//
|
|
257
|
+
// feature_repo moved into scope under §4.1's "automatic movement" clause: its derived leg
|
|
258
|
+
// resolves free-text evidence tokens (M-ADR 0143 §4.4) against `repositories.id`, and the
|
|
259
|
+
// merged repositories table carries every sibling's repos — a platform spec that names a
|
|
260
|
+
// repo registered by a MEMBER SDD can only resolve here. The spec text is not a catalogue,
|
|
261
|
+
// so the derived leg is re-run the way §4.2 describes for every builder: the two feature
|
|
262
|
+
// extractors are invoked over each manifest-read contributor's docs with the MERGED
|
|
263
|
+
// repository / service / capability id sets as their resolution inputs. Only their
|
|
264
|
+
// feature_repo output is kept; feature rows and their per-row findings were already
|
|
265
|
+
// emitted at member tier and are not repeated. level:organisation siblings expose no docs
|
|
266
|
+
// (pre-built catalogues-aggregated/ only) and are skipped — their rows arrived from their
|
|
267
|
+
// own platform-tier run, where this pass has already happened.
|
|
268
|
+
//
|
|
269
|
+
// §4.2: only the derived leg is re-run — each builder is invoked with its authored-leg
|
|
270
|
+
// inputs (configs / manifests) empty, which runs zero iterations of that path and yields
|
|
271
|
+
// the derived rows alone; the authored rows are already correct via concatenation and are
|
|
272
|
+
// left untouched. §4.2/§4.3: rederiveAppend adds the result to what concatenation already
|
|
273
|
+
// produced, keeping the concatenated row on any primary-key collision — never a replace.
|
|
274
|
+
{
|
|
275
|
+
const mergedRepoIds = new Set((rowsByCatalogue.get("repositories") ?? []).map((r) => String(r["id"])));
|
|
276
|
+
const mergedServiceIds = new Set((rowsByCatalogue.get("services") ?? []).map((r) => String(r["id"])));
|
|
277
|
+
const mergedCapabilityIds = new Set((rowsByCatalogue.get("capabilities") ?? []).map((r) => String(r["id"])));
|
|
278
|
+
const mergedFeatureIds = new Set((rowsByCatalogue.get("features") ?? []).map((r) => String(r["id"])));
|
|
279
|
+
// Re-derivation FILLS ABSENCES; it does not widen. A feature its own member
|
|
280
|
+
// build already anchored keeps exactly those anchors: re-running bare-token
|
|
281
|
+
// resolution against every sibling's repo ids over an already-resolved row
|
|
282
|
+
// mostly finds collisions (measured on one estate: 13 of the 43 rows it would
|
|
283
|
+
// have added to anchored features resolved to repo ids like `web`, `proxy`,
|
|
284
|
+
// `service`, `batch` owned by unrelated SDDs; of the 261 rows added to
|
|
285
|
+
// UNANCHORED features, 4 did). The motivating case — and §4.1's clause — is a
|
|
286
|
+
// row no member could produce, which is exactly an unanchored feature.
|
|
287
|
+
const anchoredFeatureIds = new Set((rowsByCatalogue.get("feature_repo") ?? []).map((r) => String(r["feature_id"])));
|
|
288
|
+
const contributors = [
|
|
289
|
+
{ sddPath, profile: rootManifest.profile, sddId: rootManifest.sddId },
|
|
290
|
+
];
|
|
291
|
+
for (const sib of aggManifest.siblings) {
|
|
292
|
+
if (sib.level === "organisation")
|
|
293
|
+
continue;
|
|
294
|
+
const profile = crossCheck.siblingProfiles.get(sib.sddId);
|
|
295
|
+
if (profile === undefined)
|
|
296
|
+
continue;
|
|
297
|
+
contributors.push({ sddPath: sib.sddRoot, profile, sddId: sib.sddId });
|
|
298
|
+
}
|
|
299
|
+
const rederivedFeatureRepo = [];
|
|
300
|
+
// Both extractors, over several contributors, can yield the same edge (the same
|
|
301
|
+
// feature grounded in the same repo from two tables); rederiveAppend dedups only
|
|
302
|
+
// against the CONCATENATED rows, so dedup the re-derived set itself here or the
|
|
303
|
+
// duplicate-PK check fails the aggregate on two copies of one correct fact.
|
|
304
|
+
const rederivedIds = new Set();
|
|
305
|
+
const scratch = new FindingsLog(); // member-tier findings already emitted; not repeated
|
|
306
|
+
for (const c of contributors) {
|
|
307
|
+
const aliases = await loadFeatureGroundingAliases(c.sddPath);
|
|
308
|
+
const common = {
|
|
309
|
+
sddPath: c.sddPath,
|
|
310
|
+
findings: scratch,
|
|
311
|
+
profile: c.profile,
|
|
312
|
+
productId: c.sddId,
|
|
313
|
+
knownCapabilityIds: mergedCapabilityIds,
|
|
314
|
+
knownServiceIds: mergedServiceIds,
|
|
315
|
+
knownRepoKeys: mergedRepoIds,
|
|
316
|
+
// Against every sibling's ids a prose word collides with a repo that happens to be
|
|
317
|
+
// named like it ("templates"); here only identifier-shaped tokens name a repo.
|
|
318
|
+
repoTokenPolicy: "identifier",
|
|
319
|
+
existingFeatureIds: new Set(),
|
|
320
|
+
aliases,
|
|
321
|
+
};
|
|
322
|
+
const [grounding, spec] = await Promise.all([
|
|
323
|
+
buildFeatureGrounding(common),
|
|
324
|
+
buildCapabilitySpec(common),
|
|
325
|
+
]);
|
|
326
|
+
for (const fr of [...grounding.featureRepoRows, ...spec.featureRepoRows]) {
|
|
327
|
+
const featureId = String(fr["feature_id"] ?? "");
|
|
328
|
+
if (!mergedFeatureIds.has(featureId))
|
|
329
|
+
continue;
|
|
330
|
+
if (anchoredFeatureIds.has(featureId))
|
|
331
|
+
continue;
|
|
332
|
+
const id = String(fr["id"] ?? "");
|
|
333
|
+
if (id === "" || rederivedIds.has(id))
|
|
334
|
+
continue;
|
|
335
|
+
rederivedIds.add(id);
|
|
336
|
+
rederivedFeatureRepo.push(fr);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
const before = (rowsByCatalogue.get("feature_repo") ?? []).length;
|
|
340
|
+
rederiveAppend(rowsByCatalogue, "feature_repo", rederivedFeatureRepo);
|
|
341
|
+
const added = (rowsByCatalogue.get("feature_repo") ?? []).length - before;
|
|
342
|
+
if (added > 0) {
|
|
343
|
+
findings.add("coverage", `feature_repo: ${added} row(s) re-derived at aggregate tier for features that had NO anchor at member tier — evidence tokens naming a repo registered by another contributor resolve only against the merged repositories; already-anchored features are not widened (M-ADR 0198 §4.1 as amended; ${contributors.length} contributor doc trees scanned)`);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
const repoEdges = buildRepoEdges({
|
|
347
|
+
findings,
|
|
348
|
+
configs: [],
|
|
349
|
+
capabilityIdByPath: new Map(),
|
|
350
|
+
contractIdByPath: new Map(),
|
|
351
|
+
serviceRows: rowsByCatalogue.get("services") ?? [],
|
|
352
|
+
capabilityServiceRows: rowsByCatalogue.get("capability_service") ?? [],
|
|
353
|
+
serviceContractRows: rowsByCatalogue.get("service_contract") ?? [],
|
|
354
|
+
capabilityFeatureRows: rowsByCatalogue.get("capability_feature") ?? [],
|
|
355
|
+
featureRepoRows: rowsByCatalogue.get("feature_repo") ?? [],
|
|
356
|
+
});
|
|
357
|
+
rederiveAppend(rowsByCatalogue, "repo_capability", repoEdges.repoCapability);
|
|
358
|
+
rederiveAppend(rowsByCatalogue, "repo_contract", repoEdges.repoContract);
|
|
359
|
+
const featureContractRows = buildFeatureContract({
|
|
360
|
+
findings,
|
|
361
|
+
capabilityFeatureRows: rowsByCatalogue.get("capability_feature") ?? [],
|
|
362
|
+
capabilityContractRows: rowsByCatalogue.get("capability_contract") ?? [],
|
|
363
|
+
featureServiceRows: rowsByCatalogue.get("feature_service") ?? [],
|
|
364
|
+
serviceContractRows: rowsByCatalogue.get("service_contract") ?? [],
|
|
365
|
+
});
|
|
366
|
+
rederiveAppend(rowsByCatalogue, "feature_contract", featureContractRows);
|
|
367
|
+
const capabilityEventRows = buildCapabilityEvent({
|
|
368
|
+
findings,
|
|
369
|
+
manifests: [],
|
|
370
|
+
knownCapabilityIds: new Set((rowsByCatalogue.get("capabilities") ?? []).map((r) => String(r["id"]))),
|
|
371
|
+
knownEventIds: new Set((rowsByCatalogue.get("events") ?? []).map((r) => String(r["id"]))),
|
|
372
|
+
capabilityServiceRows: rowsByCatalogue.get("capability_service") ?? [],
|
|
373
|
+
serviceEventRows: rowsByCatalogue.get("service_event") ?? [],
|
|
374
|
+
});
|
|
375
|
+
rederiveAppend(rowsByCatalogue, "capability_event", capabilityEventRows);
|
|
376
|
+
// 5a-iii. Archetype data-plane expectations overlay (M-ADR 0191 §4.3) — the
|
|
377
|
+
// deviation-visible WARN + conformance surface, run at AGGREGATION tier where
|
|
378
|
+
// the reference-architecture bundle's `expected_bindings` resolve (the
|
|
379
|
+
// ecosystem-root repo caches it). Per-SDD builds deliberately do not run this
|
|
380
|
+
// — an app SDD may consume an adopter context-pack, not the RA bundle.
|
|
381
|
+
checkDataPlaneOverlayAtAggregation({
|
|
382
|
+
products: rowsByCatalogue.get("products") ?? [],
|
|
383
|
+
bindings: rowsByCatalogue.get("application_data_plane_bindings") ?? [],
|
|
384
|
+
deviations: rowsByCatalogue.get("application_data_plane_deviations") ?? [],
|
|
385
|
+
conformanceCoverage: rowsByCatalogue.get("application_conformance_coverage") ?? [],
|
|
386
|
+
vocab: await discoverVocabulary(sddPath),
|
|
387
|
+
findings,
|
|
388
|
+
});
|
|
389
|
+
// 5a-ii. Resolve cross-SDD screen_feature edges (schema 2.24.0, R3). App/
|
|
390
|
+
// integration builds emit screen_feature rows carrying the bare `realises:`
|
|
391
|
+
// slug when the feature lives in another SDD (the platform). Rewrite those
|
|
392
|
+
// to the resolved platform feature id by matching the slug against every
|
|
393
|
+
// merged feature's slug. One slug vocabulary per capability (ADR 0177), so
|
|
394
|
+
// a single match is authoritative; an ambiguous estate-wide match is refused
|
|
395
|
+
// with a divergence finding (never guessed); no match is an orphan finding.
|
|
396
|
+
rowsByCatalogue.set("screen_feature", resolveCrossSddScreenFeature(rowsByCatalogue.get("screen_feature") ?? [], rowsByCatalogue.get("features") ?? [], findings));
|
|
397
|
+
// @rule aggregate-external-system-dedup
|
|
398
|
+
// 5b. Dedup c4_external_systems by id (ADR 0155). External systems are
|
|
399
|
+
// inherently shared references — the same external (e.g. ext-google-maps)
|
|
400
|
+
// may be declared by multiple SDDs. This is the one narrowly-scoped
|
|
401
|
+
// exception to the duplicate-PK rule: identical rows merge (first-seen
|
|
402
|
+
// wins); conflicting attributes on the same id surface as a finding.
|
|
403
|
+
dedupExternalSystems(rowsByCatalogue, findings);
|
|
404
|
+
// @rule aggregate-external-exemption-dedup
|
|
405
|
+
// 5b-i-bis. Dedup external_system_exemption by PK (M-ADR 0189 §4.6) —
|
|
406
|
+
// shared external entities may be exempted by multiple SDDs; identical
|
|
407
|
+
// assertions merge, conflicting ones keep first-seen with a finding.
|
|
408
|
+
dedupExternalExemptions(rowsByCatalogue, findings);
|
|
409
|
+
// 5b-ii. Dedup method-scoped ADRs by id. ADRs under docs/method/adr/ are the
|
|
410
|
+
// shared method baseline — byte-identical across every method-based SDD. When
|
|
411
|
+
// two or more full SDDs federate (e.g. a second platform-root joins the org
|
|
412
|
+
// tier alongside an existing one), each contributes the same method ADR ids. Merge
|
|
413
|
+
// them (first-seen wins) rather than failing the duplicate-PK check. Platform
|
|
414
|
+
// / domain ADRs are NOT deduped — a collision there is a genuine error.
|
|
415
|
+
await dedupMethodScopedAdrs(rowsByCatalogue, findings, sddRootsById);
|
|
416
|
+
// @rule aggregate-federated-reverse-metadata-enrichment
|
|
417
|
+
// 5c. Federated reverse-metadata enrichment (ADR 0163 P2). Both reference
|
|
418
|
+
// adopters centralise the service inventory in the platform-root, but for
|
|
419
|
+
// reverse-method federations the per-service evidence (service-architecture.md)
|
|
420
|
+
// lives one federation hop away, in member SDDs. The platform-root's own
|
|
421
|
+
// build can't see those docs, so those service rows carry empty reverse
|
|
422
|
+
// metadata. Here — the one place that can reach every member's repos/ —
|
|
423
|
+
// fill ONLY the empty reverse columns from each member's arch doc.
|
|
424
|
+
// Fill-if-empty: platform-root-local metadata always wins, order-independent
|
|
425
|
+
// across members. Cross-member slug ambiguity is refused, not guessed.
|
|
426
|
+
await enrichFederatedServiceMetadata(rowsByCatalogue, aggManifest.siblings, findings);
|
|
427
|
+
// @rule aggregate-successor-orphan
|
|
428
|
+
// 5d. Successor-reference orphan check (schema 2.22.0). products.successor_sdd_id
|
|
429
|
+
// is a soft reference in sdd_id vocabulary — resolve against the sdd_ids
|
|
430
|
+
// visible at this tier (direct contributors plus source_sdd_ids stamped at a
|
|
431
|
+
// lower tier, so org-tier re-aggregation sees through cohort roots). A miss
|
|
432
|
+
// is a finding, never a failure: the successor may live outside this
|
|
433
|
+
// aggregation's scope.
|
|
434
|
+
checkSuccessorReferences(rowsByCatalogue.get("products") ?? [], new Set(perSiblingCounts.keys()), findings);
|
|
435
|
+
// @rule aggregate-sibling-identity-duplicate
|
|
436
|
+
// 5e. Sibling-identity duplicate check (M-ADR 0189 §4.5.3): c4_systems
|
|
437
|
+
// rows sharing name+description — the syndicated-L1 overwrite class that
|
|
438
|
+
// PK/FK gates cannot see (the id is right, every other column is wrong).
|
|
439
|
+
checkDuplicateSystemIdentity(rowsByCatalogue.get("c4_systems") ?? [], findings);
|
|
440
|
+
// @rule aggregate-external-system-adjudication
|
|
441
|
+
// 5f. M-ADR 0189 §4.4 — adjudicate external-system candidates against the
|
|
442
|
+
// merged estate: internal-system resolutions convert to
|
|
443
|
+
// system_system_dependency; container resolutions are tier-contradiction
|
|
444
|
+
// findings; integration-kind resolutions are provider-pair collisions and
|
|
445
|
+
// stay external. Runs before the dup-PK/FK gates so the rewritten rows
|
|
446
|
+
// are what gets validated and emitted.
|
|
447
|
+
const adjudication = adjudicateExternalSystems(rowsByCatalogue, findings);
|
|
448
|
+
write(` adjudication (M-ADR 0189 §4.4): ${adjudication.converted} candidates → internal ` +
|
|
449
|
+
`(${adjudication.edgesRewritten} edges → system_system_dependency), ` +
|
|
450
|
+
`${adjudication.providerPairsKept} provider pairs kept external, ` +
|
|
451
|
+
`${adjudication.uncorroboratedKept} uncorroborated holds, ` +
|
|
452
|
+
`${adjudication.uncertainHolds} uncertain holds, ` +
|
|
453
|
+
`${adjudication.ambiguousHolds} ambiguous holds, ` +
|
|
454
|
+
`${adjudication.tierContradictions} tier contradictions asserted, ` +
|
|
455
|
+
`${adjudication.containerConverted} container conversions (${adjudication.containerEdgesRewritten} edges → container_dependency, ` +
|
|
456
|
+
`${adjudication.systemFallbackEdges} stuck edges → system_system_dependency at L1), ` +
|
|
457
|
+
`${adjudication.exempted} exempted, ${adjudication.unverified} unverified\n`);
|
|
458
|
+
// 5f-bis. Rows constructed at THIS tier after the node loader (adjudication
|
|
459
|
+
// conversions, re-derived edges) carry source_sdd_id but no platform stamp.
|
|
460
|
+
// Fill them now that every derivation has run (M-ADR 0208 §4.4 as applied to
|
|
461
|
+
// aggregator-constructed rows); whatever stays empty is reported, never keyed on.
|
|
462
|
+
fillDerivedPlatformStamps(schema, rowsByCatalogue, platformBySource, findings);
|
|
463
|
+
// @rule aggregate-platform-kind-uniqueness
|
|
464
|
+
// 5g. Platform-kind uniqueness (M-ADR 0133 §4.2c open hygiene).
|
|
465
|
+
checkPlatformKindUniqueness(rowsByCatalogue.get("c4_systems") ?? [], findings, rootIsOrganisation ? "organisation" : "platform");
|
|
466
|
+
// @rule aggregate-contract-id-rekey
|
|
467
|
+
// 5h. M-ADR 0203 §4.4 cross-SDD re-key: the contract-id migration derives
|
|
468
|
+
// per-SDD, but contract-FK edges cross SDD boundaries — a federated SDD
|
|
469
|
+
// that owns no contracts has an empty map, so its references keep the 2.x
|
|
470
|
+
// kindless ids (measured on the first rehearsal probe: 170 dangling FKs
|
|
471
|
+
// over 6 old ids). This tier sees every contract row, so the old form is
|
|
472
|
+
// decidable here and nowhere earlier.
|
|
473
|
+
const contractRekey = rekeyLegacyContractRefs(schema, rowsByCatalogue, findings);
|
|
474
|
+
// Always emitted: an absent line is indistinguishable from a check that
|
|
475
|
+
// never ran (M-ADR 0200 §4.3 — a zero must be affirmable). Measured: the
|
|
476
|
+
// first all-zero estate correctly refused to report "0" for a line that
|
|
477
|
+
// was never printed.
|
|
478
|
+
write(` contract-id cut window (M-ADR 0203 §4.4): ${contractRekey.rekeyed} cross-SDD reference(s) re-keyed, ` +
|
|
479
|
+
`${contractRekey.deduped} duplicate fact(s) dropped, ${contractRekey.ambiguous} ambiguous left dangling\n`);
|
|
480
|
+
// @rule aggregate-duplicate-primary-key
|
|
481
|
+
// 6. Duplicate primary key check. M-ADR 0208 §4.4 (F-D): at organisation
|
|
482
|
+
// tier the key of `domains` is (platform_sdd_id, id) — two platforms may
|
|
483
|
+
// each own `identity`. Everywhere else, and for every other catalogue,
|
|
484
|
+
// the scalar pk. An empty stamp is unresolved, never a key.
|
|
485
|
+
const platformScopedTargets = rootIsOrganisation ? PLATFORM_SCOPED_AT_ORGANISATION : [];
|
|
486
|
+
const duplicates = findDuplicatePrimaryKeys(schema, rowsByCatalogue, platformScopedTargets);
|
|
487
|
+
if (duplicates.length > 0) {
|
|
488
|
+
write(`\nDuplicate primary keys (${duplicates.length}) — aggregation fails:\n`);
|
|
489
|
+
for (const d of duplicates) {
|
|
490
|
+
write(` ${d.catalogue}.${d.primaryKey}="${d.value}" contributed by [${d.sources.join(", ")}]\n`);
|
|
491
|
+
}
|
|
492
|
+
return finalise({
|
|
493
|
+
schema,
|
|
494
|
+
ecosystemRoot: rootManifest.sddId,
|
|
495
|
+
aggManifest,
|
|
496
|
+
rowsByCatalogue,
|
|
497
|
+
perSiblingCounts,
|
|
498
|
+
aggregatedFkPass: false,
|
|
499
|
+
findings,
|
|
500
|
+
outDir: resolveOutDir(sddPath, inputs.outDir),
|
|
501
|
+
manifestPath,
|
|
502
|
+
writeOutputs: false,
|
|
503
|
+
duplicateCount: duplicates.length,
|
|
504
|
+
write,
|
|
505
|
+
strict: inputs.strict === true,
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
// 7. Full FK validation, treating external as internal.
|
|
509
|
+
const { fk: fkRaw } = await validateWithEngineToLog(schema, rowsByCatalogue, findings, { treatExternalAsInternal: true, platformScopedTargets },
|
|
510
|
+
// The aggregation tier has never emitted enum findings; the cutover
|
|
511
|
+
// changes no tier's finding surface (M-ADR 0205 §4.4).
|
|
512
|
+
{ logEnumFindings: false });
|
|
513
|
+
// @rule aggregate-tier-aware-external-deferral
|
|
514
|
+
// 7a. Tier-aware external resolution (M-ADR 0134 §4.3 as read under M-ADR
|
|
515
|
+
// 0208's organisation tier). 0134 wrote "the aggregator validates external
|
|
516
|
+
// FKs after merge" for a world with ONE aggregate. With an organisation tier
|
|
517
|
+
// above the platform-roots, a platform-root's OWN dependency on something it
|
|
518
|
+
// does not own — a row SOURCED BY THE ROOT ITSELF whose external FK dangles
|
|
519
|
+
// at its own platform-tier aggregate — is a cross-platform dependency by
|
|
520
|
+
// construction: no sibling at this tier can hold the target, and the tier
|
|
521
|
+
// that can is the organisation root. Such a row is deferred there as an
|
|
522
|
+
// `external` finding, not failed here. Rows sourced by MEMBERS still fail:
|
|
523
|
+
// their targets are the platform's own and are supposed to be present. At
|
|
524
|
+
// organisation tier nothing is deferred — there is no tier above.
|
|
525
|
+
// Measured shape: a packaged-SaaS platform-root depending on three of the
|
|
526
|
+
// reference platform's capabilities through two of its services — 4 dangling
|
|
527
|
+
// at its own aggregate, all present at the organisation root.
|
|
528
|
+
const fk = rootIsOrganisation
|
|
529
|
+
? fkRaw
|
|
530
|
+
: deferRootSourcedCrossPlatformExternals(schema, rowsByCatalogue, fkRaw, rootManifest.sddId, findings, write);
|
|
531
|
+
// 7b. M-ADR 0208 §4.4 (F-D): report the domain-key state at organisation
|
|
532
|
+
// tier. Always emitted there — an absent line is indistinguishable from a
|
|
533
|
+
// check that never ran (M-ADR 0200 §4.3).
|
|
534
|
+
if (rootIsOrganisation) {
|
|
535
|
+
checkPlatformScopedKeyAmbiguity(schema, rowsByCatalogue, platformScopedTargets, findings, write);
|
|
536
|
+
}
|
|
537
|
+
// 8. Emit aggregated catalogues + aggregation-manifest.json.
|
|
538
|
+
const outDir = resolveOutDir(sddPath, inputs.outDir);
|
|
539
|
+
const rowCounts = new Map();
|
|
540
|
+
for (const cat of schema.catalogues) {
|
|
541
|
+
const rows = rowsByCatalogue.get(cat.name) ?? [];
|
|
542
|
+
const result = await writeCatalogue({ outDir, catalogue: cat, rows });
|
|
543
|
+
rowCounts.set(cat.name, result.rowCount);
|
|
544
|
+
// A preserved file is data this build could not reproduce. Saying nothing
|
|
545
|
+
// would move the silence rather than close it: the previous behaviour was
|
|
546
|
+
// also silent, and that is why 26 SDDs lost their conformance evidence
|
|
547
|
+
// while their registries went on citing it.
|
|
548
|
+
if (result.preserved) {
|
|
549
|
+
findings.add("convention", `catalogues/${cat.name}.csv: this build produced NO rows for it, and the file on disk ` +
|
|
550
|
+
`holds ${result.preservedRows}. The existing file was LEFT UNTOUCHED rather than ` +
|
|
551
|
+
`replaced with a bare header. Either the catalogue has no producer in this CLI — in ` +
|
|
552
|
+
`which case its rows are authored data, not build output, and nothing here will ever ` +
|
|
553
|
+
`regenerate them — or a builder that used to emit rows has stopped. Both are worth ` +
|
|
554
|
+
`resolving; neither is worth deleting the rows over.`);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
await writeAggregationManifestJson({
|
|
558
|
+
outDir,
|
|
559
|
+
schemaVersion: schema.schemaVersion,
|
|
560
|
+
ecosystemRoot: rootManifest.sddId,
|
|
561
|
+
siblings: aggManifest.siblings,
|
|
562
|
+
perSiblingCounts,
|
|
563
|
+
aggregatedRowCounts: rowCounts,
|
|
564
|
+
cliVersion: CLI_VERSION,
|
|
565
|
+
});
|
|
566
|
+
// Report.
|
|
567
|
+
write("\nAggregated row counts:\n");
|
|
568
|
+
for (const [name, count] of rowCounts) {
|
|
569
|
+
if (count > 0)
|
|
570
|
+
write(` ${name}: ${count}\n`);
|
|
571
|
+
}
|
|
572
|
+
const emptyCount = [...rowCounts.values()].filter((c) => c === 0).length;
|
|
573
|
+
write(` (${emptyCount} headers-only)\n`);
|
|
574
|
+
const findingsText = findings.render();
|
|
575
|
+
if (findingsText)
|
|
576
|
+
write(`\n${findingsText}\n`);
|
|
577
|
+
if (fk.internalPass) {
|
|
578
|
+
write(`\nAggregated FK validation: PASS (${validatedPopulation(rowsByCatalogue)})\n`);
|
|
579
|
+
}
|
|
580
|
+
else {
|
|
581
|
+
write(`\nAggregated FK validation: FAIL (${fk.internalFailures.length} dangling)\n`);
|
|
582
|
+
for (const f of fk.internalFailures) {
|
|
583
|
+
const sourceSdd = findSourceSdd(rowsByCatalogue, f.catalogue, f.referencingRow);
|
|
584
|
+
write(` ${f.catalogue}.${f.column}: row pk=${f.referencingRow} → ${f.target}=${f.value || "<empty>"}` +
|
|
585
|
+
(sourceSdd ? ` (from ${sourceSdd})\n` : "\n"));
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
const strictFail = inputs.strict === true && findings.count() > 0;
|
|
589
|
+
const exitCode = !fk.internalPass || strictFail ? 1 : 0;
|
|
590
|
+
return {
|
|
591
|
+
exitCode,
|
|
592
|
+
schemaVersion: schema.schemaVersion,
|
|
593
|
+
ecosystemRoot: rootManifest.sddId,
|
|
594
|
+
siblingCount: aggManifest.siblings.length,
|
|
595
|
+
rowCounts,
|
|
596
|
+
perSiblingCounts,
|
|
597
|
+
aggregatedFkPass: fk.internalPass,
|
|
598
|
+
findingsCount: findings.count(),
|
|
599
|
+
outDir,
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
// ADR 0155: c4_external_systems rows dedup by id across SDDs rather than
|
|
603
|
+
// failing the duplicate-PK check. First-seen row wins; a `source_sdd_id`
|
|
604
|
+
// already stamped is preserved. Conflicting name/technology/description on
|
|
605
|
+
// the same id emits a `convention` finding so authors can reconcile.
|
|
606
|
+
function dedupExternalExemptions(rowsByCatalogue, findings) {
|
|
607
|
+
const rows = rowsByCatalogue.get("external_system_exemption");
|
|
608
|
+
if (rows === undefined || rows.length === 0)
|
|
609
|
+
return;
|
|
610
|
+
const byId = new Map();
|
|
611
|
+
for (const row of rows) {
|
|
612
|
+
const id = String(row["id"] ?? "");
|
|
613
|
+
if (id === "")
|
|
614
|
+
continue;
|
|
615
|
+
const existing = byId.get(id);
|
|
616
|
+
if (existing === undefined) {
|
|
617
|
+
byId.set(id, row);
|
|
618
|
+
continue;
|
|
619
|
+
}
|
|
620
|
+
if (String(existing["reason"]) !== String(row["reason"]) ||
|
|
621
|
+
String(existing["note"]) !== String(row["note"])) {
|
|
622
|
+
findings.add("convention", `external_system_exemption: "${id}" asserted with conflicting reason/note across SDDs — first-seen kept`);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
rowsByCatalogue.set("external_system_exemption", [...byId.values()].sort((a, b) => String(a["id"]).localeCompare(String(b["id"]))));
|
|
626
|
+
}
|
|
627
|
+
// M-ADR 0198 §4.2/§4.3: append a re-derived catalogue's rows to whatever concatenation
|
|
628
|
+
// already produced for it, keeping the concatenated row on any primary-key collision. One
|
|
629
|
+
// shared mechanism for every re-derived catalogue, so adding a fifth is a call to this
|
|
630
|
+
// function with that catalogue's builder output, not new merge logic.
|
|
631
|
+
function rederiveAppend(rowsByCatalogue, catalogueName, rederivedRows) {
|
|
632
|
+
const concatenated = rowsByCatalogue.get(catalogueName) ?? [];
|
|
633
|
+
const existingIds = new Set(concatenated.map((r) => String(r["id"])));
|
|
634
|
+
const added = rederivedRows.filter((r) => !existingIds.has(String(r["id"])));
|
|
635
|
+
rowsByCatalogue.set(catalogueName, [...concatenated, ...added]);
|
|
636
|
+
}
|
|
637
|
+
export function dedupExternalSystems(rowsByCatalogue, findings) {
|
|
638
|
+
const rows = rowsByCatalogue.get("c4_external_systems");
|
|
639
|
+
if (rows === undefined || rows.length === 0)
|
|
640
|
+
return;
|
|
641
|
+
const byId = new Map();
|
|
642
|
+
const namesById = new Map();
|
|
643
|
+
for (const row of rows) {
|
|
644
|
+
const id = String(row["id"] ?? "");
|
|
645
|
+
if (id === "")
|
|
646
|
+
continue;
|
|
647
|
+
const name = String(row["name"] ?? "");
|
|
648
|
+
const names = namesById.get(id);
|
|
649
|
+
if (names) {
|
|
650
|
+
if (name !== "" && !names.includes(name))
|
|
651
|
+
names.push(name);
|
|
652
|
+
}
|
|
653
|
+
else {
|
|
654
|
+
namesById.set(id, name === "" ? [] : [name]);
|
|
655
|
+
}
|
|
656
|
+
const existing = byId.get(id);
|
|
657
|
+
if (existing === undefined) {
|
|
658
|
+
byId.set(id, row);
|
|
659
|
+
continue;
|
|
660
|
+
}
|
|
661
|
+
for (const attr of ["technology", "description"]) {
|
|
662
|
+
const a = String(existing[attr] ?? "");
|
|
663
|
+
const b = String(row[attr] ?? "");
|
|
664
|
+
if (a !== "" && b !== "" && a !== b) {
|
|
665
|
+
findings.add("convention", `c4_external_systems: id "${id}" has conflicting ${attr} across SDDs [${String(existing["source_sdd_id"] ?? "?")}, ${String(row["source_sdd_id"] ?? "?")}] — first-seen kept`);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
// M-ADR 0203 §4.2's second fallout detector — the mirror image of the
|
|
670
|
+
// same-name split. With identity now the DECLARED entry id, a generic
|
|
671
|
+
// diagram-local mnemonic shared across SDDs merges DIFFERENT real-world
|
|
672
|
+
// systems into one row (measured before this shipped: 17 integration
|
|
673
|
+
// SDDs each declaring their provider under one shared mnemonic — 17
|
|
674
|
+
// providers, one surviving row). A merge whose display names corroborate
|
|
675
|
+
// (compact-equal or token-subset — benign label variance on one
|
|
676
|
+
// referent) stays the quiet dedup this catalogue is designed for; a
|
|
677
|
+
// merge whose names DON'T corroborate is likely N referents under one
|
|
678
|
+
// entry id, and every hidden referent is named. Rename the diagram entry
|
|
679
|
+
// ids apart — a diagram edit, the M-ADR 0189 §4.2 sanctioned opt-out.
|
|
680
|
+
const compactName = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
681
|
+
const nameTokens = (s) => new Set(s
|
|
682
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
683
|
+
.toLowerCase()
|
|
684
|
+
.split(/[^a-z0-9]+/)
|
|
685
|
+
.filter((t) => t !== ""));
|
|
686
|
+
const namesCorroborate = (a, b) => {
|
|
687
|
+
if (compactName(a) === compactName(b))
|
|
688
|
+
return true;
|
|
689
|
+
const ta = nameTokens(a);
|
|
690
|
+
const tb = nameTokens(b);
|
|
691
|
+
const subset = (x, y) => x.size > 0 && [...x].every((t) => y.has(t));
|
|
692
|
+
return subset(ta, tb) || subset(tb, ta);
|
|
693
|
+
};
|
|
694
|
+
for (const [id, names] of namesById) {
|
|
695
|
+
if (names.length < 2)
|
|
696
|
+
continue;
|
|
697
|
+
const anchor = names[0];
|
|
698
|
+
const strangers = names.slice(1).filter((n) => !namesCorroborate(anchor, n));
|
|
699
|
+
if (strangers.length === 0) {
|
|
700
|
+
// Corroborating label variance — keep the plain per-attr conflict
|
|
701
|
+
// shape the pre-3.0.0 dedup reported.
|
|
702
|
+
findings.add("convention", `c4_external_systems: id "${id}" has conflicting name across SDDs [${names.join(", ")}] — first-seen kept`);
|
|
703
|
+
continue;
|
|
704
|
+
}
|
|
705
|
+
findings.add("divergence", `c4_external_systems: declared id "${id}" merges ${names.length} rows whose display names do not corroborate ` +
|
|
706
|
+
`[${names.join(" | ")}] — likely DIFFERENT systems under one shared diagram entry id; ` +
|
|
707
|
+
`first-seen kept and ${names.length - 1} referent(s) are hidden by this merge. ` +
|
|
708
|
+
`Rename the diagram entry ids apart (M-ADR 0203 §4.2 / M-ADR 0189 §4.2)`);
|
|
709
|
+
}
|
|
710
|
+
rowsByCatalogue.set("c4_external_systems", [...byId.values()]);
|
|
711
|
+
}
|
|
712
|
+
// Method-DISTRIBUTED ADRs are the shared method baseline, identical across
|
|
713
|
+
// method-based SDDs. Dedupe by id (first-seen wins), preserving order and leaving
|
|
714
|
+
// adopter-AUTHORED platform/domain ADRs untouched — a collision there is a genuine
|
|
715
|
+
// error and MUST still fail the duplicate-PK check. Mirrors dedupExternalSystems.
|
|
716
|
+
//
|
|
717
|
+
// WHAT COUNTS AS METHOD-DISTRIBUTED (M-ADR 0208 / gov F-E): a row whose doc_path is
|
|
718
|
+
// BUNDLE-MANAGED in the contributing SDD's method-baseline.lock. Provenance, not
|
|
719
|
+
// path prefix. The earlier test `startsWith("docs/method/adr/")` was a proxy for
|
|
720
|
+
// the same property and it failed the first time two platform-roots aggregated:
|
|
721
|
+
// the baseline also distributes docs/method/platform-adr/ (13 ADR-emitting files
|
|
722
|
+
// in platform-baseline v2.3.28), which the prefix missed, so identical
|
|
723
|
+
// bundle-managed rows from two roots collided as if adopter-authored. A prefix
|
|
724
|
+
// extension would have passed every test available that day and merged genuinely
|
|
725
|
+
// authored platform ADRs the next time an adopter wrote a colliding one; the lock
|
|
726
|
+
// already records exactly the property we mean.
|
|
727
|
+
//
|
|
728
|
+
// FALLBACK: a contributing SDD with no lock (fixtures; a repo that has never been
|
|
729
|
+
// synced) keeps the historical prefix test, so it is never LESS deduped than before.
|
|
730
|
+
// @rule aggregate-method-adr-dedup
|
|
731
|
+
async function dedupMethodScopedAdrs(rowsByCatalogue, findings, sddRootsById) {
|
|
732
|
+
const rows = rowsByCatalogue.get("adrs");
|
|
733
|
+
if (rows === undefined || rows.length === 0)
|
|
734
|
+
return;
|
|
735
|
+
// source_sdd_id -> set of bundle-managed paths, or null when that SDD has no lock.
|
|
736
|
+
const managedBySdd = new Map();
|
|
737
|
+
for (const [sddId, root] of sddRootsById) {
|
|
738
|
+
const lock = await readLockFile(root);
|
|
739
|
+
if (lock === null) {
|
|
740
|
+
managedBySdd.set(sddId, null);
|
|
741
|
+
continue;
|
|
742
|
+
}
|
|
743
|
+
const managed = new Set();
|
|
744
|
+
for (const b of lock.bundles)
|
|
745
|
+
for (const mp of b.managedPaths)
|
|
746
|
+
managed.add(mp);
|
|
747
|
+
managedBySdd.set(sddId, managed);
|
|
748
|
+
}
|
|
749
|
+
const isMethodDistributed = (row) => {
|
|
750
|
+
const docPath = String(row["doc_path"] ?? "");
|
|
751
|
+
if (docPath === "")
|
|
752
|
+
return false;
|
|
753
|
+
const managed = managedBySdd.get(String(row["source_sdd_id"] ?? ""));
|
|
754
|
+
if (managed === undefined || managed === null) {
|
|
755
|
+
// No lock for this contributor: historical prefix test.
|
|
756
|
+
return docPath.startsWith("docs/method/adr/");
|
|
757
|
+
}
|
|
758
|
+
return managed.has(docPath);
|
|
759
|
+
};
|
|
760
|
+
const seen = new Map();
|
|
761
|
+
const out = [];
|
|
762
|
+
for (const row of rows) {
|
|
763
|
+
const id = String(row["id"] ?? "");
|
|
764
|
+
if (!isMethodDistributed(row) || id === "") {
|
|
765
|
+
out.push(row);
|
|
766
|
+
continue;
|
|
767
|
+
}
|
|
768
|
+
const existing = seen.get(id);
|
|
769
|
+
if (existing === undefined) {
|
|
770
|
+
seen.set(id, row);
|
|
771
|
+
out.push(row);
|
|
772
|
+
continue;
|
|
773
|
+
}
|
|
774
|
+
for (const attr of ["title", "status"]) {
|
|
775
|
+
const a = String(existing[attr] ?? "");
|
|
776
|
+
const b = String(row[attr] ?? "");
|
|
777
|
+
if (a !== "" && b !== "" && a !== b) {
|
|
778
|
+
findings.add("convention", `adrs: method-distributed id "${id}" has conflicting ${attr} across SDDs [${String(existing["source_sdd_id"] ?? "?")}, ${String(row["source_sdd_id"] ?? "?")}] — first-seen kept`);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
// duplicate method-distributed ADR — already kept first-seen; drop this copy.
|
|
782
|
+
}
|
|
783
|
+
rowsByCatalogue.set("adrs", out);
|
|
784
|
+
}
|
|
785
|
+
// ADR 0163 P2: the reverse-derived metadata columns that a member SDD's
|
|
786
|
+
// service-architecture.md can backfill onto a platform-root service row.
|
|
787
|
+
const FEDERATED_META_COLS = [
|
|
788
|
+
"runtime_type",
|
|
789
|
+
"language",
|
|
790
|
+
"status",
|
|
791
|
+
"confidence",
|
|
792
|
+
"description",
|
|
793
|
+
];
|
|
794
|
+
/**
|
|
795
|
+
* Backfill empty reverse-metadata columns on the aggregated `services`
|
|
796
|
+
* rows from member SDDs' `service-architecture.md` docs.
|
|
797
|
+
*
|
|
798
|
+
* Mechanism (Option 1 per the coordination analysis — no schema change):
|
|
799
|
+
* the aggregator already knows each sibling's repo root (`sib.sddRoot`),
|
|
800
|
+
* so it indexes `<sddRoot>/repos/**/docs/service-architecture.md` per
|
|
801
|
+
* member and matches by repo slug == service id. Only cells that are empty
|
|
802
|
+
* after the per-SDD builds are filled (platform-root-local metadata is never
|
|
803
|
+
* overwritten), so the pass is idempotent and order-independent.
|
|
804
|
+
*
|
|
805
|
+
* Safety: a slug whose arch doc exists in more than one member is
|
|
806
|
+
* **ambiguous** — we cannot know which doc describes the single inventory
|
|
807
|
+
* row — so it is skipped with a finding rather than guessed.
|
|
808
|
+
*/
|
|
809
|
+
async function enrichFederatedServiceMetadata(rowsByCatalogue, siblings, findings) {
|
|
810
|
+
const services = rowsByCatalogue.get("services");
|
|
811
|
+
if (services === undefined || services.length === 0)
|
|
812
|
+
return;
|
|
813
|
+
const hasEmptyCell = (row) => FEDERATED_META_COLS.some((c) => String(row[c] ?? "") === "");
|
|
814
|
+
// Service ids that still want metadata after the per-SDD builds.
|
|
815
|
+
const needed = new Set();
|
|
816
|
+
for (const row of services) {
|
|
817
|
+
if (!hasEmptyCell(row))
|
|
818
|
+
continue;
|
|
819
|
+
const id = String(row["id"] ?? "");
|
|
820
|
+
if (id !== "")
|
|
821
|
+
needed.add(id);
|
|
822
|
+
}
|
|
823
|
+
if (needed.size === 0)
|
|
824
|
+
return;
|
|
825
|
+
// Index each member's arch docs, but keep only slugs we actually need.
|
|
826
|
+
// A slug → the members whose repos/ carry an arch doc for it (collision
|
|
827
|
+
// detection lives in the >1 case below).
|
|
828
|
+
const bySlug = new Map();
|
|
829
|
+
for (const sib of siblings) {
|
|
830
|
+
let index;
|
|
831
|
+
try {
|
|
832
|
+
index = await indexServiceArchitectures(sib.sddRoot);
|
|
833
|
+
}
|
|
834
|
+
catch {
|
|
835
|
+
continue; // member without a repos/ tree contributes nothing
|
|
836
|
+
}
|
|
837
|
+
for (const [slug, archPath] of index) {
|
|
838
|
+
if (!needed.has(slug))
|
|
839
|
+
continue;
|
|
840
|
+
const list = bySlug.get(slug) ?? [];
|
|
841
|
+
list.push({ sddId: sib.sddId, archPath });
|
|
842
|
+
bySlug.set(slug, list);
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
if (bySlug.size === 0)
|
|
846
|
+
return;
|
|
847
|
+
const metaCache = new Map();
|
|
848
|
+
const enrichingMembers = new Set();
|
|
849
|
+
let rowsEnriched = 0;
|
|
850
|
+
let cellsFilled = 0;
|
|
851
|
+
const enriched = [];
|
|
852
|
+
for (const row of services) {
|
|
853
|
+
const id = String(row["id"] ?? "");
|
|
854
|
+
const members = id !== "" ? bySlug.get(id) : undefined;
|
|
855
|
+
if (members === undefined || !hasEmptyCell(row)) {
|
|
856
|
+
enriched.push(row);
|
|
857
|
+
continue;
|
|
858
|
+
}
|
|
859
|
+
if (members.length > 1) {
|
|
860
|
+
findings.add("convention", `services: federated metadata for "${id}" is ambiguous — ` +
|
|
861
|
+
`service-architecture.md exists in ${members.length} member SDDs ` +
|
|
862
|
+
`[${members.map((m) => m.sddId).join(", ")}]; enrichment skipped`);
|
|
863
|
+
enriched.push(row);
|
|
864
|
+
continue;
|
|
865
|
+
}
|
|
866
|
+
const { sddId, archPath } = members[0];
|
|
867
|
+
let meta = metaCache.get(archPath);
|
|
868
|
+
if (meta === undefined) {
|
|
869
|
+
meta = await readServiceMetadata(archPath);
|
|
870
|
+
metaCache.set(archPath, meta);
|
|
871
|
+
}
|
|
872
|
+
const next = {
|
|
873
|
+
...row,
|
|
874
|
+
};
|
|
875
|
+
let filledThisRow = false;
|
|
876
|
+
for (const col of FEDERATED_META_COLS) {
|
|
877
|
+
if (String(row[col] ?? "") === "" && meta[col] !== "") {
|
|
878
|
+
next[col] = meta[col];
|
|
879
|
+
cellsFilled++;
|
|
880
|
+
filledThisRow = true;
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
if (filledThisRow) {
|
|
884
|
+
rowsEnriched++;
|
|
885
|
+
enrichingMembers.add(sddId);
|
|
886
|
+
}
|
|
887
|
+
enriched.push(filledThisRow ? next : row);
|
|
888
|
+
}
|
|
889
|
+
if (rowsEnriched > 0) {
|
|
890
|
+
const stillEmpty = enriched.filter(hasEmptyCell).length;
|
|
891
|
+
findings.add("coverage", `services: federated reverse-metadata enrichment filled ${cellsFilled} ` +
|
|
892
|
+
`empty cell(s) across ${rowsEnriched} service row(s) from ` +
|
|
893
|
+
`${enrichingMembers.size} member SDD(s); ${stillEmpty} row(s) still ` +
|
|
894
|
+
`carry empty reverse metadata`);
|
|
895
|
+
rowsByCatalogue.set("services", enriched);
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
/**
|
|
899
|
+
* M-ADR 0208 §4.4: the catalogues whose organisation-tier key is
|
|
900
|
+
* `(platform_sdd_id, <pk>)`. Only `domains` — a domain is a bounded context
|
|
901
|
+
* OF a platform, so the pair is its natural key; every other catalogue's ids
|
|
902
|
+
* are already platform-qualified by construction or are method-scoped.
|
|
903
|
+
*/
|
|
904
|
+
import { PLATFORM_SCOPED_AT_ORGANISATION } from "./platform-scope.js";
|
|
905
|
+
import { deriveFeatureIntegrationSupport } from "./data-item-coverage.js";
|
|
906
|
+
export { PLATFORM_SCOPED_AT_ORGANISATION };
|
|
907
|
+
export function findDuplicatePrimaryKeys(schema, rowsByCatalogue, platformScopedTargets = []) {
|
|
908
|
+
const out = [];
|
|
909
|
+
for (const cat of schema.catalogues) {
|
|
910
|
+
const rows = rowsByCatalogue.get(cat.name) ?? [];
|
|
911
|
+
// Scoped iff asked for AND the catalogue carries the stamp column.
|
|
912
|
+
const scoped = isPlatformScopedFk(cat, cat, { platformScopedTargets });
|
|
913
|
+
const byKey = new Map();
|
|
914
|
+
const stampsByKey = new Map();
|
|
915
|
+
for (const row of rows) {
|
|
916
|
+
const pk = row[cat.primaryKey];
|
|
917
|
+
if (pk === undefined || pk === null || pk === "")
|
|
918
|
+
continue;
|
|
919
|
+
const value = String(pk);
|
|
920
|
+
const sourceSdd = String(row["source_sdd_id"] ?? "");
|
|
921
|
+
const list = byKey.get(value) ?? [];
|
|
922
|
+
list.push(sourceSdd || "<unknown>");
|
|
923
|
+
byKey.set(value, list);
|
|
924
|
+
if (scoped) {
|
|
925
|
+
const stamps = stampsByKey.get(value) ?? [];
|
|
926
|
+
stamps.push(String(row[PLATFORM_STAMP_COLUMN] ?? "").trim());
|
|
927
|
+
stampsByKey.set(value, stamps);
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
for (const [value, sources] of byKey) {
|
|
931
|
+
if (sources.length > 1 && scoped) {
|
|
932
|
+
// n rows are distinct under (stamp, pk) iff every stamp is present
|
|
933
|
+
// and all differ. An EMPTY stamp is unresolved, never a key: it
|
|
934
|
+
// collides with everything (§4.4 — "two children contributing
|
|
935
|
+
// (∅, identity) must produce a finding, not a silent merge").
|
|
936
|
+
// Mirrors ddl-generator pkQuery's HAVING clause.
|
|
937
|
+
const stamps = stampsByKey.get(value) ?? [];
|
|
938
|
+
const distinctNonEmpty = new Set(stamps.filter((st) => st !== "")).size;
|
|
939
|
+
if (distinctNonEmpty === stamps.length)
|
|
940
|
+
continue;
|
|
941
|
+
out.push({
|
|
942
|
+
catalogue: cat.name,
|
|
943
|
+
primaryKey: `(${PLATFORM_STAMP_COLUMN}, ${cat.primaryKey})`,
|
|
944
|
+
value: `${value} [stamps: ${stamps.map((st) => st || "∅").join(", ")}] — an empty stamp is unresolved, never a key (M-ADR 0208 §4.4)`,
|
|
945
|
+
sources,
|
|
946
|
+
});
|
|
947
|
+
continue;
|
|
948
|
+
}
|
|
949
|
+
if (sources.length > 1) {
|
|
950
|
+
out.push({
|
|
951
|
+
catalogue: cat.name,
|
|
952
|
+
primaryKey: cat.primaryKey,
|
|
953
|
+
value,
|
|
954
|
+
sources,
|
|
955
|
+
});
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
return out;
|
|
960
|
+
}
|
|
961
|
+
/**
|
|
962
|
+
* M-ADR 0208 §4.4 (F-D), organisation tier. For each platform-scoped
|
|
963
|
+
* catalogue: which pks are owned by more than one platform, and how many
|
|
964
|
+
* referencing rows carry such a pk with NO stamp of their own — those
|
|
965
|
+
* references resolve by scalar fallback (the FK check cannot fail them
|
|
966
|
+
* without inventing a platform) but they are AMBIGUOUS, and this says so.
|
|
967
|
+
* One line per scoped catalogue, always written; a coverage finding only
|
|
968
|
+
* when ambiguity exists. Fires zero on an estate where no id is shared.
|
|
969
|
+
*/
|
|
970
|
+
export function checkPlatformScopedKeyAmbiguity(schema, rowsByCatalogue, platformScopedTargets, findings, write) {
|
|
971
|
+
for (const target of schema.catalogues) {
|
|
972
|
+
if (!isPlatformScopedFk(target, target, { platformScopedTargets }))
|
|
973
|
+
continue;
|
|
974
|
+
const owners = new Map();
|
|
975
|
+
for (const row of rowsByCatalogue.get(target.name) ?? []) {
|
|
976
|
+
const pk = String(row[target.primaryKey] ?? "");
|
|
977
|
+
if (pk === "")
|
|
978
|
+
continue;
|
|
979
|
+
const set = owners.get(pk) ?? new Set();
|
|
980
|
+
set.add(String(row[PLATFORM_STAMP_COLUMN] ?? "").trim() || "∅");
|
|
981
|
+
owners.set(pk, set);
|
|
982
|
+
}
|
|
983
|
+
const shared = new Set([...owners].filter(([, o]) => o.size > 1).map(([pk]) => pk));
|
|
984
|
+
let ambiguous = 0;
|
|
985
|
+
const where = [];
|
|
986
|
+
for (const cat of schema.catalogues) {
|
|
987
|
+
for (const fk of cat.foreignKeys) {
|
|
988
|
+
if (fk.references.split(".")[0] !== target.name)
|
|
989
|
+
continue;
|
|
990
|
+
if (!isPlatformScopedFk(cat, target, { platformScopedTargets }))
|
|
991
|
+
continue;
|
|
992
|
+
let n = 0;
|
|
993
|
+
for (const row of rowsByCatalogue.get(cat.name) ?? []) {
|
|
994
|
+
const v = String(row[fk.column] ?? "");
|
|
995
|
+
if (v === "" || !shared.has(v))
|
|
996
|
+
continue;
|
|
997
|
+
if (String(row[PLATFORM_STAMP_COLUMN] ?? "").trim() === "")
|
|
998
|
+
n++;
|
|
999
|
+
}
|
|
1000
|
+
if (n > 0) {
|
|
1001
|
+
ambiguous += n;
|
|
1002
|
+
where.push(`${cat.name}.${fk.column}=${n}`);
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
write(` ${target.name} key at organisation tier is (${PLATFORM_STAMP_COLUMN}, ${target.primaryKey}) (M-ADR 0208 §4.4): ` +
|
|
1007
|
+
`${shared.size} id(s) owned by more than one platform, ${ambiguous} unstamped referencing row(s) ambiguous\n`);
|
|
1008
|
+
if (ambiguous > 0) {
|
|
1009
|
+
findings.add("coverage", `${target.name}: ${ambiguous} referencing row(s) with EMPTY ${PLATFORM_STAMP_COLUMN} name an id owned by more than one platform [${[...shared].sort().join(", ")}] (${where.join(", ")}) — resolved by scalar fallback, AMBIGUOUS until the row is stamped (M-ADR 0208 §4.4)`);
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
async function mergeCatalogues(schema, rowsByCatalogue, cataloguesDir, sourceSddId,
|
|
1014
|
+
/** M-ADR 0208 §4.4: value to fill into `platform_sdd_id` when empty; null = do not fill. */
|
|
1015
|
+
platformSddId) {
|
|
1016
|
+
const counts = new Map();
|
|
1017
|
+
let dirExists = true;
|
|
1018
|
+
try {
|
|
1019
|
+
const s = await stat(cataloguesDir);
|
|
1020
|
+
if (!s.isDirectory())
|
|
1021
|
+
dirExists = false;
|
|
1022
|
+
}
|
|
1023
|
+
catch (err) {
|
|
1024
|
+
if (err.code === "ENOENT")
|
|
1025
|
+
dirExists = false;
|
|
1026
|
+
else
|
|
1027
|
+
throw err;
|
|
1028
|
+
}
|
|
1029
|
+
if (!dirExists) {
|
|
1030
|
+
throw new Error(`catalogues/ directory not found at ${cataloguesDir}\n` +
|
|
1031
|
+
` Run \`sdd-cli catalogue build\` in that SDD before aggregating.`);
|
|
1032
|
+
}
|
|
1033
|
+
for (const cat of schema.catalogues) {
|
|
1034
|
+
const filePath = join(cataloguesDir, `${cat.name}.csv`);
|
|
1035
|
+
let parsed;
|
|
1036
|
+
try {
|
|
1037
|
+
parsed = await readCatalogueCsv(filePath);
|
|
1038
|
+
}
|
|
1039
|
+
catch (err) {
|
|
1040
|
+
if (err.code === "ENOENT") {
|
|
1041
|
+
counts.set(cat.name, 0);
|
|
1042
|
+
continue;
|
|
1043
|
+
}
|
|
1044
|
+
throw err;
|
|
1045
|
+
}
|
|
1046
|
+
const stamped = stampColumn(cat, stampColumn(cat, parsed.rows, "source_sdd_id", sourceSddId), "platform_sdd_id", platformSddId);
|
|
1047
|
+
rowsByCatalogue.get(cat.name).push(...stamped);
|
|
1048
|
+
counts.set(cat.name, stamped.length);
|
|
1049
|
+
}
|
|
1050
|
+
return counts;
|
|
1051
|
+
}
|
|
1052
|
+
/**
|
|
1053
|
+
* Stamp `column` = `value` on every row that does not already carry one
|
|
1054
|
+
* (preserve-if-set — chained aggregation, M-ADR 0134 §4.5 step 4). Used for
|
|
1055
|
+
* `source_sdd_id` and, per M-ADR 0208 §4.4, for `platform_sdd_id`. A null value
|
|
1056
|
+
* means "do not fill" (organisation tier, where the stamp arrived from below or
|
|
1057
|
+
* is legitimately unresolved). No-op when the catalogue lacks the column.
|
|
1058
|
+
*/
|
|
1059
|
+
function stampColumn(catalogue, rows, column, value) {
|
|
1060
|
+
const hasColumn = catalogue.columns.some((c) => c.name === column);
|
|
1061
|
+
if (!hasColumn || value === null)
|
|
1062
|
+
return rows.map((r) => ({ ...r }));
|
|
1063
|
+
return rows.map((r) => {
|
|
1064
|
+
if (String(r[column] ?? "").trim() !== "")
|
|
1065
|
+
return { ...r };
|
|
1066
|
+
return { ...r, [column]: value };
|
|
1067
|
+
});
|
|
1068
|
+
}
|
|
1069
|
+
async function crossCheckSiblings(ecosystemRootPath, siblings, aggregatorSchema, write) {
|
|
1070
|
+
const findings = new FindingsLog();
|
|
1071
|
+
const siblingProfiles = new Map();
|
|
1072
|
+
const siblingCategories = new Map();
|
|
1073
|
+
const siblingDeclaredPlatform = new Map();
|
|
1074
|
+
const deprecatedPlatformField = new Set();
|
|
1075
|
+
for (const sib of siblings) {
|
|
1076
|
+
let sibProfile;
|
|
1077
|
+
if (sib.level === "organisation") {
|
|
1078
|
+
// Organisation-level sibling: pre-built catalogues-aggregated/, no manifest.
|
|
1079
|
+
sibProfile = "organisation";
|
|
1080
|
+
}
|
|
1081
|
+
else {
|
|
1082
|
+
// Platform / app / integration: require manifest and cross-check sdd_id.
|
|
1083
|
+
let sibManifest;
|
|
1084
|
+
try {
|
|
1085
|
+
sibManifest = await loadSddManifest(sib.sddRoot, {
|
|
1086
|
+
cliVersion: CLI_VERSION,
|
|
1087
|
+
});
|
|
1088
|
+
}
|
|
1089
|
+
catch (err) {
|
|
1090
|
+
throw new Error(`aggregator: sibling "${sib.sddId}" at ${relative(ecosystemRootPath, sib.sddRoot)}\n` +
|
|
1091
|
+
` failed to load .sdd/sdd-manifest.yaml: ${err.message}`);
|
|
1092
|
+
}
|
|
1093
|
+
if (sibManifest.sddId !== sib.sddId) {
|
|
1094
|
+
throw new Error(`aggregator: sibling "${sib.sddId}" in aggregation manifest\n` +
|
|
1095
|
+
` but its own .sdd/sdd-manifest.yaml declares sdd_id="${sibManifest.sddId}"\n` +
|
|
1096
|
+
` (rule: sibling_id_matches_sibling_manifest)`);
|
|
1097
|
+
}
|
|
1098
|
+
sibProfile = sibManifest.profile;
|
|
1099
|
+
siblingCategories.set(sib.sddId, sibManifest.category);
|
|
1100
|
+
if (sibManifest.platformSddId)
|
|
1101
|
+
siblingDeclaredPlatform.set(sib.sddId, sibManifest.platformSddId);
|
|
1102
|
+
if (sibManifest.platformFieldDeprecated)
|
|
1103
|
+
deprecatedPlatformField.add(sib.sddId);
|
|
1104
|
+
}
|
|
1105
|
+
siblingProfiles.set(sib.sddId, sibProfile);
|
|
1106
|
+
// Cross-check sibling's vendored catalogue-schema.yaml against the
|
|
1107
|
+
// declared compat range. The aggregator's own canonical schema is the
|
|
1108
|
+
// source of truth; the sibling's vendored copy is informational and
|
|
1109
|
+
// only fails the cross-check if it falls outside SCHEMA_COMPAT (i.e.
|
|
1110
|
+
// a major-version mismatch). Minor-version drift inside `^X.Y.Z` is
|
|
1111
|
+
// tolerated — sibling at 2.1.0 against aggregator at 2.2.0 is fine
|
|
1112
|
+
// because both satisfy ^2.0.0.
|
|
1113
|
+
const sibSchemaPath = await findVendoredSchema(sib.sddRoot);
|
|
1114
|
+
if (sibSchemaPath !== "") {
|
|
1115
|
+
const text = await readFile(sibSchemaPath, "utf-8");
|
|
1116
|
+
const m = /^schema_version:\s*"?([0-9.]+)"?/m.exec(text);
|
|
1117
|
+
const sibVersion = m?.[1] ?? "";
|
|
1118
|
+
if (sibVersion !== "" && !isInRange(sibVersion, SCHEMA_COMPAT.catalogue_schema)) {
|
|
1119
|
+
throw new Error(`aggregator: sibling "${sib.sddId}" vendored catalogue-schema.yaml\n` +
|
|
1120
|
+
` is at ${sibVersion}, outside the declared compat range\n` +
|
|
1121
|
+
` ${SCHEMA_COMPAT.catalogue_schema} (aggregator at ${aggregatorSchema.schemaVersion}).\n` +
|
|
1122
|
+
` Major-version mismatch — upgrade sdd-cli or sync the sibling\n` +
|
|
1123
|
+
` against a matching forward-baseline (rule: schema_version_alignment).`);
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
write(` ✓ ${sib.sddId} (${sibProfile})\n`);
|
|
1127
|
+
}
|
|
1128
|
+
return { findings, siblingProfiles, siblingCategories, siblingDeclaredPlatform, deprecatedPlatformField };
|
|
1129
|
+
}
|
|
1130
|
+
async function findVendoredSchema(sddRoot) {
|
|
1131
|
+
// Adopters typically vendor canonical schema indirectly via sdd-cli.
|
|
1132
|
+
// Some SDDs pin a copy at docs/method/methods/catalogue-schema.yaml.
|
|
1133
|
+
for (const candidate of [
|
|
1134
|
+
"docs/method/methods/catalogue-schema.yaml",
|
|
1135
|
+
]) {
|
|
1136
|
+
const path = join(sddRoot, candidate);
|
|
1137
|
+
try {
|
|
1138
|
+
const s = await stat(path);
|
|
1139
|
+
if (s.isFile())
|
|
1140
|
+
return path;
|
|
1141
|
+
}
|
|
1142
|
+
catch {
|
|
1143
|
+
// not present — try next
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
return "";
|
|
1147
|
+
}
|
|
1148
|
+
function resolveOutDir(sddPath, override) {
|
|
1149
|
+
if (override !== undefined)
|
|
1150
|
+
return resolve(override);
|
|
1151
|
+
return join(sddPath, "catalogues-aggregated");
|
|
1152
|
+
}
|
|
1153
|
+
/**
|
|
1154
|
+
* Platform tier only: reclassify dangling references on EXTERNAL FKs whose
|
|
1155
|
+
* referencing row is sourced by the platform-root itself as deferred to the
|
|
1156
|
+
* organisation tier. Returns the FK result with those failures removed and an
|
|
1157
|
+
* `external` finding per deferred reference. See step 7a for the reasoning.
|
|
1158
|
+
*/
|
|
1159
|
+
function deferRootSourcedCrossPlatformExternals(schema, rowsByCatalogue, fk, rootSddId, findings, write) {
|
|
1160
|
+
if (fk.internalFailures.length === 0)
|
|
1161
|
+
return fk;
|
|
1162
|
+
const externalByCatalogue = new Map();
|
|
1163
|
+
for (const cat of schema.catalogues) {
|
|
1164
|
+
const ext = new Set(cat.foreignKeys.filter((f) => f.external).map((f) => f.column));
|
|
1165
|
+
if (ext.size > 0)
|
|
1166
|
+
externalByCatalogue.set(cat.name, ext);
|
|
1167
|
+
}
|
|
1168
|
+
const kept = [];
|
|
1169
|
+
let deferred = 0;
|
|
1170
|
+
for (const f of fk.internalFailures) {
|
|
1171
|
+
const isExternal = externalByCatalogue.get(f.catalogue)?.has(f.column) === true;
|
|
1172
|
+
const source = findSourceSdd(rowsByCatalogue, f.catalogue, f.referencingRow);
|
|
1173
|
+
if (isExternal && f.value !== "" && source === rootSddId) {
|
|
1174
|
+
deferred++;
|
|
1175
|
+
findings.add("external", `${f.catalogue}.${f.column} → ${f.target}=${f.value} (referencing row pk=${f.referencingRow}, sourced by the platform-root itself — a cross-platform dependency; resolves at organisation tier, M-ADR 0208)`);
|
|
1176
|
+
continue;
|
|
1177
|
+
}
|
|
1178
|
+
kept.push(f);
|
|
1179
|
+
}
|
|
1180
|
+
if (deferred > 0) {
|
|
1181
|
+
write(` cross-platform externals deferred to organisation tier: ${deferred} (root-sourced rows on external FKs)\n`);
|
|
1182
|
+
}
|
|
1183
|
+
return { internalPass: kept.length === 0, internalFailures: kept, externalFindings: fk.externalFindings + deferred };
|
|
1184
|
+
}
|
|
1185
|
+
function findSourceSdd(rowsByCatalogue, catalogue, pkValue) {
|
|
1186
|
+
const rows = rowsByCatalogue.get(catalogue) ?? [];
|
|
1187
|
+
for (const row of rows) {
|
|
1188
|
+
if (String(row["id"] ?? "") === pkValue) {
|
|
1189
|
+
const src = String(row["source_sdd_id"] ?? "");
|
|
1190
|
+
return src;
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
return "";
|
|
1194
|
+
}
|
|
1195
|
+
async function writeAggregationManifestJson(inputs) {
|
|
1196
|
+
const out = {
|
|
1197
|
+
schema_version: inputs.schemaVersion,
|
|
1198
|
+
cli_version: inputs.cliVersion,
|
|
1199
|
+
ecosystem_root: inputs.ecosystemRoot,
|
|
1200
|
+
siblings: inputs.siblings.map((s) => ({
|
|
1201
|
+
sdd_id: s.sddId,
|
|
1202
|
+
description: s.description,
|
|
1203
|
+
catalogues_path: s.relativeCataloguesPath,
|
|
1204
|
+
pre_merge_row_counts: counts(inputs.perSiblingCounts.get(s.sddId)),
|
|
1205
|
+
})),
|
|
1206
|
+
ecosystem_root_pre_merge_row_counts: counts(inputs.perSiblingCounts.get(inputs.ecosystemRoot)),
|
|
1207
|
+
aggregated_row_counts: counts(inputs.aggregatedRowCounts),
|
|
1208
|
+
};
|
|
1209
|
+
await writeFile(join(inputs.outDir, "aggregation-manifest.json"), JSON.stringify(out, null, 2) + "\n", "utf-8");
|
|
1210
|
+
}
|
|
1211
|
+
function counts(map) {
|
|
1212
|
+
const out = {};
|
|
1213
|
+
if (!map)
|
|
1214
|
+
return out;
|
|
1215
|
+
for (const [k, v] of [...map.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
1216
|
+
out[k] = v;
|
|
1217
|
+
}
|
|
1218
|
+
return out;
|
|
1219
|
+
}
|
|
1220
|
+
async function finalise(inputs) {
|
|
1221
|
+
const rowCounts = new Map();
|
|
1222
|
+
for (const cat of inputs.schema.catalogues) {
|
|
1223
|
+
rowCounts.set(cat.name, (inputs.rowsByCatalogue.get(cat.name) ?? []).length);
|
|
1224
|
+
}
|
|
1225
|
+
return {
|
|
1226
|
+
exitCode: 1,
|
|
1227
|
+
schemaVersion: inputs.schema.schemaVersion,
|
|
1228
|
+
ecosystemRoot: inputs.ecosystemRoot,
|
|
1229
|
+
siblingCount: inputs.aggManifest.siblings.length,
|
|
1230
|
+
rowCounts,
|
|
1231
|
+
perSiblingCounts: inputs.perSiblingCounts,
|
|
1232
|
+
aggregatedFkPass: inputs.aggregatedFkPass,
|
|
1233
|
+
findingsCount: inputs.findings.count(),
|
|
1234
|
+
outDir: inputs.outDir,
|
|
1235
|
+
};
|
|
1236
|
+
}
|
|
1237
|
+
/**
|
|
1238
|
+
* Recover a feature's slug from its catalogue row. `features.id` is
|
|
1239
|
+
* `{capability_id|product_id}-{slug}` (per the features builder), and both
|
|
1240
|
+
* prefixes are columns — so the slug is the id with the owning prefix stripped.
|
|
1241
|
+
* Falls back to the full id when no prefix is recoverable.
|
|
1242
|
+
*/
|
|
1243
|
+
function featureSlugOf(row) {
|
|
1244
|
+
const id = String(row["id"] ?? "");
|
|
1245
|
+
const cap = String(row["capability_id"] ?? "");
|
|
1246
|
+
const prod = String(row["product_id"] ?? "");
|
|
1247
|
+
const prefix = cap !== "" ? cap : prod;
|
|
1248
|
+
if (prefix !== "" && id.startsWith(`${prefix}-`)) {
|
|
1249
|
+
return id.slice(prefix.length + 1);
|
|
1250
|
+
}
|
|
1251
|
+
return id;
|
|
1252
|
+
}
|
|
1253
|
+
/**
|
|
1254
|
+
* Resolve cross-SDD `screen_feature` edges (schema 2.24.0, R3).
|
|
1255
|
+
*
|
|
1256
|
+
* App/integration per-SDD builds emit `screen_feature` rows whose `feature_id`
|
|
1257
|
+
* is the bare `realises:` slug when the feature lives in another SDD (the
|
|
1258
|
+
* platform — MFE-over-platform). Here, post-merge, each such row (feature_id
|
|
1259
|
+
* not already a feature PK) is resolved to the platform feature id by matching
|
|
1260
|
+
* the slug against every merged feature's recovered slug:
|
|
1261
|
+
* - exactly one match → rewrite feature_id (and the composite PK).
|
|
1262
|
+
* - >1 match → ambiguous; refuse (divergence finding), leave as-is
|
|
1263
|
+
* so the FK pass surfaces it. ADR 0177 guarantees one
|
|
1264
|
+
* slug per *capability*, not estate-wide, so cross-
|
|
1265
|
+
* capability slug clashes are possible and not guessed.
|
|
1266
|
+
* - 0 matches → orphan finding, leave as-is.
|
|
1267
|
+
* Rows that already resolve (local features; forward/app-owned estates) pass
|
|
1268
|
+
* through untouched.
|
|
1269
|
+
*
|
|
1270
|
+
* Exported for testing.
|
|
1271
|
+
*/
|
|
1272
|
+
export function resolveCrossSddScreenFeature(screenFeatures, features, findings) {
|
|
1273
|
+
const featureIds = new Set();
|
|
1274
|
+
for (const f of features) {
|
|
1275
|
+
const id = String(f["id"] ?? "");
|
|
1276
|
+
if (id !== "")
|
|
1277
|
+
featureIds.add(id);
|
|
1278
|
+
}
|
|
1279
|
+
const idsBySlug = new Map();
|
|
1280
|
+
for (const f of features) {
|
|
1281
|
+
const slug = featureSlugOf(f);
|
|
1282
|
+
if (slug === "")
|
|
1283
|
+
continue;
|
|
1284
|
+
const bucket = idsBySlug.get(slug);
|
|
1285
|
+
if (bucket === undefined)
|
|
1286
|
+
idsBySlug.set(slug, [String(f["id"])]);
|
|
1287
|
+
else
|
|
1288
|
+
bucket.push(String(f["id"]));
|
|
1289
|
+
}
|
|
1290
|
+
const out = [];
|
|
1291
|
+
const seenPk = new Set();
|
|
1292
|
+
for (const row of screenFeatures) {
|
|
1293
|
+
const featureId = String(row["feature_id"] ?? "");
|
|
1294
|
+
// Already resolves (local feature, or an already-full id) — pass through.
|
|
1295
|
+
if (featureId === "" || featureIds.has(featureId)) {
|
|
1296
|
+
out.push(row);
|
|
1297
|
+
continue;
|
|
1298
|
+
}
|
|
1299
|
+
const matches = idsBySlug.get(featureId) ?? [];
|
|
1300
|
+
const screenId = String(row["screen_id"] ?? "");
|
|
1301
|
+
if (matches.length === 1) {
|
|
1302
|
+
const resolved = matches[0];
|
|
1303
|
+
const pk = `${screenId}__${resolved}`;
|
|
1304
|
+
if (seenPk.has(pk))
|
|
1305
|
+
continue; // dedupe if another SDD already produced it
|
|
1306
|
+
seenPk.add(pk);
|
|
1307
|
+
out.push({ ...row, id: pk, feature_id: resolved });
|
|
1308
|
+
}
|
|
1309
|
+
else if (matches.length > 1) {
|
|
1310
|
+
findings.add("divergence", `screen_feature: ${screenId} realises slug "${featureId}" resolves ambiguously ` +
|
|
1311
|
+
`to ${matches.length} features ecosystem-wide (${matches.join(", ")}) — left unresolved; ` +
|
|
1312
|
+
`qualify the realises ref or disambiguate the slug vocabulary`);
|
|
1313
|
+
out.push(row);
|
|
1314
|
+
}
|
|
1315
|
+
else {
|
|
1316
|
+
findings.add("orphan", `screen_feature: ${screenId} realises "${featureId}" — no feature resolves it ecosystem-wide`);
|
|
1317
|
+
out.push(row);
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
return out;
|
|
1321
|
+
}
|
|
1322
|
+
/**
|
|
1323
|
+
* Orphan-check `products.successor_sdd_id` soft references (schema 2.22.0).
|
|
1324
|
+
*
|
|
1325
|
+
* A deprecated product's successor pointer resolves when the named sdd_id is
|
|
1326
|
+
* visible at this aggregation tier: either a direct contributor (root or
|
|
1327
|
+
* sibling) or a `source_sdd_id` stamped on any products row by a lower tier
|
|
1328
|
+
* (org-tier re-aggregation of cohort outputs). Unresolved pointers emit an
|
|
1329
|
+
* `orphan` finding and nothing else — the successor may legitimately live
|
|
1330
|
+
* outside the current aggregation scope, so this is never a hard FK failure.
|
|
1331
|
+
*
|
|
1332
|
+
* Exported for testing.
|
|
1333
|
+
*/
|
|
1334
|
+
export function checkSuccessorReferences(products, contributorSddIds, findings) {
|
|
1335
|
+
const visible = new Set(contributorSddIds);
|
|
1336
|
+
for (const p of products) {
|
|
1337
|
+
const stamped = String(p["source_sdd_id"] ?? "");
|
|
1338
|
+
if (stamped !== "")
|
|
1339
|
+
visible.add(stamped);
|
|
1340
|
+
const own = String(p["id"] ?? "");
|
|
1341
|
+
if (own !== "")
|
|
1342
|
+
visible.add(own);
|
|
1343
|
+
}
|
|
1344
|
+
for (const p of products) {
|
|
1345
|
+
const successor = String(p["successor_sdd_id"] ?? "");
|
|
1346
|
+
if (successor === "")
|
|
1347
|
+
continue;
|
|
1348
|
+
if (!visible.has(successor)) {
|
|
1349
|
+
findings.add("orphan", `products: ${String(p["id"])} declares successor_sdd_id="${successor}" ` +
|
|
1350
|
+
`but no contributing SDD resolves it at this tier (soft reference — ` +
|
|
1351
|
+
`the successor may live outside this aggregation scope)`);
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
/**
|
|
1356
|
+
* M-ADR 0189 §4.4 — adjudicate external-system candidates against the
|
|
1357
|
+
* merged estate. A `System_Ext` in a domain-scoped L2 is a scope-relative
|
|
1358
|
+
* REFERENCE (§4.1), and even L1 declarations may name something the estate
|
|
1359
|
+
* owns (measured: 39% of one estate's dependency edges pointed at an
|
|
1360
|
+
* external-system row denoting its own platform). Identity is the declared
|
|
1361
|
+
* diagram entry id (§4.2) — at schema 3.0.0 the row id derives from it
|
|
1362
|
+
* (M-ADR 0203 §4.2) — with the display name only as a unique-match
|
|
1363
|
+
* fallback.
|
|
1364
|
+
*
|
|
1365
|
+
* Outcomes per candidate:
|
|
1366
|
+
* - resolves to an internal system of kind platform/subsystem/application:
|
|
1367
|
+
* the candidate row is dropped and its system_external_dependency edges
|
|
1368
|
+
* are rewritten onto system_system_dependency (§4.3). A name-fallback
|
|
1369
|
+
* resolution adds a `convention` finding (label-keyed identity, §4.2);
|
|
1370
|
+
* a would-be self-edge is refused with a finding.
|
|
1371
|
+
* - resolves to an internal system of kind `integration`: a PROVIDER-PAIR
|
|
1372
|
+
* COLLISION, not a reference (a connector SDD named after its genuinely
|
|
1373
|
+
* external provider — the kind discriminator measured on both estates).
|
|
1374
|
+
* Kept external; silent when self-declared, `convention` finding
|
|
1375
|
+
* otherwise.
|
|
1376
|
+
* - resolves to an internal container: tier contradiction — `coverage`
|
|
1377
|
+
* finding, row kept (the tier cannot be auto-corrected).
|
|
1378
|
+
* - resolves to nothing: genuinely external, unchanged.
|
|
1379
|
+
*
|
|
1380
|
+
* Exported for testing.
|
|
1381
|
+
*/
|
|
1382
|
+
export function adjudicateExternalSystems(rowsByCatalogue, findings) {
|
|
1383
|
+
const systems = rowsByCatalogue.get("c4_systems") ?? [];
|
|
1384
|
+
const exts = rowsByCatalogue.get("c4_external_systems") ?? [];
|
|
1385
|
+
const containers = rowsByCatalogue.get("c4_containers") ?? [];
|
|
1386
|
+
const deps = rowsByCatalogue.get("system_external_dependency") ?? [];
|
|
1387
|
+
// Camel humps split BEFORE lowercasing (F11a): most SDD ids are
|
|
1388
|
+
// hyphenated, and `bvfmGatewayFramework` must be able to declare
|
|
1389
|
+
// `bvfm-gateway-framework`.
|
|
1390
|
+
const norm = (s) => s
|
|
1391
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
|
|
1392
|
+
.toLowerCase()
|
|
1393
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
1394
|
+
.replace(/^-+|-+$/g, "");
|
|
1395
|
+
const compact = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
1396
|
+
const systemIds = new Set(systems.map((s) => String(s["id"])));
|
|
1397
|
+
const kindById = new Map(systems.map((s) => [String(s["id"]), String(s["kind"])]));
|
|
1398
|
+
const sddById = new Map(systems.map((s) => [String(s["id"]), String(s["product_id"] ?? "")]));
|
|
1399
|
+
const systemByProduct = new Map(systems
|
|
1400
|
+
.filter((s) => String(s["product_id"] ?? "") !== "")
|
|
1401
|
+
.map((s) => [String(s["product_id"]), String(s["id"])]));
|
|
1402
|
+
// Name index — names ambiguous across systems are excluded from matching
|
|
1403
|
+
// (post-F7 duplicates are themselves a finding).
|
|
1404
|
+
const systemsByName = new Map();
|
|
1405
|
+
for (const s of systems) {
|
|
1406
|
+
const key = compact(String(s["name"] ?? ""));
|
|
1407
|
+
if (key === "")
|
|
1408
|
+
continue;
|
|
1409
|
+
const ids = systemsByName.get(key);
|
|
1410
|
+
if (ids)
|
|
1411
|
+
ids.push(String(s["id"]));
|
|
1412
|
+
else
|
|
1413
|
+
systemsByName.set(key, [String(s["id"])]);
|
|
1414
|
+
}
|
|
1415
|
+
// Container indexes (F11b): keys (names + slug aliases) are confident
|
|
1416
|
+
// only within the DECLARING SDD's own system; across the estate, only a
|
|
1417
|
+
// full display-name match counts, and only when it is unique — a
|
|
1418
|
+
// diagram-local alias like `emailService` must never bind to an
|
|
1419
|
+
// unrelated SDD's container, and an ambiguous name asserts no referent.
|
|
1420
|
+
const containerKeysBySystem = new Map();
|
|
1421
|
+
const containersByName = new Map();
|
|
1422
|
+
for (const c of containers) {
|
|
1423
|
+
const cid = String(c["id"]);
|
|
1424
|
+
const sysId = String(c["system_id"] ?? "");
|
|
1425
|
+
const slugPart = sysId !== "" && cid.startsWith(`${sysId}-`) ? cid.slice(sysId.length + 1) : cid;
|
|
1426
|
+
let own = containerKeysBySystem.get(sysId);
|
|
1427
|
+
if (own === undefined) {
|
|
1428
|
+
own = new Map();
|
|
1429
|
+
containerKeysBySystem.set(sysId, own);
|
|
1430
|
+
}
|
|
1431
|
+
for (const key of [compact(String(c["name"] ?? "")), compact(slugPart)]) {
|
|
1432
|
+
if (key !== "" && !own.has(key))
|
|
1433
|
+
own.set(key, cid);
|
|
1434
|
+
}
|
|
1435
|
+
const nameKey = compact(String(c["name"] ?? ""));
|
|
1436
|
+
if (nameKey !== "") {
|
|
1437
|
+
const ids = containersByName.get(nameKey);
|
|
1438
|
+
if (ids)
|
|
1439
|
+
ids.push(cid);
|
|
1440
|
+
else
|
|
1441
|
+
containersByName.set(nameKey, [cid]);
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
const resolveSystem = (ext) => {
|
|
1445
|
+
// Schema 3.0.0: the row id itself derives from the declared entry id
|
|
1446
|
+
// (M-ADR 0203 §4.2), so the id-slug IS declared identity — it backstops
|
|
1447
|
+
// an empty declared_id cell, and a hit through it is a declared hit
|
|
1448
|
+
// (corroboration-gated), never a label-keyed one.
|
|
1449
|
+
const declared = norm(String(ext["declared_id"] ?? "")) || String(ext["id"]).replace(/^ext-/, "");
|
|
1450
|
+
if (declared !== "") {
|
|
1451
|
+
for (const cand of [declared, `${declared}-system`]) {
|
|
1452
|
+
if (systemIds.has(cand))
|
|
1453
|
+
return { id: cand, method: "declared" };
|
|
1454
|
+
}
|
|
1455
|
+
const viaProduct = systemByProduct.get(declared);
|
|
1456
|
+
if (viaProduct !== undefined)
|
|
1457
|
+
return { id: viaProduct, method: "declared" };
|
|
1458
|
+
}
|
|
1459
|
+
// NAME evidence, per M-ADR 0189 §4.2's ratified fallback ("the
|
|
1460
|
+
// display-name slug remains only as a fallback for entries whose id
|
|
1461
|
+
// resolves to nothing"). Under 2.x the row id WAS the name slug, so
|
|
1462
|
+
// the id-slug path carried this; at 3.0.0 the slug must be derived
|
|
1463
|
+
// from the name itself or the fallback silently narrows — measured on
|
|
1464
|
+
// the first forward-estate probe: 35 of 38 internal platform edges
|
|
1465
|
+
// reclassified external because `System_Ext(platform, "Acme
|
|
1466
|
+
// Platform", …)` stopped resolving against the `acme-platform`
|
|
1467
|
+
// product. Every hit here is method "name" (label-keyed — the
|
|
1468
|
+
// conversion emits the §4.2 convention finding).
|
|
1469
|
+
const nameSlug = norm(String(ext["name"] ?? ""));
|
|
1470
|
+
if (nameSlug !== "") {
|
|
1471
|
+
for (const cand of [nameSlug, `${nameSlug}-system`]) {
|
|
1472
|
+
if (systemIds.has(cand))
|
|
1473
|
+
return { id: cand, method: "name" };
|
|
1474
|
+
}
|
|
1475
|
+
const viaNameProduct = systemByProduct.get(nameSlug);
|
|
1476
|
+
if (viaNameProduct !== undefined)
|
|
1477
|
+
return { id: viaNameProduct, method: "name" };
|
|
1478
|
+
}
|
|
1479
|
+
const byName = systemsByName.get(compact(String(ext["name"] ?? "")));
|
|
1480
|
+
if (byName !== undefined && byName.length === 1) {
|
|
1481
|
+
return { id: byName[0], method: "name" };
|
|
1482
|
+
}
|
|
1483
|
+
return null;
|
|
1484
|
+
};
|
|
1485
|
+
// Estate-wide declared-key index: key → container ids across ALL systems.
|
|
1486
|
+
const containersByKey = new Map();
|
|
1487
|
+
for (const [, own] of containerKeysBySystem) {
|
|
1488
|
+
for (const [key, cid] of own) {
|
|
1489
|
+
const ids = containersByKey.get(key);
|
|
1490
|
+
if (ids) {
|
|
1491
|
+
if (!ids.includes(cid))
|
|
1492
|
+
ids.push(cid);
|
|
1493
|
+
}
|
|
1494
|
+
else
|
|
1495
|
+
containersByKey.set(key, [cid]);
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
const containerNameById = new Map(containers.map((c) => [String(c["id"]), String(c["name"] ?? "")]));
|
|
1499
|
+
const containerSystemById = new Map(containers.map((c) => [String(c["id"]), String(c["system_id"] ?? "")]));
|
|
1500
|
+
// F12: containers follow the SAME precedence as systems — declared id
|
|
1501
|
+
// first, corroborated by the display name; the name alone only as a
|
|
1502
|
+
// unique fallback. 0.73.0 inverted this (name-equality only, declared id
|
|
1503
|
+
// discarded) and traded two false positives for five silent true
|
|
1504
|
+
// contradictions. Silence is not the remedy for a false reason; a true
|
|
1505
|
+
// reason is — every held candidate below states why it is held.
|
|
1506
|
+
const resolveContainer = (ext) => {
|
|
1507
|
+
const declarerSystem = systemByProduct.get(String(ext["source_sdd_id"] ?? ""));
|
|
1508
|
+
if (declarerSystem !== undefined) {
|
|
1509
|
+
const own = containerKeysBySystem.get(declarerSystem);
|
|
1510
|
+
if (own !== undefined) {
|
|
1511
|
+
// The id-slug key duplicates the declared key at 3.0.0 (the row id
|
|
1512
|
+
// derives from the entry id) but backstops an empty declared_id.
|
|
1513
|
+
for (const key of [
|
|
1514
|
+
compact(String(ext["declared_id"] ?? "")),
|
|
1515
|
+
compact(String(ext["id"]).replace(/^ext-/, "")),
|
|
1516
|
+
compact(String(ext["name"] ?? "")),
|
|
1517
|
+
]) {
|
|
1518
|
+
const cid = key !== "" ? own.get(key) : undefined;
|
|
1519
|
+
if (cid !== undefined)
|
|
1520
|
+
return { kind: "own", containerId: cid };
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
const extName = String(ext["name"] ?? "");
|
|
1525
|
+
const declaredKey = compact(String(ext["declared_id"] ?? "")) ||
|
|
1526
|
+
compact(String(ext["id"]).replace(/^ext-/, ""));
|
|
1527
|
+
const byKey = declaredKey !== "" ? containersByKey.get(declaredKey) : undefined;
|
|
1528
|
+
if (byKey !== undefined) {
|
|
1529
|
+
const corroborating = byKey.filter((cid) => corroboratesLabel(extName, containerNameById.get(cid) ?? "", ""));
|
|
1530
|
+
if (corroborating.length > 1) {
|
|
1531
|
+
return { kind: "ambiguous", candidates: [...corroborating].sort((a, b) => a.localeCompare(b)) };
|
|
1532
|
+
}
|
|
1533
|
+
if (corroborating.length === 1) {
|
|
1534
|
+
if (isUncertainLabel(extName)) {
|
|
1535
|
+
return { kind: "uncertain", containerId: corroborating[0] };
|
|
1536
|
+
}
|
|
1537
|
+
return { kind: "cross", containerId: corroborating[0] };
|
|
1538
|
+
}
|
|
1539
|
+
// Declared key matches, name disagrees — held, loudly.
|
|
1540
|
+
return { kind: "uncorroborated", containerId: byKey[0] };
|
|
1541
|
+
}
|
|
1542
|
+
const nameKey = compact(extName);
|
|
1543
|
+
const byName = nameKey !== "" ? containersByName.get(nameKey) : undefined;
|
|
1544
|
+
if (byName !== undefined) {
|
|
1545
|
+
if (byName.length === 1) {
|
|
1546
|
+
if (isUncertainLabel(extName)) {
|
|
1547
|
+
return { kind: "uncertain", containerId: byName[0] };
|
|
1548
|
+
}
|
|
1549
|
+
return { kind: "cross", containerId: byName[0] };
|
|
1550
|
+
}
|
|
1551
|
+
return { kind: "ambiguous", candidates: [...byName].sort((a, b) => a.localeCompare(b)) };
|
|
1552
|
+
}
|
|
1553
|
+
return null;
|
|
1554
|
+
};
|
|
1555
|
+
// F11a/F12 corroboration: a declared-id hit converts (or binds) only when
|
|
1556
|
+
// the display name agrees with the target — diagram entry ids are
|
|
1557
|
+
// conventionally local, and an old mnemonic that happens to equal an
|
|
1558
|
+
// estate id must not convert silently. Comparison is TOKEN-level, not
|
|
1559
|
+
// substring (F12): "Tachograph Service" must not corroborate `tacho`
|
|
1560
|
+
// (shared prefix, different entity), while "Engine (telematics
|
|
1561
|
+
// processor)" must corroborate a container named "Engine" (a richer
|
|
1562
|
+
// label must not defeat the match) and three-letter names like "CWS"
|
|
1563
|
+
// must work (no length floor). Corroborated = compact equality with the
|
|
1564
|
+
// target's name or id, or every token of either appearing among the
|
|
1565
|
+
// label's tokens. A declared id that corroborates is an assertion the
|
|
1566
|
+
// author is accountable for (M-ADR 0189 §4.2); renaming the mnemonic
|
|
1567
|
+
// opts out.
|
|
1568
|
+
const tokens = (s) => s
|
|
1569
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
1570
|
+
.toLowerCase()
|
|
1571
|
+
.split(/[^a-z0-9]+/)
|
|
1572
|
+
.filter((t) => t !== "");
|
|
1573
|
+
const tokenSubset = (target, label) => {
|
|
1574
|
+
const ts = tokens(target);
|
|
1575
|
+
return ts.length > 0 && ts.every((t) => label.has(t));
|
|
1576
|
+
};
|
|
1577
|
+
const corroboratesLabel = (extName, targetName, targetId) => {
|
|
1578
|
+
const en = compact(extName);
|
|
1579
|
+
if (en === "")
|
|
1580
|
+
return false;
|
|
1581
|
+
const labelTokens = new Set(tokens(extName));
|
|
1582
|
+
for (const t of [targetName, targetId]) {
|
|
1583
|
+
if (t === "")
|
|
1584
|
+
continue;
|
|
1585
|
+
if (en === compact(t))
|
|
1586
|
+
return true;
|
|
1587
|
+
if (tokenSubset(t, labelTokens))
|
|
1588
|
+
return true;
|
|
1589
|
+
}
|
|
1590
|
+
return false;
|
|
1591
|
+
};
|
|
1592
|
+
const nameBysystem = new Map(systems.map((s) => [String(s["id"]), String(s["name"] ?? "")]));
|
|
1593
|
+
const corroborates = (ext, sysId) => corroboratesLabel(String(ext["name"] ?? ""), nameBysystem.get(sysId) ?? "", sddById.get(sysId) ?? "");
|
|
1594
|
+
// A bracketed qualifier ("Email Service [unknown]") is the author
|
|
1595
|
+
// declaring uncertainty — it must never be rendered as a confident
|
|
1596
|
+
// referent (F11b/F12): held, with the uncertainty as the stated reason.
|
|
1597
|
+
const isUncertainLabel = (name) => /\[[^\]]*\]/.test(name);
|
|
1598
|
+
const keptExts = [];
|
|
1599
|
+
const dropExtIds = new Map();
|
|
1600
|
+
const outcomeByExtId = new Map();
|
|
1601
|
+
let converted = 0;
|
|
1602
|
+
let tierContradictions = 0;
|
|
1603
|
+
let providerPairsKept = 0;
|
|
1604
|
+
let uncorroboratedKept = 0;
|
|
1605
|
+
let uncertainHolds = 0;
|
|
1606
|
+
let ambiguousHolds = 0;
|
|
1607
|
+
let unverified = 0;
|
|
1608
|
+
let exempted = 0;
|
|
1609
|
+
let containerConverted = 0;
|
|
1610
|
+
let containerEdgesRewritten = 0;
|
|
1611
|
+
let systemFallbackEdges = 0;
|
|
1612
|
+
// (F17) edges per candidate, split by whether the member build resolved
|
|
1613
|
+
// the Rel's non-external endpoint to a local container — the datum that
|
|
1614
|
+
// decides whether a container-bound candidate can convert without
|
|
1615
|
+
// destroying an edge.
|
|
1616
|
+
const edgesByExtId = new Map();
|
|
1617
|
+
for (const dep of deps) {
|
|
1618
|
+
const key = String(dep["external_system_id"]);
|
|
1619
|
+
let bucket = edgesByExtId.get(key);
|
|
1620
|
+
if (bucket === undefined) {
|
|
1621
|
+
bucket = { convertible: [], stuck: [] };
|
|
1622
|
+
edgesByExtId.set(key, bucket);
|
|
1623
|
+
}
|
|
1624
|
+
if (String(dep["from_container_id"] ?? "") !== "")
|
|
1625
|
+
bucket.convertible.push(dep);
|
|
1626
|
+
else
|
|
1627
|
+
bucket.stuck.push(dep);
|
|
1628
|
+
}
|
|
1629
|
+
const containerTargetByExtId = new Map();
|
|
1630
|
+
const containerDroppedByExtId = new Map();
|
|
1631
|
+
for (const ext of exts) {
|
|
1632
|
+
const extId = String(ext["id"]);
|
|
1633
|
+
const declaredBy = String(ext["source_sdd_id"] ?? "");
|
|
1634
|
+
const sys = resolveSystem(ext);
|
|
1635
|
+
if (sys !== null) {
|
|
1636
|
+
const kind = kindById.get(sys.id) ?? "";
|
|
1637
|
+
if (kind === "integration") {
|
|
1638
|
+
providerPairsKept++;
|
|
1639
|
+
outcomeByExtId.set(extId, "provider-pair collision");
|
|
1640
|
+
keptExts.push(ext);
|
|
1641
|
+
const ownerSdd = sddById.get(sys.id) ?? "";
|
|
1642
|
+
if (declaredBy !== "" && declaredBy === ownerSdd) {
|
|
1643
|
+
// Self-declared provider pair — the connector SDD declaring its
|
|
1644
|
+
// genuinely-external provider. Silent by design.
|
|
1645
|
+
}
|
|
1646
|
+
else {
|
|
1647
|
+
findings.add("convention", `c4_external_systems: "${extId}" collides with integration-kind system "${sys.id}" — ` +
|
|
1648
|
+
`likely a provider pair (connector named after its external provider); kept external (M-ADR 0189 §4.4)`);
|
|
1649
|
+
}
|
|
1650
|
+
continue;
|
|
1651
|
+
}
|
|
1652
|
+
if (sys.method === "declared" && !corroborates(ext, sys.id)) {
|
|
1653
|
+
// F11a: declared-id hit without name corroboration — a collision
|
|
1654
|
+
// between a diagram-local mnemonic and an estate id, not a
|
|
1655
|
+
// reference. Kept external, loudly.
|
|
1656
|
+
uncorroboratedKept++;
|
|
1657
|
+
outcomeByExtId.set(extId, "uncorroborated hold");
|
|
1658
|
+
keptExts.push(ext);
|
|
1659
|
+
findings.add("convention", `c4_external_systems: "${extId}" declared_id resolves to internal system "${sys.id}" ` +
|
|
1660
|
+
`but the display name does not corroborate — kept external; align the entry id or name to convert (M-ADR 0189 §4.2 / F11a)`);
|
|
1661
|
+
continue;
|
|
1662
|
+
}
|
|
1663
|
+
if (isUncertainLabel(String(ext["name"] ?? ""))) {
|
|
1664
|
+
// Declared uncertainty never converts (F12) — held with the
|
|
1665
|
+
// uncertainty as the stated reason.
|
|
1666
|
+
uncertainHolds++;
|
|
1667
|
+
outcomeByExtId.set(extId, "uncertain hold");
|
|
1668
|
+
keptExts.push(ext);
|
|
1669
|
+
findings.add("coverage", `c4_external_systems: "${extId}" resolves to internal system "${sys.id}" but the label declares uncertainty — ` +
|
|
1670
|
+
`held, not converted; resolve the uncertainty to convert (M-ADR 0189 §4.4 / F12)`);
|
|
1671
|
+
continue;
|
|
1672
|
+
}
|
|
1673
|
+
// Internal reference — convert.
|
|
1674
|
+
dropExtIds.set(extId, { toSystem: sys.id });
|
|
1675
|
+
converted++;
|
|
1676
|
+
if (sys.method === "name") {
|
|
1677
|
+
findings.add("convention", `c4_external_systems: "${extId}" resolved to internal system "${sys.id}" by display-name fallback — ` +
|
|
1678
|
+
`label-keyed identity; declare the entry id to make this exact (M-ADR 0189 §4.2)`);
|
|
1679
|
+
}
|
|
1680
|
+
continue;
|
|
1681
|
+
}
|
|
1682
|
+
const containerRes = resolveContainer(ext);
|
|
1683
|
+
if (containerRes !== null) {
|
|
1684
|
+
outcomeByExtId.set(extId, containerRes.kind === "uncorroborated"
|
|
1685
|
+
? "uncorroborated hold"
|
|
1686
|
+
: containerRes.kind === "uncertain"
|
|
1687
|
+
? "uncertain hold"
|
|
1688
|
+
: containerRes.kind === "ambiguous"
|
|
1689
|
+
? "ambiguous hold"
|
|
1690
|
+
: "tier contradiction");
|
|
1691
|
+
if (containerRes.kind === "uncorroborated") {
|
|
1692
|
+
uncorroboratedKept++;
|
|
1693
|
+
findings.add("convention", `c4_external_systems: "${extId}"${declaredBy !== "" ? ` (declared by ${declaredBy})` : ""} ` +
|
|
1694
|
+
`declared_id matches internal container "${containerRes.containerId}" but the display name does not corroborate — ` +
|
|
1695
|
+
`no referent asserted; align the entry id or name to bind (M-ADR 0189 §4.2 / F12)`);
|
|
1696
|
+
keptExts.push(ext);
|
|
1697
|
+
continue;
|
|
1698
|
+
}
|
|
1699
|
+
if (containerRes.kind === "uncertain") {
|
|
1700
|
+
uncertainHolds++;
|
|
1701
|
+
// (already marked as an outcome above)
|
|
1702
|
+
findings.add("coverage", `c4_external_systems: "${extId}"${declaredBy !== "" ? ` (declared by ${declaredBy})` : ""} ` +
|
|
1703
|
+
`matches internal container "${containerRes.containerId}" but the label declares uncertainty — ` +
|
|
1704
|
+
`held, not asserted; resolve the uncertainty to bind (M-ADR 0189 §4.4 / F12)`);
|
|
1705
|
+
keptExts.push(ext);
|
|
1706
|
+
continue;
|
|
1707
|
+
}
|
|
1708
|
+
if (containerRes.kind === "ambiguous") {
|
|
1709
|
+
ambiguousHolds++;
|
|
1710
|
+
findings.add("coverage", `c4_external_systems: "${extId}"${declaredBy !== "" ? ` (declared by ${declaredBy})` : ""} ` +
|
|
1711
|
+
`name-matches ${containerRes.candidates.length} internal containers [${containerRes.candidates.join(", ")}] — ` +
|
|
1712
|
+
`ambiguous tier contradiction, no referent asserted, row kept (M-ADR 0189 §4.4 / F11b)`);
|
|
1713
|
+
}
|
|
1714
|
+
else {
|
|
1715
|
+
// F17: a bound candidate CONVERTS — its edges rewrite onto
|
|
1716
|
+
// container_dependency where the from-endpoint is known. Only a
|
|
1717
|
+
// candidate with unconvertible edges is kept, with the
|
|
1718
|
+
// contradiction finding stating exactly what blocks it. No edge
|
|
1719
|
+
// is ever destroyed to make a build quieter.
|
|
1720
|
+
containerTargetByExtId.set(extId, containerRes.containerId);
|
|
1721
|
+
const bucket = edgesByExtId.get(extId);
|
|
1722
|
+
// F24 (F17's residue): a stuck edge whose from-SYSTEM differs from
|
|
1723
|
+
// the target container's owning system is a correct C4 L1 edge —
|
|
1724
|
+
// it rewrites onto system_system_dependency (from-system → owning
|
|
1725
|
+
// system). Only a stuck edge WITHIN the owning system truly blocks
|
|
1726
|
+
// conversion: it would be an L1 self-edge, and the candidate is
|
|
1727
|
+
// kept for it.
|
|
1728
|
+
const owner = containerSystemById.get(containerRes.containerId) ?? "";
|
|
1729
|
+
const trulyStuck = (bucket?.stuck ?? []).filter((d) => String(d["c4_system_id"]) === owner).length;
|
|
1730
|
+
if (trulyStuck === 0) {
|
|
1731
|
+
containerConverted++;
|
|
1732
|
+
containerDroppedByExtId.set(extId, containerRes.containerId);
|
|
1733
|
+
outcomeByExtId.set(extId, "container conversion");
|
|
1734
|
+
continue; // row dropped — a phantom duplicate of the container
|
|
1735
|
+
}
|
|
1736
|
+
tierContradictions++;
|
|
1737
|
+
findings.add("coverage", `c4_external_systems: "${extId}"${declaredBy !== "" ? ` (declared by ${declaredBy})` : ""} ` +
|
|
1738
|
+
`resolves to ${containerRes.kind === "own" ? "its declarer's own" : "an"} internal container "${containerRes.containerId}" — ` +
|
|
1739
|
+
`tier contradiction, row kept: ${trulyStuck} same-system dependency edge(s) have no member-resolved from-container and would be L1 self-edges (M-ADR 0189 §4.4 / F17/F24)`);
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
keptExts.push(ext);
|
|
1743
|
+
}
|
|
1744
|
+
// Rewrite dependency edges whose external endpoint converted — to a
|
|
1745
|
+
// system (§4.4) or, one tier down, to a container (F17).
|
|
1746
|
+
const keptDeps = [];
|
|
1747
|
+
const newEdges = new Map();
|
|
1748
|
+
const newContainerEdges = new Map();
|
|
1749
|
+
let edgesRewritten = 0;
|
|
1750
|
+
for (const dep of deps) {
|
|
1751
|
+
const extKey = String(dep["external_system_id"]);
|
|
1752
|
+
const containerTarget = containerTargetByExtId.get(extKey);
|
|
1753
|
+
if (containerTarget !== undefined) {
|
|
1754
|
+
const fromContainer = String(dep["from_container_id"] ?? "");
|
|
1755
|
+
if (fromContainer === "") {
|
|
1756
|
+
// F24: no member-resolved from-container. Across systems this is a
|
|
1757
|
+
// correct C4 L1 edge — from-system → the container's owning system.
|
|
1758
|
+
// Within the owning system it would be an L1 self-edge: kept, and
|
|
1759
|
+
// its candidate row was kept for it.
|
|
1760
|
+
const owner = containerSystemById.get(containerTarget) ?? "";
|
|
1761
|
+
const fromSystem = String(dep["c4_system_id"]);
|
|
1762
|
+
if (owner !== "" && fromSystem !== owner) {
|
|
1763
|
+
const sid = `${fromSystem}__${owner}`;
|
|
1764
|
+
const existingFE = newEdges.get(sid);
|
|
1765
|
+
if (existingFE !== undefined) {
|
|
1766
|
+
mergeEdgeLabel(existingFE, String(dep["interaction"] ?? ""), "system_system_dependency", findings);
|
|
1767
|
+
}
|
|
1768
|
+
else {
|
|
1769
|
+
newEdges.set(sid, {
|
|
1770
|
+
id: sid,
|
|
1771
|
+
from_system_id: fromSystem,
|
|
1772
|
+
to_system_id: owner,
|
|
1773
|
+
interaction: String(dep["interaction"] ?? ""),
|
|
1774
|
+
source_sdd_id: sddById.get(fromSystem) ?? "",
|
|
1775
|
+
});
|
|
1776
|
+
}
|
|
1777
|
+
systemFallbackEdges++;
|
|
1778
|
+
continue;
|
|
1779
|
+
}
|
|
1780
|
+
keptDeps.push(dep); // truly stuck — its candidate row was kept for it
|
|
1781
|
+
continue;
|
|
1782
|
+
}
|
|
1783
|
+
if (fromContainer === containerTarget) {
|
|
1784
|
+
findings.add("coverage", `container_dependency: refused self-edge ${fromContainer} → ${containerTarget} ` +
|
|
1785
|
+
`(external declaration resolved to the declaring container itself; M-ADR 0189 §4.4 / F17)`);
|
|
1786
|
+
continue;
|
|
1787
|
+
}
|
|
1788
|
+
const cid = `${fromContainer}__${containerTarget}`;
|
|
1789
|
+
const existingCE = newContainerEdges.get(cid);
|
|
1790
|
+
if (existingCE !== undefined) {
|
|
1791
|
+
mergeEdgeLabel(existingCE, String(dep["interaction"] ?? ""), "container_dependency", findings);
|
|
1792
|
+
}
|
|
1793
|
+
else {
|
|
1794
|
+
newContainerEdges.set(cid, {
|
|
1795
|
+
id: cid,
|
|
1796
|
+
from_container_id: fromContainer,
|
|
1797
|
+
to_container_id: containerTarget,
|
|
1798
|
+
interaction: String(dep["interaction"] ?? ""),
|
|
1799
|
+
source_sdd_id: sddById.get(String(dep["c4_system_id"])) ?? "",
|
|
1800
|
+
});
|
|
1801
|
+
}
|
|
1802
|
+
containerEdgesRewritten++;
|
|
1803
|
+
continue;
|
|
1804
|
+
}
|
|
1805
|
+
const target = dropExtIds.get(extKey);
|
|
1806
|
+
if (target === undefined) {
|
|
1807
|
+
keptDeps.push(dep);
|
|
1808
|
+
continue;
|
|
1809
|
+
}
|
|
1810
|
+
const from = String(dep["c4_system_id"]);
|
|
1811
|
+
if (from === target.toSystem) {
|
|
1812
|
+
findings.add("coverage", `system_system_dependency: refused self-edge ${from} → ${target.toSystem} ` +
|
|
1813
|
+
`(external declaration resolved to the declaring system itself; M-ADR 0189 §4.4)`);
|
|
1814
|
+
continue;
|
|
1815
|
+
}
|
|
1816
|
+
const id = `${from}__${target.toSystem}`;
|
|
1817
|
+
const existingSE = newEdges.get(id);
|
|
1818
|
+
if (existingSE !== undefined) {
|
|
1819
|
+
mergeEdgeLabel(existingSE, String(dep["interaction"] ?? ""), "system_system_dependency", findings);
|
|
1820
|
+
}
|
|
1821
|
+
else {
|
|
1822
|
+
newEdges.set(id, {
|
|
1823
|
+
id,
|
|
1824
|
+
from_system_id: from,
|
|
1825
|
+
to_system_id: target.toSystem,
|
|
1826
|
+
interaction: String(dep["interaction"] ?? ""),
|
|
1827
|
+
source_sdd_id: sddById.get(from) ?? "",
|
|
1828
|
+
});
|
|
1829
|
+
}
|
|
1830
|
+
edgesRewritten++;
|
|
1831
|
+
}
|
|
1832
|
+
// §4.6 (WS15): exemptions vs outcomes, then the coverage predicate.
|
|
1833
|
+
// An exemption on a CONVERTED candidate contradicts the adjudication —
|
|
1834
|
+
// reported and dropped (it would also dangle). A kept candidate with no
|
|
1835
|
+
// outcome and no exemption is the silent bucket L4 lived in: reported
|
|
1836
|
+
// unverified, one finding per candidate, retired by declared identity
|
|
1837
|
+
// or by an asserted exemption.
|
|
1838
|
+
// F15: the builder docstring — "each row retires exactly one unverified
|
|
1839
|
+
// finding, and nothing else" — is enforced here, not assumed. A kept
|
|
1840
|
+
// exemption MUST retire exactly one unverified finding; one that retires
|
|
1841
|
+
// nothing (its candidate converted, or already has an adjudication
|
|
1842
|
+
// outcome of any class) is reported and DROPPED, so that
|
|
1843
|
+
// |external_system_exemption| == exempted holds as an invariant rather
|
|
1844
|
+
// than a check someone has to remember.
|
|
1845
|
+
const exemptionRows = rowsByCatalogue.get("external_system_exemption") ?? [];
|
|
1846
|
+
const keptExtIds = new Set(keptExts.map((e) => String(e["id"])));
|
|
1847
|
+
const keptExemptions = [];
|
|
1848
|
+
const exemptedIds = new Set();
|
|
1849
|
+
for (const ex of exemptionRows) {
|
|
1850
|
+
const target = String(ex["external_system_id"] ?? "");
|
|
1851
|
+
const conv = dropExtIds.get(target);
|
|
1852
|
+
if (conv !== undefined) {
|
|
1853
|
+
findings.add("convention", `external_system_exemption: "${target}" is asserted genuinely external but adjudication resolved it to ` +
|
|
1854
|
+
`internal system "${conv.toSystem}" — contradiction; exemption dropped (M-ADR 0189 §4.6)`);
|
|
1855
|
+
continue;
|
|
1856
|
+
}
|
|
1857
|
+
const containerDrop = containerDroppedByExtId.get(target);
|
|
1858
|
+
if (containerDrop !== undefined) {
|
|
1859
|
+
findings.add("convention", `external_system_exemption: "${target}" is asserted genuinely external but adjudication resolved it to ` +
|
|
1860
|
+
`internal container "${containerDrop}" — contradiction; exemption dropped (M-ADR 0189 §4.6 / F17)`);
|
|
1861
|
+
continue;
|
|
1862
|
+
}
|
|
1863
|
+
const outcome = outcomeByExtId.get(target);
|
|
1864
|
+
if (outcome !== undefined) {
|
|
1865
|
+
findings.add("convention", `external_system_exemption: "${target}" retires nothing — the candidate already has an adjudication ` +
|
|
1866
|
+
`outcome (${outcome}); exemption dropped (M-ADR 0189 §4.6 / F15)`);
|
|
1867
|
+
continue;
|
|
1868
|
+
}
|
|
1869
|
+
if (!keptExtIds.has(target)) {
|
|
1870
|
+
findings.add("convention", `external_system_exemption: "${target}" names no kept external-system candidate — exemption dropped (M-ADR 0189 §4.6 / F15)`);
|
|
1871
|
+
continue;
|
|
1872
|
+
}
|
|
1873
|
+
keptExemptions.push(ex);
|
|
1874
|
+
exemptedIds.add(target);
|
|
1875
|
+
}
|
|
1876
|
+
rowsByCatalogue.set("external_system_exemption", keptExemptions);
|
|
1877
|
+
for (const ext of keptExts) {
|
|
1878
|
+
const extId = String(ext["id"]);
|
|
1879
|
+
if (outcomeByExtId.has(extId))
|
|
1880
|
+
continue;
|
|
1881
|
+
if (exemptedIds.has(extId)) {
|
|
1882
|
+
exempted++;
|
|
1883
|
+
continue;
|
|
1884
|
+
}
|
|
1885
|
+
unverified++;
|
|
1886
|
+
findings.add("coverage", `c4_external_systems: "${extId}" resolved to no internal system or container and carries no exemption — ` +
|
|
1887
|
+
`kept external, unverified (M-ADR 0189 §4.6)`);
|
|
1888
|
+
}
|
|
1889
|
+
const existingCD = rowsByCatalogue.get("container_dependency") ?? [];
|
|
1890
|
+
const mergedCD = [...existingCD];
|
|
1891
|
+
const existingCDIds = new Set(existingCD.map((r) => String(r["id"])));
|
|
1892
|
+
const existingCDById = new Map(existingCD.map((r) => [String(r["id"]), r]));
|
|
1893
|
+
for (const row of [...newContainerEdges.values()].sort((a, b) => String(a["id"]).localeCompare(String(b["id"])))) {
|
|
1894
|
+
const member = existingCDById.get(String(row["id"]));
|
|
1895
|
+
if (member !== undefined) {
|
|
1896
|
+
mergeEdgeLabel(member, String(row["interaction"] ?? ""), "container_dependency", findings);
|
|
1897
|
+
continue;
|
|
1898
|
+
}
|
|
1899
|
+
if (!existingCDIds.has(String(row["id"])))
|
|
1900
|
+
mergedCD.push(row);
|
|
1901
|
+
}
|
|
1902
|
+
rowsByCatalogue.set("container_dependency", mergedCD);
|
|
1903
|
+
const existingSSD = rowsByCatalogue.get("system_system_dependency") ?? [];
|
|
1904
|
+
const merged = [...existingSSD];
|
|
1905
|
+
const existingIds = new Set(existingSSD.map((r) => String(r["id"])));
|
|
1906
|
+
const existingSSDById = new Map(existingSSD.map((r) => [String(r["id"]), r]));
|
|
1907
|
+
for (const row of [...newEdges.values()].sort((a, b) => String(a["id"]).localeCompare(String(b["id"])))) {
|
|
1908
|
+
const member = existingSSDById.get(String(row["id"]));
|
|
1909
|
+
if (member !== undefined) {
|
|
1910
|
+
mergeEdgeLabel(member, String(row["interaction"] ?? ""), "system_system_dependency", findings);
|
|
1911
|
+
continue;
|
|
1912
|
+
}
|
|
1913
|
+
if (!existingIds.has(String(row["id"])))
|
|
1914
|
+
merged.push(row);
|
|
1915
|
+
}
|
|
1916
|
+
// M-ADR 0203 §4.2's detector for its own fallout: with row identity now
|
|
1917
|
+
// the DECLARED entry id, SDDs that declared different entry ids for one
|
|
1918
|
+
// real-world external system split into distinct rows — where the 2.x
|
|
1919
|
+
// name-slug ids silently merged them. Measured before this shipped: 5 old
|
|
1920
|
+
// ids deriving 2-3 conflicting new ids across one estate's SDDs. The
|
|
1921
|
+
// split must be loud, not latent: one display name held by several kept
|
|
1922
|
+
// declared identities is either one estate entity (converge the diagram
|
|
1923
|
+
// entry ids — the rename is a diagram edit, the sanctioned opt-out) or
|
|
1924
|
+
// genuinely several (rename the labels apart). Convention, not an error:
|
|
1925
|
+
// the aggregator cannot know which.
|
|
1926
|
+
const keptByName = new Map();
|
|
1927
|
+
for (const ext of keptExts) {
|
|
1928
|
+
const key = compact(String(ext["name"] ?? ""));
|
|
1929
|
+
if (key === "")
|
|
1930
|
+
continue;
|
|
1931
|
+
const ids = keptByName.get(key);
|
|
1932
|
+
if (ids)
|
|
1933
|
+
ids.push(String(ext["id"]));
|
|
1934
|
+
else
|
|
1935
|
+
keptByName.set(key, [String(ext["id"])]);
|
|
1936
|
+
}
|
|
1937
|
+
for (const [, ids] of [...keptByName.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
1938
|
+
if (ids.length < 2)
|
|
1939
|
+
continue;
|
|
1940
|
+
const sorted = [...ids].sort((a, b) => a.localeCompare(b));
|
|
1941
|
+
findings.add("convention", `c4_external_systems: one display name "${String(keptExts.find((e) => String(e["id"]) === sorted[0])?.["name"] ?? "")}" is held by ${sorted.length} declared identities [${sorted.join(", ")}] — ` +
|
|
1942
|
+
`either one estate entity split by divergent diagram entry ids (converge the entry ids) ` +
|
|
1943
|
+
`or several entities sharing a label (rename the labels apart) (M-ADR 0203 §4.2)`);
|
|
1944
|
+
}
|
|
1945
|
+
rowsByCatalogue.set("c4_external_systems", keptExts);
|
|
1946
|
+
rowsByCatalogue.set("system_external_dependency", keptDeps);
|
|
1947
|
+
rowsByCatalogue.set("system_system_dependency", merged);
|
|
1948
|
+
return {
|
|
1949
|
+
converted,
|
|
1950
|
+
edgesRewritten,
|
|
1951
|
+
tierContradictions,
|
|
1952
|
+
providerPairsKept,
|
|
1953
|
+
uncorroboratedKept,
|
|
1954
|
+
uncertainHolds,
|
|
1955
|
+
ambiguousHolds,
|
|
1956
|
+
unverified,
|
|
1957
|
+
exempted,
|
|
1958
|
+
containerConverted,
|
|
1959
|
+
containerEdgesRewritten,
|
|
1960
|
+
systemFallbackEdges,
|
|
1961
|
+
};
|
|
1962
|
+
}
|
|
1963
|
+
/**
|
|
1964
|
+
* M-ADR 0203 §4.4's cross-SDD detector-and-re-key, cut window only.
|
|
1965
|
+
*
|
|
1966
|
+
* Every FK referencing `contracts.id` (schema-driven, so a future contract
|
|
1967
|
+
* edge is covered without touching this) is scanned over the merged rows: a
|
|
1968
|
+
* value that resolves to no contract, but whose `${value}-${kind}` form
|
|
1969
|
+
* resolves via the legacy map built from the MERGED contracts rows, is a
|
|
1970
|
+
* provably tool-authored 2.x id — regenerable, not drift. The value re-keys
|
|
1971
|
+
* and the composite row id's embedded `__`-delimited component re-keys with
|
|
1972
|
+
* it. A re-keyed row whose id then collides with an existing row is the
|
|
1973
|
+
* same fact asserted under both eras — deduped, counted. An old id that was
|
|
1974
|
+
* a 2.x cross-family collision maps to several contracts: loud and left
|
|
1975
|
+
* dangling for FK validation to report (the author qualifies; never a
|
|
1976
|
+
* guess). Exported for testing.
|
|
1977
|
+
*/
|
|
1978
|
+
export function rekeyLegacyContractRefs(schema, rowsByCatalogue, findings) {
|
|
1979
|
+
const contracts = rowsByCatalogue.get("contracts") ?? [];
|
|
1980
|
+
const contractIds = new Set(contracts.map((r) => String(r["id"])));
|
|
1981
|
+
const legacyMap = buildLegacyContractIdMap(contracts);
|
|
1982
|
+
let rekeyed = 0;
|
|
1983
|
+
let deduped = 0;
|
|
1984
|
+
let ambiguous = 0;
|
|
1985
|
+
if (legacyMap.size === 0)
|
|
1986
|
+
return { rekeyed, deduped, ambiguous };
|
|
1987
|
+
for (const cat of schema.catalogues) {
|
|
1988
|
+
const fkCols = cat.foreignKeys
|
|
1989
|
+
.filter((fk) => fk.references === "contracts.id")
|
|
1990
|
+
.map((fk) => fk.column);
|
|
1991
|
+
if (fkCols.length === 0 || cat.name === "contracts")
|
|
1992
|
+
continue;
|
|
1993
|
+
const rows = rowsByCatalogue.get(cat.name) ?? [];
|
|
1994
|
+
if (rows.length === 0)
|
|
1995
|
+
continue;
|
|
1996
|
+
const perOldId = new Map();
|
|
1997
|
+
const ambiguousOld = new Map();
|
|
1998
|
+
const kept = [];
|
|
1999
|
+
const idsAfter = new Set(rows.map((r) => String(r["id"])));
|
|
2000
|
+
for (const row of rows) {
|
|
2001
|
+
let dropped = false;
|
|
2002
|
+
let current = row;
|
|
2003
|
+
for (const col of fkCols) {
|
|
2004
|
+
const value = String(current[col] ?? "");
|
|
2005
|
+
if (value === "" || contractIds.has(value))
|
|
2006
|
+
continue;
|
|
2007
|
+
const mapped = legacyMap.get(value);
|
|
2008
|
+
if (mapped === undefined)
|
|
2009
|
+
continue;
|
|
2010
|
+
if (mapped.length > 1) {
|
|
2011
|
+
ambiguousOld.set(value, mapped);
|
|
2012
|
+
continue;
|
|
2013
|
+
}
|
|
2014
|
+
const newVal = mapped[0];
|
|
2015
|
+
const oldRowId = String(current["id"] ?? "");
|
|
2016
|
+
const newRowId = oldRowId
|
|
2017
|
+
.split("__")
|
|
2018
|
+
.map((part) => (part === value ? newVal : part))
|
|
2019
|
+
.join("__");
|
|
2020
|
+
if (newRowId !== oldRowId && idsAfter.has(newRowId)) {
|
|
2021
|
+
// The same edge fact already exists under the new id (typically
|
|
2022
|
+
// the owning SDD's own re-keyed row) — one fact, two eras.
|
|
2023
|
+
deduped++;
|
|
2024
|
+
dropped = true;
|
|
2025
|
+
break;
|
|
2026
|
+
}
|
|
2027
|
+
const replaced = {
|
|
2028
|
+
...current,
|
|
2029
|
+
[col]: newVal,
|
|
2030
|
+
};
|
|
2031
|
+
if (newRowId !== oldRowId) {
|
|
2032
|
+
idsAfter.delete(oldRowId);
|
|
2033
|
+
idsAfter.add(newRowId);
|
|
2034
|
+
replaced["id"] = newRowId;
|
|
2035
|
+
}
|
|
2036
|
+
current = replaced;
|
|
2037
|
+
perOldId.set(value, (perOldId.get(value) ?? 0) + 1);
|
|
2038
|
+
rekeyed++;
|
|
2039
|
+
}
|
|
2040
|
+
if (!dropped)
|
|
2041
|
+
kept.push(current);
|
|
2042
|
+
}
|
|
2043
|
+
rowsByCatalogue.set(cat.name, kept);
|
|
2044
|
+
for (const [oldId, count] of [...perOldId.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
2045
|
+
findings.add("inferred", `${cat.name}: ${count} cross-SDD reference(s) to "${oldId}" re-keyed to "${legacyMap.get(oldId)[0]}" — ` +
|
|
2046
|
+
`2.x kindless contract id migrated per the 3.0.0 map (regenerable, not drift; M-ADR 0203 §4.4)`);
|
|
2047
|
+
}
|
|
2048
|
+
for (const [oldId, targets] of [...ambiguousOld.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
2049
|
+
ambiguous++;
|
|
2050
|
+
findings.add("convention", `${cat.name}: references to "${oldId}" map to ${targets.length} contracts at 3.0.0 [${targets.join(", ")}] — ` +
|
|
2051
|
+
`a 2.x cross-family collision; the referencing SDD must qualify with the kind-suffixed id; left dangling (M-ADR 0203 §4.4)`);
|
|
2052
|
+
}
|
|
2053
|
+
}
|
|
2054
|
+
return { rekeyed, deduped, ambiguous };
|
|
2055
|
+
}
|
|
2056
|
+
/**
|
|
2057
|
+
* Fill `platform_sdd_id` on rows the aggregator constructed after the node
|
|
2058
|
+
* loader stamped its inputs (M-ADR 0208 §4.4). The node loader stamps every
|
|
2059
|
+
* row it MERGES; rows built at this tier from those inputs — 0189 §4.4
|
|
2060
|
+
* adjudication conversions into system_system_dependency / container_dependency,
|
|
2061
|
+
* re-derived edges — set source_sdd_id explicitly and were emitted with an empty
|
|
2062
|
+
* platform stamp under FK PASS (measured: 38 of 38 system_system_dependency rows
|
|
2063
|
+
* and 2 of 208 container_dependency rows on the forward reference estate, the
|
|
2064
|
+
* other 206 stamped by the loader; 26 of 26 and 13 of 194 on the reverse estate's
|
|
2065
|
+
* platform tier). Resolution order per row: the §4.4 fill-rule platform recorded
|
|
2066
|
+
* for its source_sdd_id; else the single stamp already observed on that source's
|
|
2067
|
+
* rows anywhere in the aggregate (a source whose rows carry two different stamps
|
|
2068
|
+
* is ambiguous and is NOT guessed). Whatever remains empty is reported as
|
|
2069
|
+
* coverage — an empty stamp is unresolved, never a key value. Exported for testing.
|
|
2070
|
+
*/
|
|
2071
|
+
export function fillDerivedPlatformStamps(schema, rowsByCatalogue, platformBySource, findings) {
|
|
2072
|
+
const stamped = schema.catalogues.filter((c) => c.columns.some((col) => col.name === "platform_sdd_id") &&
|
|
2073
|
+
c.columns.some((col) => col.name === "source_sdd_id"));
|
|
2074
|
+
const observed = new Map();
|
|
2075
|
+
for (const cat of stamped) {
|
|
2076
|
+
for (const r of rowsByCatalogue.get(cat.name) ?? []) {
|
|
2077
|
+
const src = String(r["source_sdd_id"] ?? "").trim();
|
|
2078
|
+
const pf = String(r["platform_sdd_id"] ?? "").trim();
|
|
2079
|
+
if (src === "" || pf === "")
|
|
2080
|
+
continue;
|
|
2081
|
+
if (!observed.has(src))
|
|
2082
|
+
observed.set(src, new Set());
|
|
2083
|
+
observed.get(src).add(pf);
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
2086
|
+
const resolve = (src) => {
|
|
2087
|
+
const ruled = platformBySource.get(src);
|
|
2088
|
+
if (ruled !== undefined)
|
|
2089
|
+
return ruled;
|
|
2090
|
+
const seen = observed.get(src);
|
|
2091
|
+
if (seen !== undefined && seen.size === 1)
|
|
2092
|
+
return [...seen][0];
|
|
2093
|
+
return null;
|
|
2094
|
+
};
|
|
2095
|
+
let filled = 0;
|
|
2096
|
+
const unresolved = [];
|
|
2097
|
+
for (const cat of stamped) {
|
|
2098
|
+
const rows = rowsByCatalogue.get(cat.name);
|
|
2099
|
+
if (rows === undefined || rows.length === 0)
|
|
2100
|
+
continue;
|
|
2101
|
+
let catEmpty = 0;
|
|
2102
|
+
const next = rows.map((r) => {
|
|
2103
|
+
if (String(r["platform_sdd_id"] ?? "").trim() !== "")
|
|
2104
|
+
return r;
|
|
2105
|
+
const src = String(r["source_sdd_id"] ?? "").trim();
|
|
2106
|
+
const value = src === "" ? null : resolve(src);
|
|
2107
|
+
if (value === null) {
|
|
2108
|
+
catEmpty++;
|
|
2109
|
+
return r;
|
|
2110
|
+
}
|
|
2111
|
+
filled++;
|
|
2112
|
+
return { ...r, platform_sdd_id: value };
|
|
2113
|
+
});
|
|
2114
|
+
rowsByCatalogue.set(cat.name, next);
|
|
2115
|
+
if (catEmpty > 0)
|
|
2116
|
+
unresolved.push(`${cat.name} ${catEmpty}`);
|
|
2117
|
+
}
|
|
2118
|
+
if (filled > 0) {
|
|
2119
|
+
findings.add("inferred", `platform_sdd_id: ${filled} aggregator-constructed row(s) stamped from their source_sdd_id after derivation (M-ADR 0208 §4.4) — the node loader stamps only the rows it merges`);
|
|
2120
|
+
}
|
|
2121
|
+
if (unresolved.length > 0) {
|
|
2122
|
+
findings.add("coverage", `platform_sdd_id: rows still EMPTY after the final pass [${unresolved.join(", ")}] — source unknown to the fill rule, or its rows carry more than one stamp; unresolved, not a key (M-ADR 0208 §4.4)`);
|
|
2123
|
+
}
|
|
2124
|
+
}
|
|
2125
|
+
/**
|
|
2126
|
+
* M-ADR 0133 §4.2c open hygiene, shipped with M-ADR 0189 §4.4, TIER-GATED by
|
|
2127
|
+
* M-ADR 0208 §4.2 — gated, not skipped: a skipped check fails toward success.
|
|
2128
|
+
* platform tier: exactly one row with kind "platform" in the aggregate.
|
|
2129
|
+
* organisation tier: exactly one "platform" row PER DISTINCT platform_sdd_id,
|
|
2130
|
+
* and every "platform" row must carry a stamp — an empty
|
|
2131
|
+
* stamp is unresolved, never a key (0208 §4.4).
|
|
2132
|
+
* Exported for testing.
|
|
2133
|
+
*/
|
|
2134
|
+
export function checkPlatformKindUniqueness(systems, findings, tier = "platform") {
|
|
2135
|
+
const plats = systems.filter((s) => String(s["kind"]) === "platform");
|
|
2136
|
+
if (tier === "platform") {
|
|
2137
|
+
const ids = plats.map((s) => String(s["id"])).sort((a, b) => a.localeCompare(b));
|
|
2138
|
+
if (ids.length > 1) {
|
|
2139
|
+
findings.add("convention", `c4_systems: ${ids.length} rows with kind "platform" [${ids.join(", ")}] — ` +
|
|
2140
|
+
`exactly one per platform (M-ADR 0208 §4.2); this is a platform-tier aggregate`);
|
|
2141
|
+
}
|
|
2142
|
+
return;
|
|
2143
|
+
}
|
|
2144
|
+
const unstamped = plats
|
|
2145
|
+
.filter((s) => String(s["platform_sdd_id"] ?? "").trim() === "")
|
|
2146
|
+
.map((s) => String(s["id"]))
|
|
2147
|
+
.sort((a, b) => a.localeCompare(b));
|
|
2148
|
+
if (unstamped.length > 0) {
|
|
2149
|
+
findings.add("coverage", `c4_systems: ${unstamped.length} "platform" row(s) with EMPTY platform_sdd_id at organisation tier [${unstamped.join(", ")}] — unresolved, not a key (M-ADR 0208 §4.4)`);
|
|
2150
|
+
}
|
|
2151
|
+
const byPlatform = new Map();
|
|
2152
|
+
for (const s of plats) {
|
|
2153
|
+
const key = String(s["platform_sdd_id"] ?? "").trim();
|
|
2154
|
+
if (key === "")
|
|
2155
|
+
continue;
|
|
2156
|
+
const list = byPlatform.get(key) ?? [];
|
|
2157
|
+
list.push(String(s["id"]));
|
|
2158
|
+
byPlatform.set(key, list);
|
|
2159
|
+
}
|
|
2160
|
+
for (const [platform, ids] of byPlatform) {
|
|
2161
|
+
if (ids.length > 1) {
|
|
2162
|
+
findings.add("convention", `c4_systems: platform "${platform}" has ${ids.length} rows with kind "platform" [${ids.sort((a, b) => a.localeCompare(b)).join(", ")}] — exactly one per platform (M-ADR 0208 §4.2)`);
|
|
2163
|
+
}
|
|
2164
|
+
}
|
|
2165
|
+
}
|
|
2166
|
+
/**
|
|
2167
|
+
* Flag c4_systems rows that share name AND description (M-ADR 0189 §4.5.3).
|
|
2168
|
+
* PK uniqueness and FK validation cannot see this class: when a sibling's
|
|
2169
|
+
* identity is sourced from a syndicated copy of the platform L1, the id is
|
|
2170
|
+
* right and every other column is wrong. Measured at 29 of 60 systems on one
|
|
2171
|
+
* estate and 17 of 40 on another before the 0.68.0 identity-sourcing fix;
|
|
2172
|
+
* this is the aggregation-tier backstop.
|
|
2173
|
+
*
|
|
2174
|
+
* Exported for testing.
|
|
2175
|
+
*/
|
|
2176
|
+
export function checkDuplicateSystemIdentity(systems, findings) {
|
|
2177
|
+
const byIdentity = new Map();
|
|
2178
|
+
for (const s of systems) {
|
|
2179
|
+
const name = String(s["name"] ?? "");
|
|
2180
|
+
const description = String(s["description"] ?? "");
|
|
2181
|
+
if (name === "")
|
|
2182
|
+
continue;
|
|
2183
|
+
const key = JSON.stringify([name, description]);
|
|
2184
|
+
const group = byIdentity.get(key);
|
|
2185
|
+
if (group)
|
|
2186
|
+
group.ids.push(String(s["id"]));
|
|
2187
|
+
else
|
|
2188
|
+
byIdentity.set(key, { name, ids: [String(s["id"])] });
|
|
2189
|
+
}
|
|
2190
|
+
for (const group of byIdentity.values()) {
|
|
2191
|
+
if (group.ids.length < 2)
|
|
2192
|
+
continue;
|
|
2193
|
+
findings.add("convention", `c4_systems: ${group.ids.length} rows share name "${group.name}" and an ` +
|
|
2194
|
+
`identical description [${group.ids.sort((a, b) => a.localeCompare(b)).join(", ")}] — ` +
|
|
2195
|
+
`sibling identity likely sourced from a syndicated platform L1`);
|
|
2196
|
+
}
|
|
2197
|
+
}
|
|
2198
|
+
/**
|
|
2199
|
+
* Does a file exist at this absolute path? `stat` rather than `access` to match
|
|
2200
|
+
* the rest of this module's imports; a directory is deliberately NOT a hit — a
|
|
2201
|
+
* contract_path names a contract file.
|
|
2202
|
+
*/
|
|
2203
|
+
async function fileExists(absPath) {
|
|
2204
|
+
try {
|
|
2205
|
+
const st = await stat(absPath);
|
|
2206
|
+
return st.isFile();
|
|
2207
|
+
}
|
|
2208
|
+
catch {
|
|
2209
|
+
return false;
|
|
2210
|
+
}
|
|
2211
|
+
}
|
|
2212
|
+
/**
|
|
2213
|
+
* Derive `cross_sdd_platform_capability` rows by joining each consuming
|
|
2214
|
+
* SDD's `product_platform_dependencies` rows against every platform SDD's
|
|
2215
|
+
* `capability_service` rows on `target_service_id == service_id`.
|
|
2216
|
+
*
|
|
2217
|
+
* Emits one row per resolved (consuming-product, platform-capability)
|
|
2218
|
+
* pair. A consuming dependency whose `target_service_id` doesn't resolve
|
|
2219
|
+
* against any `capability_service` row is logged as an `orphan` finding
|
|
2220
|
+
* and produces no row.
|
|
2221
|
+
*
|
|
2222
|
+
* ASYNC shared-stream dependencies (M-ADR 0192) — a dependency whose
|
|
2223
|
+
* `contract_type` is in the async set (`event`/`event-stream`/`stream`) — have
|
|
2224
|
+
* no single realiser service and carry an empty `target_service_id`. The F18
|
|
2225
|
+
* synchronous realiser-join does not apply to them: instead they resolve via
|
|
2226
|
+
* the capability leg (`target_capability_id` against any `capability_service`
|
|
2227
|
+
* row for that capability) and emit the (consuming-product, platform-capability)
|
|
2228
|
+
* edge with an empty `service_id`. A capability that resolves to no
|
|
2229
|
+
* `capability_service` row ecosystem-wide is an `orphan` finding.
|
|
2230
|
+
*
|
|
2231
|
+
* Duplicate composite ids (same consuming-product → same platform-capability
|
|
2232
|
+
* → same service) are deduped silently — they would arise only when the
|
|
2233
|
+
* same service belongs to multiple capabilities on the platform side,
|
|
2234
|
+
* which is itself a platform-side data quality concern surfaced elsewhere.
|
|
2235
|
+
*
|
|
2236
|
+
* Exported for testing.
|
|
2237
|
+
*/
|
|
2238
|
+
export function deriveCrossSddPlatformCapability(productPlatformDeps, capabilityServices, findings, capabilities = []) {
|
|
2239
|
+
// The edge's PLATFORM LEG. `capability_service` has no source_sdd_id column
|
|
2240
|
+
// (schema: id, capability_id, service_id, role), so the row that knows which
|
|
2241
|
+
// platform owns a capability is `capabilities` — its platform_sdd_id
|
|
2242
|
+
// (M-ADR 0208 §4.4) or, on pre-0208 output, its source_sdd_id. From
|
|
2243
|
+
// 2.9.0 (v0.22.0) until 1.19.2 this read `match["source_sdd_id"]` off the
|
|
2244
|
+
// capability_service row and was therefore "" on every emitted row, in
|
|
2245
|
+
// every estate — the id carried a visible empty slot (`a__b____cap__svc`).
|
|
2246
|
+
const platformByCapability = new Map();
|
|
2247
|
+
for (const cap of capabilities) {
|
|
2248
|
+
const id = String(cap["id"] ?? "").trim();
|
|
2249
|
+
if (id === "")
|
|
2250
|
+
continue;
|
|
2251
|
+
const leg = String(cap["platform_sdd_id"] ?? "").trim() ||
|
|
2252
|
+
String(cap["source_sdd_id"] ?? "").trim();
|
|
2253
|
+
if (leg !== "" && !platformByCapability.has(id))
|
|
2254
|
+
platformByCapability.set(id, leg);
|
|
2255
|
+
}
|
|
2256
|
+
const unresolvedLegs = new Set();
|
|
2257
|
+
const resolvePlatformLeg = (match) => {
|
|
2258
|
+
const capId = String(match["capability_id"] ?? "");
|
|
2259
|
+
const leg = platformByCapability.get(capId) ?? String(match["source_sdd_id"] ?? "").trim();
|
|
2260
|
+
if (leg === "" && capId !== "")
|
|
2261
|
+
unresolvedLegs.add(capId);
|
|
2262
|
+
return leg;
|
|
2263
|
+
};
|
|
2264
|
+
// Index capability_service rows by service_id for O(1) lookup. A service
|
|
2265
|
+
// may belong to multiple capabilities — keep them all so a dependency
|
|
2266
|
+
// can resolve into every capability that owns its service. Filter out
|
|
2267
|
+
// rows whose service_id OR capability_id is empty up front, so that
|
|
2268
|
+
// the "no valid join" orphan check below fires when only-malformed
|
|
2269
|
+
// matches exist.
|
|
2270
|
+
const byServiceId = new Map();
|
|
2271
|
+
// Also index by capability_id, for the async capability-leg resolution
|
|
2272
|
+
// (M-ADR 0192) — an async dependency has no service to join on.
|
|
2273
|
+
const byCapabilityId = new Map();
|
|
2274
|
+
for (const cs of capabilityServices) {
|
|
2275
|
+
const svc = String(cs["service_id"] ?? "");
|
|
2276
|
+
const cap = String(cs["capability_id"] ?? "");
|
|
2277
|
+
if (svc === "" || cap === "")
|
|
2278
|
+
continue;
|
|
2279
|
+
const svcBucket = byServiceId.get(svc);
|
|
2280
|
+
if (svcBucket === undefined) {
|
|
2281
|
+
byServiceId.set(svc, [cs]);
|
|
2282
|
+
}
|
|
2283
|
+
else {
|
|
2284
|
+
svcBucket.push(cs);
|
|
2285
|
+
}
|
|
2286
|
+
const capBucket = byCapabilityId.get(cap);
|
|
2287
|
+
if (capBucket === undefined) {
|
|
2288
|
+
byCapabilityId.set(cap, [cs]);
|
|
2289
|
+
}
|
|
2290
|
+
else {
|
|
2291
|
+
capBucket.push(cs);
|
|
2292
|
+
}
|
|
2293
|
+
}
|
|
2294
|
+
const rows = [];
|
|
2295
|
+
const seenIds = new Set();
|
|
2296
|
+
for (const ppd of productPlatformDeps) {
|
|
2297
|
+
// M-ADR 0204: an off-platform row has no realiser to join — its FK columns
|
|
2298
|
+
// are empty by construction, and its validation is §4.2 corroboration, not
|
|
2299
|
+
// this derivation. Explicit skip rather than falling through to the empty-
|
|
2300
|
+
// service branches, so the reason is structural and not incidental.
|
|
2301
|
+
const locus = String(ppd["target_locus"] ?? "").trim().toLowerCase();
|
|
2302
|
+
if (locus === "external" || locus === "legacy")
|
|
2303
|
+
continue;
|
|
2304
|
+
const consumingSddId = String(ppd["source_sdd_id"] ?? "");
|
|
2305
|
+
const consumingProductId = String(ppd["product_id"] ?? "");
|
|
2306
|
+
const targetServiceId = String(ppd["target_service_id"] ?? "");
|
|
2307
|
+
const targetCapabilityId = String(ppd["target_capability_id"] ?? "");
|
|
2308
|
+
const contractType = String(ppd["contract_type"] ?? "");
|
|
2309
|
+
if (consumingProductId === "")
|
|
2310
|
+
continue;
|
|
2311
|
+
// M-ADR 0192: an async shared-stream dependency has no realiser service.
|
|
2312
|
+
// The F18 pair-join cannot apply — resolve via the capability leg and emit
|
|
2313
|
+
// the (consuming-product, platform-capability) edge with an empty service.
|
|
2314
|
+
if (isAsyncContractType(contractType)) {
|
|
2315
|
+
if (targetCapabilityId === "")
|
|
2316
|
+
continue;
|
|
2317
|
+
const capMatches = byCapabilityId.get(targetCapabilityId);
|
|
2318
|
+
if (capMatches === undefined || capMatches.length === 0) {
|
|
2319
|
+
findings.add("orphan", `cross_sdd_platform_capability: ${consumingSddId}/${consumingProductId} declares an async dependency on target_capability_id="${targetCapabilityId}" (contract_type="${contractType}") but no capability_service row realises it ecosystem-wide`);
|
|
2320
|
+
continue;
|
|
2321
|
+
}
|
|
2322
|
+
for (const match of capMatches) {
|
|
2323
|
+
const platformSddId = resolvePlatformLeg(match);
|
|
2324
|
+
const id = [
|
|
2325
|
+
consumingSddId,
|
|
2326
|
+
consumingProductId,
|
|
2327
|
+
platformSddId,
|
|
2328
|
+
targetCapabilityId,
|
|
2329
|
+
"",
|
|
2330
|
+
].join("__");
|
|
2331
|
+
if (seenIds.has(id))
|
|
2332
|
+
continue;
|
|
2333
|
+
seenIds.add(id);
|
|
2334
|
+
rows.push({
|
|
2335
|
+
id,
|
|
2336
|
+
consuming_sdd_id: consumingSddId,
|
|
2337
|
+
consuming_product_id: consumingProductId,
|
|
2338
|
+
platform_sdd_id: platformSddId,
|
|
2339
|
+
platform_capability_id: targetCapabilityId,
|
|
2340
|
+
service_id: "",
|
|
2341
|
+
});
|
|
2342
|
+
}
|
|
2343
|
+
continue;
|
|
2344
|
+
}
|
|
2345
|
+
// SYNCHRONOUS dependency — the realiser-join path. A row with no service is
|
|
2346
|
+
// a single-sided / malformed entry the builder would not emit; skip it.
|
|
2347
|
+
if (targetServiceId === "")
|
|
2348
|
+
continue;
|
|
2349
|
+
const matches = byServiceId.get(targetServiceId);
|
|
2350
|
+
if (matches === undefined || matches.length === 0) {
|
|
2351
|
+
findings.add("orphan", `cross_sdd_platform_capability: ${consumingSddId}/${consumingProductId} declares target_service_id="${targetServiceId}" but no capability_service row resolves it ecosystem-wide`);
|
|
2352
|
+
continue;
|
|
2353
|
+
}
|
|
2354
|
+
// F18: validate the declared PAIR, not each column alone. Both columns
|
|
2355
|
+
// resolving independently while no capability_service row binds them
|
|
2356
|
+
// is a false pairing accepted in silence — the evidence table for the
|
|
2357
|
+
// pair exists and was never consulted.
|
|
2358
|
+
const declaredCapability = targetCapabilityId;
|
|
2359
|
+
if (declaredCapability !== "") {
|
|
2360
|
+
const pairBound = matches.some((m) => String(m["capability_id"]) === declaredCapability);
|
|
2361
|
+
if (!pairBound) {
|
|
2362
|
+
const actual = [...new Set(matches.map((m) => String(m["capability_id"])))]
|
|
2363
|
+
.sort((a, b) => a.localeCompare(b))
|
|
2364
|
+
.join(", ");
|
|
2365
|
+
findings.add("divergence", `product_platform_dependencies: ${consumingSddId}/${consumingProductId} declares the pair ` +
|
|
2366
|
+
`(target_capability_id="${declaredCapability}", target_service_id="${targetServiceId}") but no ` +
|
|
2367
|
+
`capability_service row binds them — the service resolves to [${actual}] (F18)`);
|
|
2368
|
+
}
|
|
2369
|
+
}
|
|
2370
|
+
for (const match of matches) {
|
|
2371
|
+
const platformSddId = resolvePlatformLeg(match);
|
|
2372
|
+
const platformCapabilityId = String(match["capability_id"] ?? "");
|
|
2373
|
+
if (platformCapabilityId === "")
|
|
2374
|
+
continue;
|
|
2375
|
+
const id = [
|
|
2376
|
+
consumingSddId,
|
|
2377
|
+
consumingProductId,
|
|
2378
|
+
platformSddId,
|
|
2379
|
+
platformCapabilityId,
|
|
2380
|
+
targetServiceId,
|
|
2381
|
+
].join("__");
|
|
2382
|
+
if (seenIds.has(id))
|
|
2383
|
+
continue;
|
|
2384
|
+
seenIds.add(id);
|
|
2385
|
+
rows.push({
|
|
2386
|
+
id,
|
|
2387
|
+
consuming_sdd_id: consumingSddId,
|
|
2388
|
+
consuming_product_id: consumingProductId,
|
|
2389
|
+
platform_sdd_id: platformSddId,
|
|
2390
|
+
platform_capability_id: platformCapabilityId,
|
|
2391
|
+
service_id: targetServiceId,
|
|
2392
|
+
});
|
|
2393
|
+
}
|
|
2394
|
+
}
|
|
2395
|
+
if (unresolvedLegs.size > 0) {
|
|
2396
|
+
const caps = [...unresolvedLegs].sort((a, b) => a.localeCompare(b));
|
|
2397
|
+
findings.add("coverage", `cross_sdd_platform_capability: platform leg (platform_sdd_id) unresolved for ${caps.length} capability id(s) [${caps.join(", ")}] — no capabilities row carries platform_sdd_id or source_sdd_id for them; the edge id has an empty slot`);
|
|
2398
|
+
}
|
|
2399
|
+
return rows;
|
|
2400
|
+
}
|
|
2401
|
+
// Used by tests that want to round-trip CSV through the aggregator's
|
|
2402
|
+
// stamping logic without writing files.
|
|
2403
|
+
export const __test_only__ = { stampColumn, parseCatalogueCsv };
|
|
2404
|
+
//# sourceMappingURL=aggregate.js.map
|