@sdd-method/maintain-cli 0.14.0 → 0.15.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/README.md CHANGED
@@ -2,6 +2,6 @@
2
2
 
3
3
  `sdd-maintain` brings an SDD repository current and rebuilds its derived model, and **cannot author specification content** (M-ADR 0218).
4
4
 
5
- Verbs: everything in `@sdd-method/verify-cli`, plus `sync`, `generate-briefing`, `context pull`, `catalogue build`, `catalogue aggregate`, `catalogue graph`, `catalogue diff`, `pack build`, `render screen`.
5
+ Verbs: everything in `@sdd-method/verify-cli`, plus `sync`, `generate-briefing`, `context pull`, `catalogue build`, `catalogue aggregate`, `catalogue graph`, `catalogue diff`, `pack build`, `render screen`, `structure show`, `structure render`.
6
6
 
7
7
  Each writes only derived or method-managed content, and two runs on the same commit write the same bytes. Authoring verbs — `init`, `scaffold`, `plan`, `conformance` and the rest — are not present in it, and are in `@sdd-method/sdd-cli`. Invoking one fails with exit 1.
@@ -32,6 +32,7 @@ import { buildLegacyContractIdMap } from "./contract-id.js";
32
32
  import { validatedPopulation, validateWithEngineToLog } from "./engine-validator.js";
33
33
  import { PLATFORM_STAMP_COLUMN, isPlatformScopedFk } from "./fk-validator.js";
34
34
  import { FindingsLog } from "./findings.js";
35
+ import { STRUCTURE_DIR } from "../structure/model.js";
35
36
  import { readLockFile } from "../lock-file.js";
36
37
  import { mergeEdgeLabel } from "./builders/edge-label-merge.js";
37
38
  import { isAsyncContractType } from "./builders/product-platform-dependencies.js";
