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