@sdd-method/author-cli 0.12.1 → 0.13.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.
@@ -44,6 +44,8 @@ import { deriveAdrGoverns } from "./builders/adr-governs.js";
44
44
  import { buildContractRevisions } from "./builders/contract-revisions.js";
45
45
  import { buildJourneyRequirement } from "./builders/journey-requirement.js";
46
46
  import { buildDomains, buildProducts, buildApplicationDataPlaneBindings, buildApplicationDataPlaneDeviations, buildApplicationConformanceCoverage, buildImplementations, buildRepositories, buildServices, buildCapabilities, buildCapabilityService, buildCapabilityAdr, buildCapabilityFeature, buildCapabilityContract, buildAdrs, buildAdrSupersedes, buildFeatures, buildFeatureGroups, buildFeatureGrounding, buildCapabilitySpec, buildContracts, buildDeclaredExternalContracts, buildRepoEdges, deriveFeatureRepoFromServices, buildRepoCapabilityExemption, buildServiceCapabilityExemption, buildEvents, buildServiceEvent, buildCapabilityEvent, buildFeatureEvent, buildServiceContract, buildServiceContractConsumes, buildFeatureContract, buildDomainModels, buildCapabilityDomainModel, buildFeatureDomainModel, buildFeatureSubscriptionChannels, buildProductPlatformDependencies, buildGlossaryItems, backfillGlossaryRelations, buildGlossaryXref, buildC4Systems, buildC4Containers, buildC4Components, buildC4ExternalSystems, buildExternalSystemExemption, buildContainerDependency, reportEntryIdReferentCollisions, buildSystemExternalDependency, buildPersonaC4System, buildPersonaContainer, buildPersonas, buildQualityScores, buildScaffolds, buildScreens, buildUserJourneys, buildPersonaJourney, buildPersonaFeature, buildJourneyFeature, buildProductEdges, buildIntegrations, entitySlug, buildEntitiesFromManifest, buildEntitiesFromMermaid, buildServiceEntityFromTables, enrichEntityPersistenceKind, buildC4ContainerEntity, buildC4SystemEntity, } from "./builders/index.js";
47
+ import { mergeEntities } from "./builders/entity-merge.js";
48
+ import { loadAliasMap } from "./builders/service-entity-tables.js";
47
49
  import { buildIntegrationComponents } from "./builders/integration-components.js";
48
50
  import { loadReverseProductQuality } from "./reverse-product-quality.js";
49
51
  import { loadTraceabilityManifests } from "./traceability-loader.js";
@@ -895,8 +897,8 @@ async function collectRows(schema, manifest, findings, sddPath, snapshotStore) {
895
897
  findings,
896
898
  knownServiceIds,
897
899
  aliases: serviceContractAliases,
898
- // M-ADR 0203 §4.4 cut window: alias targets authored as 2.x kindless
899
- // contract ids re-key against this build's kind-suffixed rows.
900
+ // M-ADR 0203 §4.4 cut window: alias targets authored as 2.x kindless contract ids, or with a pre-7.0.0
901
+ // unsplit stem, re-key against this build's rows.
900
902
  legacyContractIdMap: buildLegacyContractIdMap(out.get("contracts") ?? []),
901
903
  });
902
904
  out.set("service_contract", [...exposesRows, ...consumesRows]);