@@ -189,6 +190,14 @@ export async function runCatalogueAggregate(inputs, write) {
189
190
  // `sdd-loyalty-platform`). Kept separate from sddRootsById on purpose: the
190
191
  // try-every-root pass must iterate each root ONCE, or a root reachable under two
191
192
  // names would count as two hits and report a false ambiguity.
193
+ // M-ADR 0230: the SDDs here that hold a structural model. A structure-sourced row is that model's
194
+ // to state, so re-derivation never adds one the model leaves out (see rederiveAppend).
195
+ const modelSdds = new Set();
196
+ for (const [id, root] of sddRootsById) {
197
+ if (await stat(join(root, STRUCTURE_DIR)).then(() => true, () => false))
198
+ modelSdds.add(id);
199
+ }
200
+ const modelled = { schema, rowsByCatalogue, modelSdds, findings };
192
201
  const sddRootAliases = new Map(sddRootsById);
193
202
  for (const root of sddRootsById.values()) {
194
203
  sddRootAliases.set(basename(root), root);
@@ -338,7 +347,7 @@ export async function runCatalogueAggregate(inputs, write) {
338
347
  }
339
348
  }
340
349
  const before = (rowsByCatalogue.get("feature_repo") ?? []).length;
341
- rederiveAppend(rowsByCatalogue, "feature_repo", rederivedFeatureRepo);
350
+ rederiveAppend(rowsByCatalogue, "feature_repo", rederivedFeatureRepo, modelled);
342
351
  const added = (rowsByCatalogue.get("feature_repo") ?? []).length - before;
343
352
  if (added > 0) {
344
353
  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)`);
@@ -397,7 +406,7 @@ export async function runCatalogueAggregate(inputs, write) {
397
406
  }
398
407
  }
399
408
  const before = (rowsByCatalogue.get("journey_requirement") ?? []).length;
400
- rederiveAppend(rowsByCatalogue, "journey_requirement", rederived);
409
+ rederiveAppend(rowsByCatalogue, "journey_requirement", rederived, modelled);
401
410
  const added = (rowsByCatalogue.get("journey_requirement") ?? []).length - before;
402
411
  if (added > 0) {
403
412
  findings.add("coverage", `journey_requirement: ${added} row(s) derived at aggregate tier, where a journey and the requirements it cites are catalogued in different SDDs (M-ADR 0229 §4.4; ${contributors.length} contributor(s) read)`);
@@ -416,8 +425,8 @@ export async function runCatalogueAggregate(inputs, write) {
416
425
  capabilityFeatureRows: rowsByCatalogue.get("capability_feature") ?? [],
417
426
  featureRepoRows: rowsByCatalogue.get("feature_repo") ?? [],
418
427
  });
419
- rederiveAppend(rowsByCatalogue, "repo_capability", repoEdges.repoCapability);
420
- rederiveAppend(rowsByCatalogue, "repo_contract", repoEdges.repoContract);
428
+ rederiveAppend(rowsByCatalogue, "repo_capability", repoEdges.repoCapability, modelled);
429
+ rederiveAppend(rowsByCatalogue, "repo_contract", repoEdges.repoContract, modelled);
421
430
  const featureContractRows = buildFeatureContract({
422
431
  findings,
423
432
  capabilityFeatureRows: rowsByCatalogue.get("capability_feature") ?? [],
@@ -425,7 +434,7 @@ export async function runCatalogueAggregate(inputs, write) {
425
434
  featureServiceRows: rowsByCatalogue.get("feature_service") ?? [],
426
435
  serviceContractRows: rowsByCatalogue.get("service_contract") ?? [],
427
436
  });
428
- rederiveAppend(rowsByCatalogue, "feature_contract", featureContractRows);
437
+ rederiveAppend(rowsByCatalogue, "feature_contract", featureContractRows, modelled);
429
438
  const capabilityEventRows = buildCapabilityEvent({
430
439
  findings,
431
440
  manifests: [],
@@ -434,7 +443,7 @@ export async function runCatalogueAggregate(inputs, write) {
434
443
  capabilityServiceRows: rowsByCatalogue.get("capability_service") ?? [],
435
444
  serviceEventRows: rowsByCatalogue.get("service_event") ?? [],
436
445
  });
437
- rederiveAppend(rowsByCatalogue, "capability_event", capabilityEventRows);
446
+ rederiveAppend(rowsByCatalogue, "capability_event", capabilityEventRows, modelled);
438
447
  // 5a-iii. Archetype data-plane expectations overlay (M-ADR 0191 §4.3) — the
439
448
  // deviation-visible WARN + conformance surface, run at AGGREGATION tier where
440
449
  // the reference-architecture bundle's `expected_bindings` resolve (the
@@ -687,14 +696,42 @@ function dedupExternalExemptions(rowsByCatalogue, findings) {
687
696
  }
688
697
  rowsByCatalogue.set("external_system_exemption", [...byId.values()].sort((a, b) => String(a["id"]).localeCompare(String(b["id"]))));
689
698
  }
699
+ // M-ADR 0230: in a structure-sourced catalogue, the column naming the row's subject, and the
700
+ // node catalogue it names. The subject's SDD is the one whose model states the row.
701
+ const STRUCTURE_SUBJECT = {
702
+ capability_event: { column: "capability_id", catalogue: "capabilities" },
703
+ };
690
704
  // M-ADR 0198 §4.2/§4.3: append a re-derived catalogue's rows to whatever concatenation
691
705
  // already produced for it, keeping the concatenated row on any primary-key collision. One
692
706
  // shared mechanism for every re-derived catalogue, so adding a fifth is a call to this
693
707
  // function with that catalogue's builder output, not new merge logic.
694
- function rederiveAppend(rowsByCatalogue, catalogueName, rederivedRows) {
708
+ // M-ADR 0230: a structure-sourced catalogue is stated by the structural model where its
709
+ // subject's SDD holds one, and that SDD's own build already put the model's rows here; a
710
+ // re-derived row the model leaves out is reported, never added. A row whose subject's SDD this
711
+ // aggregation cannot see (an organisation sibling's member) is added as before.
712
+ function rederiveAppend(rowsByCatalogue, catalogueName, rederivedRows, modelled) {
695
713
  const concatenated = rowsByCatalogue.get(catalogueName) ?? [];
696
714
  const existingIds = new Set(concatenated.map((r) => String(r["id"])));
697
- const added = rederivedRows.filter((r) => !existingIds.has(String(r["id"])));
715
+ let added = rederivedRows.filter((r) => !existingIds.has(String(r["id"])));
716
+ const def = modelled.schema.catalogues.find((c) => c.name === catalogueName);
717
+ if (def?.sourceOfTruth === "structure") {
718
+ const subject = STRUCTURE_SUBJECT[catalogueName];
719
+ if (subject === undefined)
720
+ throw new Error(`rederiveAppend: ${catalogueName} is structure-sourced and names no subject column`);
721
+ const sddOf = new Map((modelled.rowsByCatalogue.get(subject.catalogue) ?? []).map((r) => [String(r["id"]), String(r["source_sdd_id"] ?? "")]));
722
+ const left = [];
723
+ added = added.filter((r) => {
724
+ const sdd = sddOf.get(String(r[subject.column] ?? "")) ?? "";
725
+ if (!modelled.modelSdds.has(sdd))
726
+ return true;
727
+ left.push(`${String(r["id"])} (${sdd})`);
728
+ return false;
729
+ });
730
+ if (left.length > 0) {
731
+ left.sort();
732
+ modelled.findings.add("divergence", `${catalogueName}: ${left.length} row(s) derived at aggregate tier left out: the ${subject.column.replace(/_id$/, "")}'s SDD holds a structural model, which states them (M-ADR 0230): ${left.slice(0, 5).join(", ")}${left.length > 5 ? `, and ${left.length - 5} more` : ""}`);
733
+ }
734
+ }
698
735
  rowsByCatalogue.set(catalogueName, [...concatenated, ...added]);
699
736
  }
700
737
  export function dedupExternalSystems(rowsByCatalogue, findings) {
@@ -17,7 +17,10 @@
17
17
  * smoke test that the infrastructure works end-to-end on any SDD repo.
18
18
  */
19
19
  import { overlayModel } from "../structure/catalogue.js";
20
+ import { agreedOrder } from "../structure/compose.js";
21
+ import { Additions, intendedWrites, loadOpenChanges, splitDangling } from "../structure/intended.js";
20
22
  import { buildDataModelElements, readDataModelSources } from "./builders/data-model.js";
23
+ import { enrichEntityArchetype } from "./builders/entity-archetype.js";
21
24
  import { removeRetiredCatalogues } from "./retired-catalogues.js";
22
25
  import { loadModel } from "../structure/model.js";
23
26
  import { viewOf } from "../structure/view.js";
@@ -41,7 +44,7 @@ import { buildEvidenceTargetRepos } from "./builders/evidence-target-repo.js";
41
44
  import { buildRepoDependencies } from "./builders/repo-dependency.js";
42
45
  import { buildRequirements } from "./builders/requirements.js";
43
46
  import { deriveAdrGoverns } from "./builders/adr-governs.js";
44
- import { buildContractRevisions } from "./builders/contract-revisions.js";
47
+ import { contractRevisionsAndDeprecations } from "./builders/contract-revisions.js";
45
48
  import { buildJourneyRequirement } from "./builders/journey-requirement.js";
46
49
  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
50
  import { mergeEntities } from "./builders/entity-merge.js";
@@ -79,12 +82,17 @@ export async function runCatalogueBuild(inputs, write) {
79
82
  }
80
83
  const collected = await collectRows(schema, manifest, findings, sddPath, inputs.snapshotStore);
81
84
  // 3 (then). Where the SDD has a structural model, it is the record of structure: every structure catalogue is built from
82
- // its current view, and what the sources say otherwise is reported (M-ADR 0230 §4.3, Proposed).
85
+ // its current view, and what the sources say otherwise is reported (M-ADR 0230 §4.3).
86
+ // An agreed open change's elements, stated by the sources ahead of its archive, are intended, not divergent (§4.7).
83
87
  const modelProblems = [];
84
88
  const modelFiles = await loadModel(sddPath, modelProblems);
85
- const rowsByCatalogue = modelFiles === null ? collected : overlayModel(schema, viewOf(modelFiles, modelProblems), collected, findings);
89
+ const { open, newContracts } = await loadOpenChanges(sddPath, []);
90
+ const intendedRows = [];
91
+ const rowsByCatalogue = modelFiles === null
92
+ ? collected
93
+ : overlayModel(schema, viewOf(modelFiles, modelProblems), collected, findings, intendedWrites(open, agreedOrder(open)), intendedRows);
86
94
  for (const p of modelProblems)
87
- findings.add("convention", `${p} (M-ADR 0230, Proposed)`);
95
+ findings.add("convention", `${p} (M-ADR 0230)`);
88
96
  // 3a. Normalise every keyed catalogue to PK-unique rows. Multi-source edge
89
97
  // tables (feature_service, feature_repo) can have the same fact asserted by
90
98
  // more than one walker; each walker's own seen-set only dedupes intra-walker.
@@ -141,7 +149,13 @@ export async function runCatalogueBuild(inputs, write) {
141
149
  // SDD profile so profile-external FKs (schema 2.23.0, e.g. an app/integration
142
150
  // capability's platform-resident domain_id per M-ADR 0136) resolve at
143
151
  // aggregation instead of failing this local build.
144
- const { fk, enums } = await validateWithEngineToLog(schema, rowsByCatalogue, findings, { profile: manifest.profile });
152
+ const { fk: engineFk, enums } = await validateWithEngineToLog(schema, rowsByCatalogue, findings, { profile: manifest.profile });
153
+ // 5a. A row an open change owns (its change_id, or a contract its record agrees as new) resolves against what that
154
+ // change adds; any other row naming an addition is a current reference to an intended element, and still dangles
155
+ // (M-ADR 0230 §4.7, §4.11 proof 3).
156
+ const additions = new Additions(open, newContracts, rowsByCatalogue.get("contracts") ?? []);
157
+ const split = splitDangling(schema, rowsByCatalogue, engineFk.internalFailures, additions, manifest.sddId);
158
+ const fk = { ...engineFk, internalPass: split.dangling.length === 0, internalFailures: split.dangling.map((d) => d.failure) };
145
159
  // 5b. Bound-enum validation (M-ADR 0203 §4.1): every column_enums-bound
146
160
  // column hard-fails on an out-of-set value, like a dangling internal FK.
147
161
  // The canonical builder cannot emit a non-canonical c4_containers.type
@@ -173,10 +187,24 @@ export async function runCatalogueBuild(inputs, write) {
173
187
  }
174
188
  else {
175
189
  write(`\nFK validation (internal): FAIL (${fk.internalFailures.length} dangling)\n`);
176
- for (const f of fk.internalFailures) {
177
- write(` ${f.catalogue}.${f.column}: row pk=${f.referencingRow} → ${f.target}=${f.value || "<empty>"}\n`);
190
+ for (const { failure: f, addedBy } of split.dangling) {
191
+ write(` ${f.catalogue}.${f.column}: row pk=${f.referencingRow} → ${f.target}=${f.value || "<empty>"}` +
192
+ (addedBy === undefined
193
+ ? ""
194
+ : ` (open change ${addedBy} adds it, and nothing ties this row to that change: a current reference to an intended element)`) +
195
+ `\n`);
178
196
  }
179
197
  }
198
+ if (split.intended.length > 0) {
199
+ write(`Intended references: ${split.intended.length}, each made by an open change's own row to what that change adds\n`);
200
+ for (const { failure: f, change } of split.intended) {
201
+ write(` ${f.catalogue}.${f.column}: row pk=${f.referencingRow} → ${f.target}=${f.value} (${change})\n`);
202
+ }
203
+ }
204
+ if (intendedRows.length > 0) {
205
+ write(`Intended elements: ${intendedRows.length} source row(s) state what an agreed open change adds or modifies, ` +
206
+ `catalogued once it archives: ${[...intendedRows].sort().join(", ")}\n`);
207
+ }
180
208
  if (enums.pass) {
181
209
  write(`Enum validation (column_enums): PASS (${validatedPopulation(rowsByCatalogue)})\n`);
182
210
  }
@@ -1194,10 +1222,16 @@ async function collectRows(schema, manifest, findings, sddPath, snapshotStore) {
1194
1222
  out.set("changes", r.changes);
1195
1223
  out.set("change_work_order", r.workOrders);
1196
1224
  out.set("change_work_item", r.workItems);
1197
- // M-ADR 0230 §4.2 (Proposed): which view each decision governs, now the store's changes are known.
1225
+ // M-ADR 0230 §4.2: which view each decision governs, now the store's changes are known.
1198
1226
  out.set("adrs", deriveAdrGoverns(out.get("adrs") ?? [], out.get("adr_supersedes") ?? [], r.changes, findings));
1199
- // M-ADR 0230 §4.2 (Proposed): each contract's revisions by digest, active beside agreed.
1200
- out.set("contract_revisions", await buildContractRevisions({ sddPath, findings, contracts: out.get("contracts") ?? [], changes: r.changes }));
1227
+ // M-ADR 0230 §4.2: each contract's revisions by digest, active beside agreed.
1228
+ const revisions = await contractRevisionsAndDeprecations({ sddPath, findings, contracts: out.get("contracts") ?? [], changes: r.changes });
1229
+ out.set("contract_revisions", revisions.rows);
1230
+ // A version an archived change deprecated (agreement record 1.3.0) is deprecated in contracts too, whether or
1231
+ // not its file is in this SDD.
1232
+ const deprecated = revisions.deprecated;
1233
+ if (deprecated.size > 0)
1234
+ out.set("contracts", (out.get("contracts") ?? []).map((c) => (deprecated.has(String(c["id"])) ? { ...c, status: "deprecated" } : c)));
1201
1235
  // M-ADR 0229 §4.4: the requirements each journey cites, from its tagged end-to-end files or its
1202
1236
  // journey feature's living spec.
1203
1237
  out.set("journey_requirement", await buildJourneyRequirement({
@@ -1426,8 +1460,20 @@ async function collectRows(schema, manifest, findings, sddPath, snapshotStore) {
1426
1460
  entityIdBySlug,
1427
1461
  entityRows,
1428
1462
  });
1429
- out.set("entities", enrichedEntityRows);
1430
- // 17a. The domain models' own content (M-ADR 0230 §4.3, §4.11 proof 7, Proposed; schema 6.0.0): each entity's
1463
+ // Schema 7.1.0: the PPT archetype, from the capability specs' Entity Classification tables, in an SDD without a
1464
+ // structural model; with one, the model holds it (M-ADR 0230 §4.3).
1465
+ const hasModel = await stat(join(sddPath, "openspec", "structure")).then(() => true, () => false);
1466
+ out.set("entities", hasModel
1467
+ ? enrichedEntityRows
1468
+ : await enrichEntityArchetype({
1469
+ sddPath,
1470
+ findings,
1471
+ capabilities: (out.get("capabilities") ?? []).map((r) => ({ spec_path: String(r["spec_path"] ?? ""), domain_id: String(r["domain_id"] ?? "") })),
1472
+ entityIdByDomainSlug,
1473
+ entityIdBySlug,
1474
+ entityRows: enrichedEntityRows,
1475
+ }));
1476
+ // 17a. The domain models' own content (M-ADR 0230 §4.3, §4.11 proof 7; schema 6.0.0): each entity's
1431
1477
  // attributes, the relationships with both ends' cardinality and roles, and the keys as invariants, from the same
1432
1478
  // parse the entities came from.
1433
1479
  const dataModel = buildDataModelElements(await readDataModelSources(sddPath, domainModels.map((r) => ({ id: String(r["id"]), domainId: String(r["domain_id"]), docPath: String(r["doc_path"]) }))), enrichedEntityRows, findings);
@@ -18,7 +18,7 @@ export function deriveAdrGoverns(adrs, supersedes, changes, findings) {
18
18
  }
19
19
  const state = changeState(change);
20
20
  if (state === "absent")
21
- findings.add("convention", `${String(a["doc_path"])}: effective on archive of ${change}, which is no change in this store; the decision governs nothing until it is (M-ADR 0230 §4.2, Proposed)`);
21
+ findings.add("convention", `${String(a["doc_path"])}: effective on archive of ${change}, which is no change in this store; the decision governs nothing until it is (M-ADR 0230 §4.2)`);
22
22
  accepted.set(String(a["id"]), state === "archived" ? "current" : state === "open" ? "intended" : "none");
23
23
  }
24
24
  const successors = new Map();
@@ -19,7 +19,7 @@
19
19
  // - **Date:** YYYY-MM-DD
20
20
  // - **Supersedes:** raw text (captured into supersedes_raw for the
21
21
  // adr_supersedes builder to parse)
22
- // - **Effective:** `on acceptance` (the default) or `on archive of <change>` (M-ADR 0230 §4.2, Proposed):
22
+ // - **Effective:** `on acceptance` (the default) or `on archive of <change>` (M-ADR 0230 §4.2):
23
23
  // effective_change holds the change; governs is derived once the store's changes are known (adr-governs.ts)
24
24
  //
25
25
  // ID canonicalisation: filename stem `ADR XXXX[: Title]` → `ADR-{0-padded}`.
@@ -85,7 +85,7 @@ export async function buildAdrs(inputs) {
85
85
  const effectiveRaw = (extractField(text, "Effective") || "").trim();
86
86
  const onArchive = /^on archive of\s+`?([A-Za-z0-9][A-Za-z0-9._-]*)`?\s*\.?$/i.exec(effectiveRaw);
87
87
  if (effectiveRaw !== "" && !onArchive && !/^on acceptance\.?$/i.test(effectiveRaw)) {
88
- inputs.findings.add("convention", `${relPath}: **Effective:** is "${effectiveRaw}", neither "on acceptance" nor "on archive of <change>"; read as on acceptance (M-ADR 0230 §4.2, Proposed)`);
88
+ inputs.findings.add("convention", `${relPath}: **Effective:** is "${effectiveRaw}", neither "on acceptance" nor "on archive of <change>"; read as on acceptance (M-ADR 0230 §4.2)`);
89
89
  }
90
90
  const effectiveChange = onArchive?.[1] ?? "";
91
91
  if (scope === "domain" && !inputs.knownDomainIds.has(domainId)) {
@@ -1,9 +1,12 @@
1
- // contract_revisions: a contract version's content revisions, by digest (M-ADR 0230 §4.2, Proposed; schema 6.0.0).
1
+ // contract_revisions: a contract version's content revisions, by digest (M-ADR 0230 §4.2; schema 6.0.0).
2
2
  // active the contract file's current content (deprecated where the contract is)
3
3
  // agreed a revision an agreed open change agrees (agreement record 1.2.0, contracts[].agrees)
4
4
  // retired a revision an archived change agreed that the file no longer holds
5
+ // deprecated the active revision, where an archived change's record deprecates the version (record 1.3.0,
6
+ // contracts[].deprecates: {sunset}), with that sunset and that change; the latest archive wins
5
7
  // An archived change that agreed the current content is the active revision's change. A compatible amendment is a
6
- // new revision at the same path, so an agreed revision and the active one coexist.
8
+ // new revision at the same path, so an agreed revision and the active one coexist. A deprecation an open change
9
+ // states is intended, not current, and moves no row until the change archives.
7
10
  //
8
11
  // A record names a contract by the file in its own repository (`file:`, or a `files:` list), which the catalogue holds
9
12
  // under the SDD's mount of that repository; so a record's file matches the contract whose spec_path ends with it, and
@@ -16,11 +19,12 @@ import { join } from "node:path";
16
19
  import { parse as parseYaml } from "yaml";
17
20
  const AGREEMENTS = join("docs", "agreements");
18
21
  const DIGEST = /^sha256:[0-9a-f]{64}$/;
22
+ const DATE = /^\d{4}-\d{2}-\d{2}$/;
19
23
  export function contentDigest(bytes) {
20
24
  return `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
21
25
  }
22
26
  const revisionId = (contract, digest) => `${contract}@${digest.slice("sha256:".length, "sha256:".length + 12)}`;
23
- /** Each (file, relies_on, agrees) a record's contracts list pins. */
27
+ /** Each (file, relies_on, agrees, deprecates) a record's contracts list pins. */
24
28
  function pins(record, where, findings) {
25
29
  const r = record?.agreement_record;
26
30
  if (!Array.isArray(r?.contracts))
@@ -44,16 +48,32 @@ function pins(record, where, findings) {
44
48
  }
45
49
  return d;
46
50
  };
51
+ // `deprecates: {sunset}` deprecates the version at each of the entry's files.
52
+ let deprecates;
53
+ if (c["deprecates"] !== undefined) {
54
+ const sunset = c["deprecates"]?.sunset;
55
+ if (typeof sunset === "string" && DATE.test(sunset))
56
+ deprecates = sunset;
57
+ else
58
+ findings.add("convention", `${where}: contracts[${i}].deprecates is {sunset: YYYY-MM-DD}; it deprecates nothing as written`);
59
+ }
47
60
  for (const file of files) {
48
61
  const reliesOn = digestFor("relies_on", file);
49
62
  const agrees = digestFor("agrees", file);
50
- if (reliesOn || agrees)
51
- out.push({ file, ...(reliesOn ? { reliesOn } : {}), ...(agrees ? { agrees } : {}) });
63
+ if (reliesOn || agrees || deprecates)
64
+ out.push({ file, ...(reliesOn ? { reliesOn } : {}), ...(agrees ? { agrees } : {}), ...(deprecates ? { deprecates } : {}) });
52
65
  }
53
66
  }
54
67
  return out;
55
68
  }
56
69
  export async function buildContractRevisions(inputs) {
70
+ return (await contractRevisionsAndDeprecations(inputs)).rows;
71
+ }
72
+ /**
73
+ * The revision rows, and every contract an archived change deprecated. The second is kept apart because a contract
74
+ * declared outside this SDD has no file here and so no revision row, yet its version is still deprecated.
75
+ */
76
+ export async function contractRevisionsAndDeprecations(inputs) {
57
77
  const { sddPath, findings } = inputs;
58
78
  const rows = new Map();
59
79
  const current = new Map(); // contract id -> current digest
@@ -78,6 +98,8 @@ export async function buildContractRevisions(inputs) {
78
98
  catch {
79
99
  names = [];
80
100
  }
101
+ // contract id -> the latest archived deprecation of its version
102
+ const deprecations = new Map();
81
103
  for (const name of names) {
82
104
  const change = name.slice(0, -".yaml".length);
83
105
  const where = join(AGREEMENTS, name);
@@ -103,8 +125,14 @@ export async function buildContractRevisions(inputs) {
103
125
  }
104
126
  const contract = matches[0];
105
127
  const now = current.get(contract);
128
+ if (p.deprecates && archived) {
129
+ const archivedOn = String(archived["archived_on"] ?? "");
130
+ const prior = deprecations.get(contract);
131
+ if (!prior || `${archivedOn}\u0000${changeId}` > `${prior.archivedOn}\u0000${prior.changeId}`)
132
+ deprecations.set(contract, { sunset: p.deprecates, changeId, archivedOn });
133
+ }
106
134
  if (state === "agreed" && p.reliesOn && now && p.reliesOn !== now) {
107
- findings.add("divergence", `${where}: relies on revision ${p.reliesOn.slice(0, 19)}… of ${contract}, which has moved to ${now.slice(0, 19)}…; the change relies on a revision no longer published, so it is re-agreed (M-ADR 0230 §4.2, Proposed)`);
135
+ findings.add("divergence", `${where}: relies on revision ${p.reliesOn.slice(0, 19)}… of ${contract}, which has moved to ${now.slice(0, 19)}…; the change relies on a revision no longer published, so it is re-agreed (M-ADR 0230 §4.2)`);
108
136
  }
109
137
  if (!p.agrees)
110
138
  continue;
@@ -121,6 +149,16 @@ export async function buildContractRevisions(inputs) {
121
149
  rows.set(rid, { id: rid, contract_id: contract, digest: p.agrees, state: "retired", sunset_date: "", change_id: changeId, source_sdd_id: "", platform_sdd_id: "" });
122
150
  }
123
151
  }
124
- return [...rows.values()].sort((a, b) => String(a["id"]).localeCompare(String(b["id"])));
152
+ const deprecated = new Map();
153
+ for (const [contract, d] of deprecations) {
154
+ deprecated.set(contract, { sunset: d.sunset, changeId: d.changeId });
155
+ // A contract whose file is outside this SDD has no revision row to mark.
156
+ const now = current.get(contract);
157
+ const rid = now ? revisionId(contract, now) : undefined;
158
+ const row = rid ? rows.get(rid) : undefined;
159
+ if (rid && row)
160
+ rows.set(rid, { ...row, state: "deprecated", sunset_date: d.sunset, change_id: d.changeId });
161
+ }
162
+ return { rows: [...rows.values()].sort((a, b) => String(a["id"]).localeCompare(String(b["id"]))), deprecated };
125
163
  }
126
164
  //# sourceMappingURL=contract-revisions.js.map
@@ -1,4 +1,4 @@
1
- // A domain model's content, parsed whole (M-ADR 0230 §4.3, §4.11 proof 7, Proposed; schema 6.0.0). One parser per
1
+ // A domain model's content, parsed whole (M-ADR 0230 §4.3, §4.11 proof 7; schema 6.0.0). One parser per
2
2
  // notation, shared by every reader of the file: the entities builder takes its entities from here, and this module
3
3
  // builds entity_attributes, entity_relationships and domain_invariants from the same parse.
4
4
  //
@@ -0,0 +1,121 @@
1
+ // Catalogue schema 7.1.0: backfill `entities.ppt_archetype` from the capability specs' `## Entity Classification`
2
+ // tables (M-ADR 0230 §4.3, at its acceptance).
3
+ //
4
+ // The archetype is a structure fact. Where the SDD has a structural model the model holds it, and the capability spec
5
+ // template drops its table, so this reads the tables only in an SDD without a model: that build is what `migrate
6
+ // structure` carries into a first model.
7
+ //
8
+ // Authored cells vary: "Party (Green)", "**Thing — audit record**", "Blue/Description", "Moment", "PartyRole". A cell's
9
+ // archetype is the first one it names, colour labels and qualifiers aside. Moment alone is moment-interval, and
10
+ // PartyRole is a role. A cell naming more than two is a list of options, not a classification, and names none.
11
+ // What cannot be classified is reported, once per build, never guessed.
12
+ import { readFile } from "node:fs/promises";
13
+ import { join } from "node:path";
14
+ import { collectTableLines, detectColumn, entitySlug, splitRow } from "./data-store-mapping.js";
15
+ export const PPT_ARCHETYPES = ["party", "place", "thing", "role", "moment-interval", "description"];
16
+ const RE_HEADING = /^## Entity Classification\b/;
17
+ const RE_TOKEN = /\b(?:moment[\s-]*interval|moment|party[\s-]*role|party|place|thing|role|description)/g;
18
+ const EXAMPLES = 5;
19
+ /** The archetypes a cell names, in order, each once. */
20
+ export function archetypesNamed(cell) {
21
+ const out = [];
22
+ for (const m of cell.toLowerCase().matchAll(RE_TOKEN)) {
23
+ const t = m[0].replace(/[\s-]+/g, "");
24
+ const a = t.startsWith("moment") ? "moment-interval" : t === "partyrole" ? "role" : t;
25
+ if (!out.includes(a))
26
+ out.push(a);
27
+ }
28
+ return out;
29
+ }
30
+ /** A cell's archetype: the first it names, or null when it names none or more than two. */
31
+ export function archetypeOf(cell) {
32
+ const named = archetypesNamed(cell);
33
+ return named.length === 0 || named.length > 2 ? null : named[0];
34
+ }
35
+ /** The entity a row's first cell names: markup and a trailing qualifier stripped. */
36
+ function entityName(cell) {
37
+ return cell
38
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
39
+ .replace(/[*`_]/g, "")
40
+ .split(/\s+[—–-]\s+/)[0]
41
+ .replace(/\s*\([^)]*\)\s*$/, "")
42
+ .trim();
43
+ }
44
+ function sample(xs) {
45
+ return xs.slice(0, EXAMPLES).join(", ") + (xs.length > EXAMPLES ? `, and ${xs.length - EXAMPLES} more` : "");
46
+ }
47
+ export async function enrichEntityArchetype(inputs) {
48
+ // A spec in the entity's own domain outranks one that reaches it by the cross-domain fallback; otherwise the first wins.
49
+ const chosen = new Map();
50
+ const several = [];
51
+ const none = [];
52
+ const unresolved = [];
53
+ const disagree = new Set();
54
+ const specs = [...new Map(inputs.capabilities.filter((c) => c.spec_path !== "").map((c) => [c.spec_path, c])).values()].sort((a, b) => a.spec_path.localeCompare(b.spec_path));
55
+ for (const cap of specs) {
56
+ let text;
57
+ try {
58
+ text = await readFile(join(inputs.sddPath, cap.spec_path), "utf8");
59
+ }
60
+ catch {
61
+ continue;
62
+ }
63
+ const lines = text.split(/\r?\n/);
64
+ const at = lines.findIndex((l) => RE_HEADING.test(l));
65
+ if (at === -1)
66
+ continue;
67
+ const table = collectTableLines(lines, at + 1);
68
+ if (table === null || table.length < 3)
69
+ continue;
70
+ const headers = splitRow(table[0]);
71
+ const col = detectColumn(headers, (h) => h.includes("archetype") || h.includes("ppt"));
72
+ if (col === -1)
73
+ continue;
74
+ const entityCol = Math.max(0, detectColumn(headers, (h) => h.includes("entity") || h.includes("aggregate")));
75
+ for (const line of table.slice(2)) {
76
+ const cells = splitRow(line);
77
+ const name = entityName(cells[entityCol] ?? "");
78
+ // a separator, the template's placeholder row, or a header repeated inside the table
79
+ if (name === "" || /^[-:]+$/.test(name) || name.startsWith("{") || name.toLowerCase() === (headers[entityCol] ?? "").toLowerCase())
80
+ continue;
81
+ const where = `${cap.spec_path}: ${name}`;
82
+ const cell = cells[col] ?? "";
83
+ const named = archetypesNamed(cell);
84
+ const archetype = archetypeOf(cell);
85
+ if (archetype === null) {
86
+ none.push(`${where} (${JSON.stringify(cell)})`);
87
+ continue;
88
+ }
89
+ if (named.length > 1)
90
+ several.push(`${where} (${JSON.stringify(cell)} read as ${archetype})`);
91
+ const slug = entitySlug(name);
92
+ const localId = inputs.entityIdByDomainSlug.get(`${cap.domain_id}|${slug}`);
93
+ const id = localId ?? inputs.entityIdBySlug.get(slug);
94
+ if (id === undefined) {
95
+ unresolved.push(where);
96
+ continue;
97
+ }
98
+ const mine = { archetype, spec: cap.spec_path, local: localId !== undefined };
99
+ const prior = chosen.get(id);
100
+ if (prior !== undefined && prior.archetype !== archetype) {
101
+ const [kept, other] = mine.local && !prior.local ? [mine, prior] : [prior, mine];
102
+ disagree.add(`${id} (${kept.archetype} in ${kept.spec}, catalogued; ${other.archetype} in ${other.spec})`);
103
+ }
104
+ if (prior === undefined || (mine.local && !prior.local))
105
+ chosen.set(id, mine);
106
+ }
107
+ }
108
+ const report = (n, what, items) => {
109
+ if (n > 0)
110
+ inputs.findings.add("coverage", `entities.ppt_archetype: ${n} ${what}: ${sample([...items].sort())}`);
111
+ };
112
+ report(none.length, "Entity Classification row(s) name no archetype, so none is catalogued", none);
113
+ report(several.length, "Entity Classification row(s) name two archetypes; the first is catalogued", several);
114
+ report(unresolved.length, "Entity Classification row(s) name no entity of this SDD", unresolved);
115
+ report(disagree.size, "entities classified differently by two specs; the entity's own domain's spec, else the first, is catalogued", disagree);
116
+ return inputs.entityRows.map((r) => {
117
+ const c = chosen.get(String(r["id"]));
118
+ return c === undefined || String(r["ppt_archetype"] ?? "") !== "" ? r : { ...r, ppt_archetype: c.archetype };
119
+ });
120
+ }
121
+ //# sourceMappingURL=entity-archetype.js.map
@@ -119,7 +119,7 @@ const num = (v) => {
119
119
  return null;
120
120
  };
121
121
  /**
122
- * The review's other assessed inputs (M-ADR 0230 §4.9, Proposed): `inputs:` as a list of paths, or of `{path}`
122
+ * The review's other assessed inputs (M-ADR 0230 §4.9): `inputs:` as a list of paths, or of `{path}`
123
123
  * entries, each read at the review's corpus_ref. ';'-joined; null when the review names none.
124
124
  */
125
125
  function inputPaths(v) {
@@ -18,7 +18,7 @@
18
18
  // change_work_order per (change, repository): the record's work order, agreed or completed.
19
19
  // change_work_item per (change, work item): the ids its proposal's `## Work Items` section lists, keyed by
20
20
  // the undated name so the row survives archive.
21
- // requirement_revisions one row per distinct text of a requirement (schema 6.0.0, M-ADR 0230 §4.7, Proposed): the
21
+ // requirement_revisions one row per distinct text of a requirement (schema 6.0.0, M-ADR 0230 §4.7): the
22
22
  // living spec's is current, an agreed open change's ADDED or MODIFIED block intended, an
23
23
  // archived change's block the living spec no longer holds history. Identified by its digest.
24
24
  // requirement_assertions per (revision, repository): observed from a baseline's `**Observed in:**` line;