@@ -1196,7 +1198,7 @@ async function collectRows(schema, manifest, findings, sddPath, snapshotStore) {
1196
1198
  out.set("adrs", deriveAdrGoverns(out.get("adrs") ?? [], out.get("adr_supersedes") ?? [], r.changes, findings));
1197
1199
  // M-ADR 0230 §4.2 (Proposed): each contract's revisions by digest, active beside agreed.
1198
1200
  out.set("contract_revisions", await buildContractRevisions({ sddPath, findings, contracts: out.get("contracts") ?? [], changes: r.changes }));
1199
- // M-ADR 0229 §4.4 (Proposed): the requirements each journey cites, from its tagged end-to-end files or its
1201
+ // M-ADR 0229 §4.4: the requirements each journey cites, from its tagged end-to-end files or its
1200
1202
  // journey feature's living spec.
1201
1203
  out.set("journey_requirement", await buildJourneyRequirement({
1202
1204
  sddPath,
@@ -1229,6 +1231,8 @@ async function collectRows(schema, manifest, findings, sddPath, snapshotStore) {
1229
1231
  findings,
1230
1232
  knownSystemIds,
1231
1233
  knownServiceIds,
1234
+ // A malformed alias file is reported by the service_entity walker; not twice.
1235
+ serviceIdByDisplayLower: (await loadAliasMap(sddPath, new FindingsLog())).byDisplayLower,
1232
1236
  knownDomainIds,
1233
1237
  containerIdByEntryIdOut: containerIdByEntryId,
1234
1238
  containerNameByEntryIdOut: containerNameByEntryId,
@@ -1343,12 +1347,14 @@ async function collectRows(schema, manifest, findings, sddPath, snapshotStore) {
1343
1347
  // c4_containers.service_id.
1344
1348
  // - c4-system-entity: aggregation roll-up over c4_container_entity ⋈
1345
1349
  // c4_containers.system_id with container_count.
1350
+ const platformWideEntityIds = new Set();
1346
1351
  const entitiesFromManifest = await buildEntitiesFromManifest({
1347
1352
  sddPath,
1348
1353
  findings,
1349
1354
  profile: manifest.profile,
1350
1355
  knownDomainIds,
1351
1356
  knownGlossaryIds,
1357
+ platformWideIdsOut: platformWideEntityIds,
1352
1358
  });
1353
1359
  const entitiesFromMermaid = await buildEntitiesFromMermaid({
1354
1360
  sddPath,
@@ -1361,13 +1367,8 @@ async function collectRows(schema, manifest, findings, sddPath, snapshotStore) {
1361
1367
  knownGlossaryIds,
1362
1368
  });
1363
1369
  // Forward wins on attribute conflicts (matches per-adopter authoring intent
1364
- // per M-ADR 0159 §4.4). Reverse rows seed the map; forward overwrites.
1365
- const entitiesById = new Map();
1366
- for (const r of entitiesFromMermaid)
1367
- entitiesById.set(String(r["id"]), r);
1368
- for (const r of entitiesFromManifest)
1369
- entitiesById.set(String(r["id"]), r);
1370
- const entityRows = [...entitiesById.values()];
1370
+ // per M-ADR 0159 §4.4), field by field; a platform-wide entry naming one domain entity is that entity.
1371
+ const entityRows = mergeEntities(entitiesFromMermaid, entitiesFromManifest, findings, platformWideEntityIds);
1371
1372
  out.set("entities", entityRows);
1372
1373
  // Per M-ADR 0159 §4.9: now that entities.csv exists, append entity_glossary
1373
1374
  // rows for the reverse-method side. The forward-method emission via
@@ -1391,7 +1392,9 @@ async function collectRows(schema, manifest, findings, sddPath, snapshotStore) {
1391
1392
  domain_id: domainId,
1392
1393
  entity_name: name,
1393
1394
  glossary_item_id: glossaryId,
1394
- status: "matched_direct",
1395
+ // A manifest entry folded into this entity keeps the alignment it was authored with; a data-model slug match
1396
+ // is direct.
1397
+ status: /(?:^|;)status=aligned(?:;|$)/.test(String(e["notes"] ?? "")) ? "aligned" : "matched_direct",
1395
1398
  source_sdd_id: "",
1396
1399
  });
1397
1400
  }
@@ -11,7 +11,8 @@
11
11
  // - domain_id (nullable, denormalised) derived from the L2 file path:
12
12
  // docs/domains/<slug>/... → <slug>; platform-wide L2 → empty.
13
13
  // - service_id (optional) — opportunistic match on the C4 entry's id
14
- // or a slug of its display name to a known service id.
14
+ // or a slug of its display name to a known service id, then on its
15
+ // display name through service-name-aliases.yaml.
15
16
  //
16
17
  // We only consider entries inside System_Boundary or Container_Boundary
17
18
  // blocks — mirrors the python reference and excludes Person/System_Ext
@@ -90,11 +91,14 @@ export async function buildC4Containers(inputs) {
90
91
  continue;
91
92
  }
92
93
  seen.add(id);
94
+ const aliased = inputs.serviceIdByDisplayLower?.get(entry.name.toLowerCase()) ?? "";
93
95
  const serviceId = inputs.knownServiceIds.has(entry.entryId)
94
96
  ? entry.entryId
95
97
  : inputs.knownServiceIds.has(slug)
96
98
  ? slug
97
- : "";
99
+ : inputs.knownServiceIds.has(aliased)
100
+ ? aliased
101
+ : "";
98
102
  const domainId = file.domainId !== "" && inputs.knownDomainIds.has(file.domainId)
99
103
  ? file.domainId
100
104
  : "";
@@ -18,7 +18,7 @@
18
18
  import { readdir, readFile } from "node:fs/promises";
19
19
  import { extname, join, relative, sep } from "node:path";
20
20
  import { parse as parseYaml } from "yaml";
21
- import { contractIdWithKind } from "../contract-id.js";
21
+ import { contractIdWithKind, contractKindlessSlug } from "../contract-id.js";
22
22
  const CONTRACTS_DIR_REL = "contracts";
23
23
  const DOCS_DOMAINS_DIR_REL = join("docs", "domains");
24
24
  const KIND_SPECS = [
@@ -117,8 +117,8 @@ export async function buildContracts(inputs) {
117
117
  // this format exists to end. A remaining collision is same-kind and
118
118
  // still reported.
119
119
  const kindless = entry.domain !== ""
120
- ? slugify(`${entry.domain}-${stem}`)
121
- : slugify(stem);
120
+ ? contractKindlessSlug(entry.domain, stem)
121
+ : contractKindlessSlug("", stem);
122
122
  const slug = contractIdWithKind(kindless, entry.kind);
123
123
  if (seen.has(slug)) {
124
124
  inputs.findings.add("divergence", `${entry.relPath}: contract id "${slug}" already emitted by an earlier file`);
@@ -311,13 +311,6 @@ function domainSegmentCanonical(relPath) {
311
311
  return "";
312
312
  return parts[2] ?? "";
313
313
  }
314
- function slugify(s) {
315
- return s
316
- .toLowerCase()
317
- .replace(/^_+/, "") // turn _common into common
318
- .replace(/[^a-z0-9]+/g, "-")
319
- .replace(/^-+|-+$/g, "");
320
- }
321
314
  const DECLARED_KINDS = {
322
315
  openapi: "rest",
323
316
  proto: "rpc",
@@ -366,7 +359,7 @@ export function buildDeclaredExternalContracts(inputs) {
366
359
  if (inputs.discoveredIdByPath.has(declared) || localBasenames.has(base))
367
360
  continue;
368
361
  const stem = stemOf(base);
369
- const kindless = m.domain !== "" ? slugify(`${m.domain}-${stem}`) : slugify(stem);
362
+ const kindless = contractKindlessSlug(m.domain, stem);
370
363
  const slug = contractIdWithKind(kindless, kind);
371
364
  if (seenDeclaredPaths.has(declared)) {
372
365
  // Same path, same contract: legitimate cross-capability sharing.
@@ -24,8 +24,8 @@
24
24
  // - For each entities[] item with non-empty name, emit a row.
25
25
  // - Within-model dedup by id; if attributes diverge, fire [finding] divergence
26
26
  // on first re-encounter.
27
- // - `status: superseded` entries carry extra `superseded_by` + `note` keys.
28
- // Ingested as ordinary rows; superseded_by preserved in notes for future
27
+ // - `status: superseded` entries emit no entity: a superseded term is history, and glossary_xref carries
28
+ // its supersedes edge (schema 7.0.0). Formerly ingested as ordinary rows; superseded_by preserved in notes for future
29
29
  // Phase 5 promotion to an entity_supersedes edge.
30
30
  // - `glossary_term` resolved best-effort against glossary_items (exact term
31
31
  // then entitySlug equality); leave empty and fire orphan on miss.
@@ -34,8 +34,8 @@ import { join } from "node:path";
34
34
  import { parse as parseYaml } from "yaml";
35
35
  import { entitySlug } from "./entity-slug.js";
36
36
  const MANIFEST_REL_PATH = join("docs", "platform", "reference", "glossary-alignment-manifest.yaml");
37
- const NON_CANONICAL_DOMAINS = new Set(["platform-wide", "", "platform_wide"]);
38
- const PLATFORM_WIDE_PK_PREFIX = "_platform-wide";
37
+ export const NON_CANONICAL_DOMAINS = new Set(["platform-wide", "", "platform_wide"]);
38
+ export const PLATFORM_WIDE_PK_PREFIX = "_platform-wide";
39
39
  export async function buildEntitiesFromManifest(inputs) {
40
40
  // Profile gate per M-ADR 0159 §4.2 amendment: only platform-profile SDDs
41
41
  // read the manifest. Sibling profiles (application-product, integration-
@@ -93,6 +93,10 @@ export async function buildEntitiesFromManifest(inputs) {
93
93
  inputs.findings.add("convention", `${MANIFEST_REL_PATH}: model ${model.path ?? "(unknown)"} has an entities[] entry with empty name — skipped`);
94
94
  continue;
95
95
  }
96
+ // A superseded entry is history, not an element: the glossary keeps its supersedes edge (glossary_xref), and
97
+ // the entity it names is its successor's.
98
+ if (String(entity.status ?? "").trim() === "superseded")
99
+ continue; // as glossary_xref reads it
96
100
  const slug = entitySlug(name);
97
101
  const id = domainId
98
102
  ? `${domainId}-${slug}`
@@ -128,6 +132,8 @@ export async function buildEntitiesFromManifest(inputs) {
128
132
  }
129
133
  byId.set(id, row);
130
134
  rows.push(row);
135
+ if (NON_CANONICAL_DOMAINS.has(rawDomain))
136
+ inputs.platformWideIdsOut?.add(id);
131
137
  }
132
138
  }
133
139
  return rows;
@@ -0,0 +1,90 @@
1
+ // entities: the data-model walker's rows merged with the glossary-alignment manifest's (M-ADR 0159 §4.4).
2
+ //
3
+ // Field by field: a manifest value wins where it is non-empty, and the data model's value stands where the
4
+ // manifest is silent, so source_domain_model_id and the columns= note survive a manifest entry naming the
5
+ // same entity. Notes from both are kept.
6
+ //
7
+ // A platform-wide manifest entry (`_platform-wide-<slug>`, M-ADR 0159 §4.2) whose slug names exactly one
8
+ // domain entity (`<domain>-<slug>`) IS that entity: it fills the domain entity's empty fields and emits no row
9
+ // of its own, so one entity is one element. Its glossary term moves with it, and the entity_glossary step
10
+ // derives the edge from the domain entity. An entry matching no domain entity, or several, stays platform-wide
11
+ // and is reported.
12
+ import { PLATFORM_WIDE_PK_PREFIX } from "./entities-manifest.js";
13
+ const MANIFEST_REL_PATH = "glossary-alignment-manifest.yaml";
14
+ function filled(v) {
15
+ return v !== undefined && v !== null && String(v) !== "";
16
+ }
17
+ function joinNotes(a, b) {
18
+ const parts = [];
19
+ for (const v of [a, b]) {
20
+ for (const p of String(v ?? "").split(";"))
21
+ if (p !== "" && !parts.includes(p))
22
+ parts.push(p);
23
+ }
24
+ return parts.join(";");
25
+ }
26
+ /** `over`'s non-empty values on top of `base`; notes from both. */
27
+ function overlay(base, over) {
28
+ const out = { ...base };
29
+ for (const [k, v] of Object.entries(over)) {
30
+ if (k === "notes")
31
+ out[k] = joinNotes(base[k], v);
32
+ else if (filled(v))
33
+ out[k] = v;
34
+ }
35
+ return out;
36
+ }
37
+ /** `extra`'s values only where `base` is empty; notes from both. */
38
+ function fillEmpty(base, extra) {
39
+ const out = { ...base };
40
+ for (const [k, v] of Object.entries(extra)) {
41
+ if (k === "id" || k === "name" || k === "domain_id")
42
+ continue;
43
+ if (k === "notes")
44
+ out[k] = joinNotes(base[k], v);
45
+ else if (!filled(base[k]) && filled(v))
46
+ out[k] = v;
47
+ }
48
+ return out;
49
+ }
50
+ export function mergeEntities(fromDataModels, fromManifest, findings,
51
+ /** The sentinel ids from a platform-wide domain hint (entities-manifest's out-set): the only ones that fold. */
52
+ platformWideIds) {
53
+ const byId = new Map();
54
+ for (const r of fromDataModels)
55
+ byId.set(String(r["id"]), r);
56
+ for (const r of fromManifest) {
57
+ const id = String(r["id"]);
58
+ const existing = byId.get(id);
59
+ byId.set(id, existing ? overlay(existing, r) : r);
60
+ }
61
+ const prefix = `${PLATFORM_WIDE_PK_PREFIX}-`;
62
+ for (const [id, row] of [...byId]) {
63
+ if (!id.startsWith(prefix) || !platformWideIds.has(id))
64
+ continue;
65
+ const slug = id.slice(prefix.length);
66
+ const matches = [...byId.values()].filter((e) => {
67
+ const dom = String(e["domain_id"] ?? "");
68
+ return dom !== "" && String(e["id"]) === `${dom}-${slug}`;
69
+ });
70
+ if (matches.length === 1) {
71
+ const target = matches[0];
72
+ const own = String(target["glossary_term"] ?? "");
73
+ const theirs = String(row["glossary_term"] ?? "");
74
+ if (own !== "" && theirs !== "" && own !== theirs) {
75
+ findings.add("divergence", `${MANIFEST_REL_PATH}: platform-wide entity ${row["name"]} names glossary term ${theirs}, but ${target["id"]} ` +
76
+ `already names ${own} — kept ${own}`);
77
+ }
78
+ byId.set(String(target["id"]), fillEmpty(target, row));
79
+ byId.delete(id);
80
+ }
81
+ else if (matches.length === 0) {
82
+ findings.add("coverage", `${MANIFEST_REL_PATH}: platform-wide entity ${row["name"]} names no domain entity — kept as ${id}`);
83
+ }
84
+ else {
85
+ findings.add("divergence", `${MANIFEST_REL_PATH}: platform-wide entity ${row["name"]} names ${matches.length} domain entities (${matches.map((m) => m["id"]).join(", ")}) — kept as ${id}; give it a domain`);
86
+ }
87
+ }
88
+ return [...byId.values()];
89
+ }
90
+ //# sourceMappingURL=entity-merge.js.map
@@ -158,7 +158,7 @@ export function featureStatus(text) {
158
158
  }
159
159
  /**
160
160
  * A feature spec's kind (schema 4.4.0): the value of its `**Kind:**` line, lower-cased, else `feature`. An
161
- * obligation (M-ADR 0226, Proposed) and a journey feature (M-ADR 0229 §4.2, Proposed) say so; any other value is written as given, so
161
+ * obligation (M-ADR 0226, Proposed) and a journey feature (M-ADR 0229 §4.2) say so; any other value is written as given, so
162
162
  * the catalogue's vocabulary gate names the spec rather than a default hiding the mistake.
163
163
  */
164
164
  export function featureKind(text) {
@@ -27,8 +27,9 @@
27
27
  // 4. Forward-method supersedes (new).
28
28
  // models[].entities[] with status: superseded + superseded_by
29
29
  // One row per supersedes mapping after resolving both endpoints'
30
- // glossary_term against glossary_items.csv. Orphan finding when an
31
- // endpoint doesn't resolve.
30
+ // glossary_term against glossary_items.csv: the successor (superseded_by)
31
+ // is the source and supersedes the superseded term, the target. Orphan
32
+ // finding when an endpoint doesn't resolve.
32
33
  //
33
34
  // 5. Forward-method entity-mapping (new, writes to entity_glossary).
34
35
  // models[].entities[] with status ∈ {aligned, matched_direct} and
@@ -40,6 +41,7 @@
40
41
  import { readFile } from "node:fs/promises";
41
42
  import { join } from "node:path";
42
43
  import { parse as parseYaml } from "yaml";
44
+ import { NON_CANONICAL_DOMAINS } from "./entities-manifest.js";
43
45
  const MANIFEST_REL_PATH = join("docs", "platform", "reference", "glossary-alignment-manifest.yaml");
44
46
  export async function buildGlossaryXref(inputs) {
45
47
  // Profile gate per M-ADR 0158 §amendment: the manifest is platform-owned;
@@ -259,14 +261,18 @@ export async function buildGlossaryXref(inputs) {
259
261
  inputs.findings.add("orphan", `${MANIFEST_REL_PATH}: supersedes edge can't emit — target "${supersededBy}" (→ glossary "${targetEntity?.glossaryTerm ?? "?"}") doesn't resolve to a glossary item`);
260
262
  continue;
261
263
  }
262
- emitXref(sourceId, targetId, "supersedes");
264
+ // The successor supersedes the superseded term: `source supersedes target`, as adr_supersedes reads.
265
+ emitXref(targetId, sourceId, "supersedes");
263
266
  continue;
264
267
  }
265
268
  if ((status === "aligned" || status === "matched_direct") && glossaryTerm !== "") {
266
269
  // Entity-mapping walker writes to entity_glossary.csv.
267
270
  if (domain === "" || !inputs.knownDomainIds.has(domain)) {
268
- // Domain not in this SDD's domains.csv — likely "platform-wide"
269
- // or another scope identifier. Skip with a coverage finding.
271
+ // A platform-wide entry is the domain entity its slug names (entity-merge.ts), and the entity_glossary
272
+ // edge is derived from that entity after the merge; the merge reports one it cannot place. Any other
273
+ // domain not in this SDD's domains.csv is skipped with a coverage finding.
274
+ if (NON_CANONICAL_DOMAINS.has(domain))
275
+ continue;
270
276
  inputs.findings.add("coverage", `${MANIFEST_REL_PATH}: entity ${name} domain "${domain}" not in known domains — entity_glossary row skipped`);
271
277
  continue;
272
278
  }
@@ -1,4 +1,4 @@
1
- // journey_requirement: the requirements a user journey cites for its steps (M-ADR 0229 §4.4, Proposed; schema 4.4.0).
1
+ // journey_requirement: the requirements a user journey cites for its steps (M-ADR 0229 §4.4; schema 4.4.0).
2
2
  //
3
3
  // Derived, never authored, from either master (§4.2):
4
4
  // tag option A: the end-to-end `.feature` files under the manifest's `journeys.test_paths`. A file tagged
@@ -64,13 +64,13 @@ export async function buildJourneyRequirement(inputs) {
64
64
  for (const m of text.matchAll(REQ_TAG)) {
65
65
  const quiet = atAggregate && inputs.reportedFeatureIds.has(m[1]);
66
66
  if (!knownFeatures.has(m[1])) {
67
- findings.add("external", `${where}: @req:${m[1]}/${m[2]} names a feature this catalogue does not hold; resolves at aggregation (M-ADR 0229 §4.4, Proposed)`);
67
+ findings.add("external", `${where}: @req:${m[1]}/${m[2]} names a feature this catalogue does not hold; resolves at aggregation (M-ADR 0229 §4.4)`);
68
68
  continue;
69
69
  }
70
70
  const ids = bySlug.get(`${m[1]}/${m[2]}`) ?? [];
71
71
  if (ids.length !== 1) {
72
72
  if (!quiet)
73
- findings.add("orphan", `${where}: @req:${m[1]}/${m[2]} ${ids.length === 0 ? "names no requirement of that feature" : `matches ${ids.length} requirements of that feature`}; no journey_requirement row (M-ADR 0229 §4.4, Proposed)`);
73
+ findings.add("orphan", `${where}: @req:${m[1]}/${m[2]} ${ids.length === 0 ? "names no requirement of that feature" : `matches ${ids.length} requirements of that feature`}; no journey_requirement row (M-ADR 0229 §4.4)`);
74
74
  continue;
75
75
  }
76
76
  const id = `${journeyId}__${ids[0]}`;
@@ -95,7 +95,7 @@ export async function buildJourneyRequirement(inputs) {
95
95
  continue;
96
96
  if (journeys.length > 1) {
97
97
  if (!atAggregate)
98
- findings.add("divergence", `${where}: tagged with ${journeys.length} journeys (${journeys.join(", ")}); one file is one journey's master (M-ADR 0229 §4.2, Proposed)`);
98
+ findings.add("divergence", `${where}: tagged with ${journeys.length} journeys (${journeys.join(", ")}); one file is one journey's master (M-ADR 0229 §4.2)`);
99
99
  continue;
100
100
  }
101
101
  const journeyId = journeys[0];
@@ -129,7 +129,7 @@ export async function buildJourneyRequirement(inputs) {
129
129
  const journeys = [...new Set([...spec.matchAll(JOURNEY_TAG)].map((m) => m[1]))];
130
130
  if (journeys.length !== 1) {
131
131
  if (!atAggregate)
132
- findings.add(journeys.length === 0 ? "orphan" : "divergence", `${where}: a journey feature's living spec ${journeys.length === 0 ? "names no journey (@journey:<journey-id>)" : `names ${journeys.length} journeys`}; one journey feature masters one journey (M-ADR 0229 §4.2, Proposed)`);
132
+ findings.add(journeys.length === 0 ? "orphan" : "divergence", `${where}: a journey feature's living spec ${journeys.length === 0 ? "names no journey (@journey:<journey-id>)" : `names ${journeys.length} journeys`}; one journey feature masters one journey (M-ADR 0229 §4.2)`);
133
133
  continue;
134
134
  }
135
135
  const journeyId = journeys[0];
@@ -59,13 +59,13 @@ export async function buildServiceContractConsumes(inputs) {
59
59
  const mapped = inputs.legacyContractIdMap?.get(contractId);
60
60
  if (mapped !== undefined && mapped.length === 1) {
61
61
  inputs.findings.add("inferred", `service_contract: alias target "${contractId}" (${serviceId} ← "${iface.interfaceText}") re-keyed to ` +
62
- `"${mapped[0]}" — 2.x kindless contract id migrated per the 3.0.0 map (regenerable, not drift); ` +
62
+ `"${mapped[0]}" — an earlier-form contract id (2.x kindless, or a pre-7.0.0 unsplit stem) migrated per the contract-id map (regenerable, not drift); ` +
63
63
  `update .sdd/service-contract-aliases.yaml to retire this finding`);
64
64
  contractId = mapped[0];
65
65
  }
66
66
  else if (mapped !== undefined && mapped.length > 1) {
67
67
  inputs.findings.add("convention", `service_contract: alias target "${contractId}" (${serviceId} ← "${iface.interfaceText}") maps to ` +
68
- `${mapped.length} contracts at 3.0.0 [${mapped.join(", ")}] — a 2.x cross-family collision; ` +
68
+ `${mapped.length} current contracts [${mapped.join(", ")}] — a cross-family collision under the earlier form; ` +
69
69
  `qualify the alias with the kind-suffixed id; edge dropped`);
70
70
  continue;
71
71
  }
@@ -128,7 +128,7 @@ export async function buildServiceEntityFromTables(inputs) {
128
128
  }
129
129
  return [...rowsByPk.values()];
130
130
  }
131
- async function loadAliasMap(sddPath, findings) {
131
+ export async function loadAliasMap(sddPath, findings) {
132
132
  const empty = { byDisplayLower: new Map() };
133
133
  const path = join(sddPath, ALIAS_REL_PATH);
134
134
  let text;
@@ -7,7 +7,7 @@
7
7
  # Emitted by sdd-cli's YAML emitter with no line folding; the three header scalars are quoted for the
8
8
  # textual readers downstream. The one deliberate difference from the authored form is schema_status:
9
9
  # the authored form says canonical; a projection never does.
10
- schema_version: "6.0.0"
10
+ schema_version: "7.0.0"
11
11
  schema_owner: "sdd-method"
12
12
  schema_status: "generated"
13
13
  enums:
@@ -685,15 +685,15 @@ enums:
685
685
  - value: obligation
686
686
  description: Behaviour every feature in a population depends on, owned as a feature of its own (M-ADR 0226, Proposed).
687
687
  - value: journey
688
- description: A user journey's joint behaviour, held in the behaviour store (M-ADR 0229 §4.2, option B; Proposed).
689
- description: What a catalogued feature is (M-ADR 0226 and M-ADR 0229, both Proposed).
688
+ description: A user journey's joint behaviour, held in the behaviour store (M-ADR 0229 §4.2, option B).
689
+ description: What a catalogued feature is (M-ADR 0226, Proposed, and M-ADR 0229).
690
690
  journey_requirement_basis:
691
691
  values:
692
692
  - value: tag
693
693
  description: A @req tag in the journey's end-to-end test file (option A).
694
694
  - value: citation
695
695
  description: A citation in the journey feature's living spec (option B).
696
- description: Where a journey's citation of a requirement was read (M-ADR 0229 §4.4, Proposed).
696
+ description: Where a journey's citation of a requirement was read (M-ADR 0229 §4.4).
697
697
  source_of_truth:
698
698
  values:
699
699
  - value: structure
@@ -1318,7 +1318,7 @@ catalogues:
1318
1318
  - name: relation
1319
1319
  type: string
1320
1320
  nullable: false
1321
- description: alias-of | conflicts-with | references | supersedes (extended in schema 2.12.0 per M-ADR 0158).
1321
+ description: "alias-of | conflicts-with | references | supersedes (extended in schema 2.12.0 per M-ADR 0158). Read as source, relation, target: for supersedes the successor is the source and the superseded term the target."
1322
1322
  description: "Edge: cross-domain glossary relationships from glossary-alignment-manifest.yaml. Per M-ADR 0158, four walkers ingest into this catalogue: (a) forward declared pair-form {source, target} entries under aliases/conflicts, (b) reverse-method cross_domain_terms (emits references edges per referencer→owner), (c) reverse-method multi-domain conflicts (pair-wise expansion across domains[]), (d) forward-method supersedes (status: superseded entries with superseded_by back-link)."
1323
1323
  natural_key:
1324
1324
  - source_id
@@ -1646,7 +1646,7 @@ catalogues:
1646
1646
  - name: kind
1647
1647
  type: string
1648
1648
  nullable: false
1649
- description: "Bound to feature_kind: feature (the default), obligation (M-ADR 0226, Proposed) or journey (M-ADR 0229 §4.2, Proposed), from the feature spec's **Kind:** line."
1649
+ description: "Bound to feature_kind: feature (the default), obligation (M-ADR 0226, Proposed) or journey (M-ADR 0229 §4.2), from the feature spec's **Kind:** line."
1650
1650
  - name: source_sdd_id
1651
1651
  type: string
1652
1652
  nullable: true
@@ -1760,7 +1760,7 @@ catalogues:
1760
1760
  - name: id
1761
1761
  type: string
1762
1762
  nullable: false
1763
- description: "{domain}-{stem}-{kind} — the kind component joined the id at schema 3.0.0 (M-ADR 0203 §4.4), ending the kindless collision class where one service published as two contract families (openapi + proto) minted one id (measured cost: 1 row + 2 edges permanently unrepresentable, plus legitimate shared-contract citation flagged as divergence). Mechanical migration: new id = old id + '-' + kind; `sdd-cli catalogue migration-map` emits the pairs, and authored alias targets (M-ADR 0147) re-key automatically during the cut window."
1763
+ description: "{domain}-{stem}-{kind} — the kind component joined the id at schema 3.0.0 (M-ADR 0203 §4.4), ending the kindless collision class where one service published as two contract families (openapi + proto) minted one id (measured cost: 1 row + 2 edges permanently unrepresentable, plus legitimate shared-contract citation flagged as divergence). Mechanical migration: new id = old id + '-' + kind; `sdd-cli catalogue migration-map` emits the pairs, and authored alias targets (M-ADR 0147) re-key automatically during the cut window. From schema 7.0.0 the stem is split at its CamelCase boundaries as an entity name is (`OrderPlaced` → `order-placed`), and references to the unsplit form re-key the same way, from id, kind and spec_path."
1764
1764
  - name: name
1765
1765
  type: string
1766
1766
  nullable: false
@@ -1946,7 +1946,7 @@ catalogues:
1946
1946
  - name: service_id
1947
1947
  type: string
1948
1948
  nullable: true
1949
- description: Matched by container name ↔ service name/repo where possible.
1949
+ description: Matched to a service id by the container's diagram id or display-name slug, then by its display name through docs/platform/reference/service-name-aliases.yaml.
1950
1950
  - name: type
1951
1951
  type: string
1952
1952
  nullable: true
@@ -2211,7 +2211,7 @@ catalogues:
2211
2211
  - name: id
2212
2212
  type: string
2213
2213
  nullable: false
2214
- description: "Slugged PK: {domain_id}-{entitySlug(name)}. For manifest-sourced rows with domain=platform-wide, the leading domain segment is empty (`-{entitySlug(name)}`) and the row carries a [finding] convention."
2214
+ description: "Slugged PK: {domain_id}-{entitySlug(name)}. A glossary-manifest entry with domain=platform-wide is the domain entity its slug names when exactly one does, and adds no row of its own; one naming none, or several, is kept as `_platform-wide-{entitySlug(name)}` with a finding. A superseded manifest entry is no entity: glossary_xref holds its supersedes edge."
2215
2215
  - name: name
2216
2216
  type: string
2217
2217
  nullable: false
@@ -4904,7 +4904,7 @@ catalogues:
4904
4904
  nullable: true
4905
4905
  description: The platform SDD the row belongs to (stamped at aggregation).
4906
4906
  source_of_truth: execution
4907
- description: "Edge: a requirement a user journey cites for one of its steps (M-ADR 0229 §4.4, Proposed). Derived, never authored: from the @req tags in the journey's end-to-end test file, or from the citations in its journey feature's living spec. A journey with no row is untraced."
4907
+ description: "Edge: a requirement a user journey cites for one of its steps (M-ADR 0229 §4.4). Derived, never authored: from the @req tags in the journey's end-to-end test file, or from the citations in its journey feature's living spec. A journey with no row is untraced."
4908
4908
  foreign_keys:
4909
4909
  - column: journey_id
4910
4910
  references: user_journeys.id
@@ -13,26 +13,46 @@
13
13
  // `sdd-cli catalogue migration-map`; the alias-file re-key (M-ADR 0147 targets
14
14
  // are authored copies of tool-minted ids — regenerable, not drift, the same
15
15
  // rule as external_system_exemption) consults the in-build form of it.
16
+ /** A contract id's kindless slug (catalogue schema 7.0.0): CamelCase split, lower-cased, non-alphanumerics to `-`. */
17
+ export function contractSlug(s) {
18
+ return legacySlug(s.replace(/([A-Z]{2,})([A-Z][a-z])/g, "$1-$2").replace(/([a-z0-9])([A-Z])/g, "$1-$2"));
19
+ }
20
+ /**
21
+ * A contract id's kindless slug from its domain segment and file stem: only the stem is CamelCase-split, so a
22
+ * domain directory keeps the slug it always had.
23
+ */
24
+ export function contractKindlessSlug(domain, stem) {
25
+ if (domain === "")
26
+ return contractSlug(stem);
27
+ return [legacySlug(domain), contractSlug(stem)].filter((p) => p !== "").join("-");
28
+ }
29
+ /** The pre-7.0.0 slug: the same, without the CamelCase split. */
30
+ function legacySlug(s) {
31
+ return s
32
+ .toLowerCase()
33
+ .replace(/^_+/, "") // turn _common into common
34
+ .replace(/[^a-z0-9]+/g, "-")
35
+ .replace(/^-+|-+$/g, "");
36
+ }
37
+ function stemOfPath(specPath) {
38
+ const base = specPath.split(/[\\/]/).pop() ?? "";
39
+ return base.replace(/\.[^.]+$/, "");
40
+ }
16
41
  /** The 3.0.0 contract id: the kindless slug with its kind appended. */
17
42
  export function contractIdWithKind(kindlessSlug, kind) {
18
43
  return kindlessSlug === "" ? "" : `${kindlessSlug}-${kind}`;
19
44
  }
20
45
  /**
21
- * legacy (2.x, kindless) id → the 3.0.0 kind-suffixed ids it maps to, from
22
- * built rows. More than one target means the id was a measured collision
23
- * under 2.x (two families sharing one kindless slug) — the consumer decides
24
- * which family is meant, loudly, never by guess.
46
+ * legacy id → the current ids it maps to, from built rows: the 2.x kindless id (→ 3.0.0), and the pre-7.0.0
47
+ * unsplit-stem id, with and without its kind. More than one target means the id was a measured collision
48
+ * under the old form (two families sharing one slug) — the consumer decides which family is meant, loudly,
49
+ * never by guess.
25
50
  */
26
51
  export function buildLegacyContractIdMap(rows) {
27
52
  const map = new Map();
28
- for (const row of rows) {
29
- const id = String(row["id"] ?? "");
30
- const kind = String(row["kind"] ?? "");
31
- if (id === "" || kind === "" || !id.endsWith(`-${kind}`))
32
- continue;
33
- const legacy = id.slice(0, -(kind.length + 1));
34
- if (legacy === "")
35
- continue;
53
+ const add = (legacy, id) => {
54
+ if (legacy === "" || legacy === id)
55
+ return;
36
56
  const ids = map.get(legacy);
37
57
  if (ids) {
38
58
  if (!ids.includes(id))
@@ -41,6 +61,23 @@ export function buildLegacyContractIdMap(rows) {
41
61
  else {
42
62
  map.set(legacy, [id]);
43
63
  }
64
+ };
65
+ for (const row of rows) {
66
+ const id = String(row["id"] ?? "");
67
+ const kind = String(row["kind"] ?? "");
68
+ if (id === "" || kind === "" || !id.endsWith(`-${kind}`))
69
+ continue;
70
+ const kindless = id.slice(0, -(kind.length + 1));
71
+ add(kindless, id);
72
+ // 7.0.0: the stem's segment as it read before the CamelCase split.
73
+ const stem = stemOfPath(String(row["spec_path"] ?? ""));
74
+ const split = contractSlug(stem);
75
+ const unsplit = legacySlug(stem);
76
+ if (split === "" || split === unsplit || !kindless.endsWith(split))
77
+ continue;
78
+ const oldKindless = kindless.slice(0, -split.length) + unsplit;
79
+ add(oldKindless, id);
80
+ add(`${oldKindless}-${kind}`, id);
44
81
  }
45
82
  for (const ids of map.values())
46
83
  ids.sort((a, b) => a.localeCompare(b));
@@ -218,7 +218,7 @@ const ManifestSchema = z.object({
218
218
  // Shared-library trait (sdd-manifest-schema 1.12.0, M-ADR 0217 §4.4). Profile-agnostic,
219
219
  // like `exemplar`; absent is the unmarked norm, no finding.
220
220
  shared_library: z.boolean().optional(),
221
- // Where this estate's journey behaviour is mastered (sdd-manifest-schema 1.13.0, M-ADR 0229 §4.2, Proposed).
221
+ // Where this estate's journey behaviour is mastered (sdd-manifest-schema 1.13.0, M-ADR 0229 §4.2).
222
222
  // `master` is declared at the root, and journey-impact treats a member naming its platform as inheriting it.
223
223
  // `test_paths` is never inherited: it is relative to this SDD, so an SDD that holds end-to-end files declares
224
224
  // its own block, with the master and its paths. Unknown values fail at load.
@@ -27,7 +27,7 @@
27
27
  * M-ADR 0134 §4.6 (canonical builder + aggregator).
28
28
  */
29
29
  export const SCHEMA_COMPAT = {
30
- catalogue_schema: "^6.0.0",
30
+ catalogue_schema: "^7.0.0",
31
31
  sdd_manifest: "^1.0.0",
32
32
  aggregation_manifest: "^1.0.0",
33
33
  };
@@ -37,7 +37,7 @@ const BEHAVIOUR_STORE_VALIDATORS = [
37
37
  source: { kind: "native", run: async (ctx) => checkFeatureSpecPointers(ctx.targetRepo) },
38
38
  build: () => ({ status: "run", args: [] }),
39
39
  },
40
- // The journeys a change can break, and whether its proposal names them (M-ADR 0229 §4.5, Proposed). Report only; [N/A] without journeys.
40
+ // The journeys a change can break, and whether its proposal names them (M-ADR 0229 §4.5). Report only; [N/A] without journeys.
41
41
  {
42
42
  name: "journey-impact",
43
43
  source: { kind: "native", run: async (ctx) => checkJourneyImpact(ctx.targetRepo) },
@@ -1,5 +1,5 @@
1
- // journey-impact: the journeys a change can break, and whether its proposal names them (M-ADR 0229 §4.5, Proposed).
2
- // Report only (M-ADR 0229 §4.5, Proposed): every finding is a [WARN] and the check exits 0. Gate 0 carries this report; nothing
1
+ // journey-impact: the journeys a change can break, and whether its proposal names them (M-ADR 0229 §4.5).
2
+ // Report only (M-ADR 0229 §4.5): every finding is a [WARN] and the check exits 0. Gate 0 carries this report; nothing
3
3
  // blocks on it, and the judge does not fail a change for it.
4
4
  //
5
5
  // For each open change in the behaviour store (openspec/changes/<change>/, not the archive):
@@ -14,8 +14,8 @@
14
14
  // with no reason is reported too.
15
15
  //
16
16
  // Also reported: a journey citing a requirement of a feature its journey_feature rows do not name (one record or
17
- // the other is wrong, M-ADR 0229 §4.4, Proposed); and an SDD with catalogued journeys whose manifest declares no journey master and that names no
18
- // platform it inherits from (an unresolved root, M-ADR 0229 §4.2, Proposed). A member that names its platform inherits the
17
+ // the other is wrong, M-ADR 0229 §4.4); and an SDD with catalogued journeys whose manifest declares no journey master and that names no
18
+ // platform it inherits from (an unresolved root, M-ADR 0229 §4.2). A member that names its platform inherits the
19
19
  // root's declaration, which is checked at the root.
20
20
  //
21
21
  // Reads the aggregated catalogue (catalogues-aggregated/*.csv) where this SDD has one, since a root's changes can
@@ -100,7 +100,7 @@ export async function checkJourneyImpact(repo) {
100
100
  else if (platform)
101
101
  lines.push(`journey master: not declared here; inherited from platform ${platform}, where it is checked`);
102
102
  else
103
- warnings.push(`.sdd/sdd-manifest.yaml: ${journeys.length} catalogued journey(s) and no journey master (journeys.master: test-file or store), and no platform to inherit one from: unresolved (M-ADR 0229 §4.2, Proposed)`);
103
+ warnings.push(`.sdd/sdd-manifest.yaml: ${journeys.length} catalogued journey(s) and no journey master (journeys.master: test-file or store), and no platform to inherit one from: unresolved (M-ADR 0229 §4.2)`);
104
104
  const jr = await csv(repo, "journey_requirement", dir);
105
105
  const jf = (await csv(repo, "journey_feature", dir)) ?? [];
106
106
  const featureKinds = new Map(((await csv(repo, "features", dir)) ?? []).map((f) => [f["id"], f["kind"] ?? ""]));
@@ -137,7 +137,7 @@ export async function checkJourneyImpact(repo) {
137
137
  if (touched.get(r["journey_id"])?.has(feature) || mismatched.has(key))
138
138
  continue;
139
139
  mismatched.add(key);
140
- warnings.push(`journey ${r["journey_id"]} cites requirements of ${feature}, which its journey_feature rows do not name: the journey's related features or its tags are wrong (M-ADR 0229 §4.4, Proposed)`);
140
+ warnings.push(`journey ${r["journey_id"]} cites requirements of ${feature}, which its journey_feature rows do not name: the journey's related features or its tags are wrong (M-ADR 0229 §4.4)`);
141
141
  }
142
142
  const untracedCount = journeys.filter((j) => !traced.has(j["id"])).length;
143
143
  lines.push(`journeys: ${journeys.length} catalogued, ${journeys.length - untracedCount} traced to requirements, ${untracedCount} untraced`);
@@ -203,6 +203,6 @@ export async function checkJourneyImpact(repo) {
203
203
  const body = lines.map((l) => `${l}\n`).join("");
204
204
  if (warnings.length === 0)
205
205
  return { exitCode: 0, stdout: `journey-impact:\n${body}[PASS] journey-impact: every listed journey is named\n` };
206
- return { exitCode: 0, stdout: `journey-impact:\n${body}${warnings.map((w) => `[WARN] ${w}\n`).join("")}[WARN] journey-impact: ${warnings.length} finding(s), report only (M-ADR 0229 §4.5, Proposed)\n` };
206
+ return { exitCode: 0, stdout: `journey-impact:\n${body}${warnings.map((w) => `[WARN] ${w}\n`).join("")}[WARN] journey-impact: ${warnings.length} finding(s), report only (M-ADR 0229 §4.5)\n` };
207
207
  }
208
208
  //# sourceMappingURL=journey-impact.js.map
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "0.13.1",
3
- "built_against_sdd_cli": "4.0.1"
2
+ "version": "0.14.0",
3
+ "built_against_sdd_cli": "5.0.0"
4
4
  }
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "4.0.1",
3
- "built_against_sdd_cli": "4.0.1"
2
+ "version": "5.0.0",
3
+ "built_against_sdd_cli": "5.0.0"
4
4
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sdd-method/author-cli",
3
- "version": "0.12.1",
4
- "sddCliVersion": "4.0.1",
3
+ "version": "0.13.0",
4
+ "sddCliVersion": "5.0.0",
5
5
  "description": "Re-author one SDD's content — scaffold, plan, contracts, conformance, api-surface, snapshots, briefings, catalogue check — plus every read-only verify verb. Ships only the code those verbs reach; it cannot sync the baseline or rebuild the catalogues. Maintenance is in @sdd-method/maintain-cli, administration in @sdd-method/sdd-cli.",
6
6
  "license": "UNLICENSED",
7
7
  "type": "module",