@sdd-method/maintain-cli 0.11.0 → 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.
- package/dist/lib/behaviour-store/archive.js +13 -0
- package/dist/lib/catalogue/aggregate.js +64 -0
- package/dist/lib/catalogue/build.js +45 -2
- package/dist/lib/catalogue/builders/adr-governs.js +39 -0
- package/dist/lib/catalogue/builders/adrs.js +11 -0
- package/dist/lib/catalogue/builders/capability-spec.js +1 -0
- package/dist/lib/catalogue/builders/contract-revisions.js +126 -0
- package/dist/lib/catalogue/builders/data-model.js +316 -0
- package/dist/lib/catalogue/builders/entities-mermaid.js +18 -85
- package/dist/lib/catalogue/builders/feature-grounding.js +1 -0
- package/dist/lib/catalogue/builders/features.js +23 -2
- package/dist/lib/catalogue/builders/integration-components.js +49 -5
- package/dist/lib/catalogue/builders/journey-requirement.js +144 -0
- package/dist/lib/catalogue/builders/quality-scores.js +17 -0
- package/dist/lib/catalogue/builders/requirements.js +168 -32
- package/dist/lib/catalogue/canonical-schema.yaml +845 -28
- package/dist/lib/catalogue/manifest-loader.js +13 -0
- package/dist/lib/catalogue/retired-catalogues.js +25 -0
- package/dist/lib/catalogue/rubric-coverage-map.yaml +108 -2
- package/dist/lib/catalogue/schema-compat.js +1 -1
- package/dist/lib/catalogue/schema-loader.js +3 -0
- package/dist/lib/catalogue/staleness-verdicts.js +75 -6
- package/dist/lib/metamodel/tql-subset-parser.js +35 -2
- package/dist/lib/structure/catalogue.js +183 -0
- package/dist/lib/structure/compose.js +174 -0
- package/dist/lib/structure/effects.js +36 -0
- package/dist/lib/structure/model.js +212 -0
- package/dist/lib/structure/render.js +242 -0
- package/dist/lib/structure/view.js +301 -0
- package/dist/lib/validate/agreement-evidence.js +216 -0
- package/dist/lib/validate/behaviour-store.js +516 -28
- package/dist/lib/validate/dispatch.js +21 -0
- package/dist/lib/validate/feature-spec-pointers.js +175 -0
- package/dist/lib/validate/journey-impact.js +193 -0
- package/dist/lib/validate/structural-model.js +265 -0
- package/dist/maintain-version.json +2 -2
- package/dist/verify-version.json +2 -2
- package/package.json +2 -2
|
@@ -204,4 +204,17 @@ export function surfaceMatches(entry, contractId, specPath) {
|
|
|
204
204
|
const re = new RegExp(`(^|/)${e.split("*").map((p) => p.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join("[^/]*")}$`);
|
|
205
205
|
return re.test(specPath.split("\\").join("/"));
|
|
206
206
|
}
|
|
207
|
+
/**
|
|
208
|
+
* The delta entries a work order's `implements` entry names (schema 5.0.0). `<feature>::<name>` names exactly one
|
|
209
|
+
* requirement. A bare name resolves only when one feature in the change has a requirement of that name; across
|
|
210
|
+
* two features it is ambiguous, and names none.
|
|
211
|
+
*/
|
|
212
|
+
export function resolveImplements(delta, entry) {
|
|
213
|
+
const q = /^(.+?)::(.+)$/.exec(entry);
|
|
214
|
+
if (q)
|
|
215
|
+
return { matches: delta.filter((d) => d.feature === q[1] && d.name === q[2]), ambiguous: [] };
|
|
216
|
+
const matches = delta.filter((d) => d.name === entry);
|
|
217
|
+
const features = [...new Set(matches.map((d) => d.feature))];
|
|
218
|
+
return features.length > 1 ? { matches: [], ambiguous: features } : { matches, ambiguous: [] };
|
|
219
|
+
}
|
|
207
220
|
//# sourceMappingURL=archive.js.map
|
|
@@ -27,6 +27,7 @@ import { SCHEMA_COMPAT } from "./schema-compat.js";
|
|
|
27
27
|
import { loadCanonicalSchema } from "./schema-loader.js";
|
|
28
28
|
import { readCatalogueCsv, parseCatalogueCsv } from "./csv-reader.js";
|
|
29
29
|
import { writeCatalogue } from "./csv-writer.js";
|
|
30
|
+
import { removeRetiredCatalogues } from "./retired-catalogues.js";
|
|
30
31
|
import { buildLegacyContractIdMap } from "./contract-id.js";
|
|
31
32
|
import { validatedPopulation, validateWithEngineToLog } from "./engine-validator.js";
|
|
32
33
|
import { PLATFORM_STAMP_COLUMN, isPlatformScopedFk } from "./fk-validator.js";
|
|
@@ -40,6 +41,7 @@ import { buildCapabilitySpec } from "./builders/capability-spec.js";
|
|
|
40
41
|
import { buildFeatureGrounding } from "./builders/feature-grounding.js";
|
|
41
42
|
import { loadFeatureGroundingAliases } from "./feature-grounding-aliases-loader.js";
|
|
42
43
|
import { buildFeatureContract } from "./builders/feature-contract.js";
|
|
44
|
+
import { buildJourneyRequirement } from "./builders/journey-requirement.js";
|
|
43
45
|
import { buildCapabilityEvent } from "./builders/event-edges.js";
|
|
44
46
|
import { discoverVocabulary } from "./application-archetype/vocabulary.js";
|
|
45
47
|
import { checkDataPlaneOverlayAtAggregation } from "./application-archetype/expectations.js";
|
|
@@ -342,6 +344,67 @@ export async function runCatalogueAggregate(inputs, write) {
|
|
|
342
344
|
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)`);
|
|
343
345
|
}
|
|
344
346
|
}
|
|
347
|
+
// M-ADR 0229 §4.4 (Proposed): journey_requirement re-derived over the merged rows. A journey is often catalogued
|
|
348
|
+
// in an application SDD and the requirements its steps cite in the platform's behaviour store, so neither member
|
|
349
|
+
// holds both join inputs; each reports the tag as external. Each contributor's tagged end-to-end files (its own
|
|
350
|
+
// manifest's journeys.test_paths) and its journey features' living specs are read again here against the MERGED
|
|
351
|
+
// features, requirements and journeys. Fills absences only (rederiveAppend). A tag naming another contributor's
|
|
352
|
+
// feature that the merged rows still do not resolve is reported here; everything the member build already reported
|
|
353
|
+
// (its own features' tags, its test paths, its files' journey tags and journey specs) is not repeated.
|
|
354
|
+
{
|
|
355
|
+
const contributors = [{ sddPath, sddId: rootManifest.sddId }];
|
|
356
|
+
for (const sib of aggManifest.siblings) {
|
|
357
|
+
if (sib.level === "organisation")
|
|
358
|
+
continue;
|
|
359
|
+
if (crossCheck.siblingProfiles.get(sib.sddId) === undefined)
|
|
360
|
+
continue;
|
|
361
|
+
contributors.push({ sddPath: sib.sddRoot, sddId: sib.sddId });
|
|
362
|
+
}
|
|
363
|
+
const merged = {
|
|
364
|
+
features: rowsByCatalogue.get("features") ?? [],
|
|
365
|
+
requirements: rowsByCatalogue.get("requirements") ?? [],
|
|
366
|
+
knownJourneyIds: new Set((rowsByCatalogue.get("user_journeys") ?? []).map((r) => String(r["id"]))),
|
|
367
|
+
};
|
|
368
|
+
const rederived = [];
|
|
369
|
+
const seen = new Set();
|
|
370
|
+
const unresolved = [];
|
|
371
|
+
for (const c of contributors) {
|
|
372
|
+
let testPaths = [];
|
|
373
|
+
try {
|
|
374
|
+
testPaths = (await loadSddManifest(c.sddPath, { cliVersion: CLI_VERSION })).journeys?.testPaths ?? [];
|
|
375
|
+
}
|
|
376
|
+
catch {
|
|
377
|
+
// the member's own build reports an unreadable manifest
|
|
378
|
+
}
|
|
379
|
+
const scratch = new FindingsLog();
|
|
380
|
+
// The contributor's own features: its member build already reported on tags naming them.
|
|
381
|
+
const reportedFeatureIds = new Set(merged.features.filter((f) => String(f["source_sdd_id"] ?? "") === c.sddId).map((f) => String(f["id"])));
|
|
382
|
+
for (const row of await buildJourneyRequirement({ sddPath: c.sddPath, findings: scratch, testPaths, reportedFeatureIds, ...merged })) {
|
|
383
|
+
const id = String(row["id"]);
|
|
384
|
+
if (seen.has(id))
|
|
385
|
+
continue;
|
|
386
|
+
seen.add(id);
|
|
387
|
+
rederived.push({ ...row, source_sdd_id: c.sddId });
|
|
388
|
+
}
|
|
389
|
+
// Still external against the merged rows: unresolved. An orphan or divergence about another contributor's feature
|
|
390
|
+
// (a tag naming a platform requirement that does not exist, or that two of its requirements share) was invisible
|
|
391
|
+
// to the member build, so it is reported here.
|
|
392
|
+
for (const f of scratch.all()) {
|
|
393
|
+
if (f.category === "external")
|
|
394
|
+
unresolved.push(`${c.sddId}: ${f.summary.replace(/; resolves at aggregation.*$/, "")}`);
|
|
395
|
+
else if (f.category === "orphan" || f.category === "divergence")
|
|
396
|
+
unresolved.push(`${c.sddId}: ${f.summary}`);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
const before = (rowsByCatalogue.get("journey_requirement") ?? []).length;
|
|
400
|
+
rederiveAppend(rowsByCatalogue, "journey_requirement", rederived);
|
|
401
|
+
const added = (rowsByCatalogue.get("journey_requirement") ?? []).length - before;
|
|
402
|
+
if (added > 0) {
|
|
403
|
+
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, Proposed; ${contributors.length} contributor(s) read)`);
|
|
404
|
+
}
|
|
405
|
+
for (const u of unresolved)
|
|
406
|
+
findings.add("orphan", `journey_requirement: unresolved against the merged catalogue: ${u}`);
|
|
407
|
+
}
|
|
345
408
|
const repoEdges = buildRepoEdges({
|
|
346
409
|
findings,
|
|
347
410
|
configs: [],
|
|
@@ -553,6 +616,7 @@ export async function runCatalogueAggregate(inputs, write) {
|
|
|
553
616
|
`resolving; neither is worth deleting the rows over.`);
|
|
554
617
|
}
|
|
555
618
|
}
|
|
619
|
+
await removeRetiredCatalogues(outDir, findings, "catalogues-aggregated");
|
|
556
620
|
await writeAggregationManifestJson({
|
|
557
621
|
outDir,
|
|
558
622
|
schemaVersion: schema.schemaVersion,
|
|
@@ -16,6 +16,11 @@
|
|
|
16
16
|
* still produces a valid but empty `catalogues/` directory — useful as a
|
|
17
17
|
* smoke test that the infrastructure works end-to-end on any SDD repo.
|
|
18
18
|
*/
|
|
19
|
+
import { overlayModel } from "../structure/catalogue.js";
|
|
20
|
+
import { buildDataModelElements, readDataModelSources } from "./builders/data-model.js";
|
|
21
|
+
import { removeRetiredCatalogues } from "./retired-catalogues.js";
|
|
22
|
+
import { loadModel } from "../structure/model.js";
|
|
23
|
+
import { viewOf } from "../structure/view.js";
|
|
19
24
|
import { checkCapabilityServicePopulation } from "./population-checks.js";
|
|
20
25
|
import { basename, join, relative, resolve } from "node:path";
|
|
21
26
|
import { copyFile, mkdir, readFile, stat } from "node:fs/promises";
|
|
@@ -35,6 +40,9 @@ import { buildDataItems, buildFeatureDemand, buildIntegrationSupply } from "./bu
|
|
|
35
40
|
import { buildEvidenceTargetRepos } from "./builders/evidence-target-repo.js";
|
|
36
41
|
import { buildRepoDependencies } from "./builders/repo-dependency.js";
|
|
37
42
|
import { buildRequirements } from "./builders/requirements.js";
|
|
43
|
+
import { deriveAdrGoverns } from "./builders/adr-governs.js";
|
|
44
|
+
import { buildContractRevisions } from "./builders/contract-revisions.js";
|
|
45
|
+
import { buildJourneyRequirement } from "./builders/journey-requirement.js";
|
|
38
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";
|
|
39
47
|
import { buildIntegrationComponents } from "./builders/integration-components.js";
|
|
40
48
|
import { loadReverseProductQuality } from "./reverse-product-quality.js";
|
|
@@ -67,7 +75,14 @@ export async function runCatalogueBuild(inputs, write) {
|
|
|
67
75
|
`If the manifest follows a newer sdd-manifest-schema than this build, upgrade ` +
|
|
68
76
|
`sdd-cli; otherwise remove or correct the key.`);
|
|
69
77
|
}
|
|
70
|
-
const
|
|
78
|
+
const collected = await collectRows(schema, manifest, findings, sddPath, inputs.snapshotStore);
|
|
79
|
+
// 3 (then). Where the SDD has a structural model, it is the record of structure: every structure catalogue is built from
|
|
80
|
+
// its current view, and what the sources say otherwise is reported (M-ADR 0230 §4.3, Proposed).
|
|
81
|
+
const modelProblems = [];
|
|
82
|
+
const modelFiles = await loadModel(sddPath, modelProblems);
|
|
83
|
+
const rowsByCatalogue = modelFiles === null ? collected : overlayModel(schema, viewOf(modelFiles, modelProblems), collected, findings);
|
|
84
|
+
for (const p of modelProblems)
|
|
85
|
+
findings.add("convention", `${p} (M-ADR 0230, Proposed)`);
|
|
71
86
|
// 3a. Normalise every keyed catalogue to PK-unique rows. Multi-source edge
|
|
72
87
|
// tables (feature_service, feature_repo) can have the same fact asserted by
|
|
73
88
|
// more than one walker; each walker's own seen-set only dedupes intra-walker.
|
|
@@ -117,6 +132,9 @@ export async function runCatalogueBuild(inputs, write) {
|
|
|
117
132
|
`resolving; neither is worth deleting the rows over.`);
|
|
118
133
|
}
|
|
119
134
|
}
|
|
135
|
+
// 4a. A catalogue the schema has retired leaves the SDD's own catalogues/ with its replacement's first build.
|
|
136
|
+
if (outDir === ownCatalogues)
|
|
137
|
+
await removeRetiredCatalogues(outDir, findings, "catalogues");
|
|
120
138
|
// 5. Internal FK validation (external FKs logged as findings). Pass the
|
|
121
139
|
// SDD profile so profile-external FKs (schema 2.23.0, e.g. an app/integration
|
|
122
140
|
// capability's platform-resident domain_id per M-ADR 0136) resolve at
|
|
@@ -190,6 +208,7 @@ export async function runCatalogueBuild(inputs, write) {
|
|
|
190
208
|
pkConflicts: dedupe.conflicts.length,
|
|
191
209
|
unionedRows: dedupe.unionedRows,
|
|
192
210
|
outDir,
|
|
211
|
+
findings: findings.all(),
|
|
193
212
|
};
|
|
194
213
|
}
|
|
195
214
|
/**
|
|
@@ -1163,13 +1182,30 @@ async function collectRows(schema, manifest, findings, sddPath, snapshotStore) {
|
|
|
1163
1182
|
findings,
|
|
1164
1183
|
knownFeatureIds: new Set((out.get("features") ?? []).map((row) => String(row["id"]))),
|
|
1165
1184
|
knownRepoIds: knownRepoIdsFinal,
|
|
1185
|
+
sddId: manifest.sddId,
|
|
1166
1186
|
});
|
|
1167
1187
|
out.set("requirements", r.requirements);
|
|
1168
1188
|
out.set("requirement_lineage", r.lineage);
|
|
1169
|
-
out.set("
|
|
1189
|
+
out.set("requirement_revisions", r.revisions);
|
|
1190
|
+
out.set("requirement_assertions", r.assertions);
|
|
1191
|
+
out.set("change_effects", r.effects);
|
|
1170
1192
|
out.set("changes", r.changes);
|
|
1171
1193
|
out.set("change_work_order", r.workOrders);
|
|
1172
1194
|
out.set("change_work_item", r.workItems);
|
|
1195
|
+
// M-ADR 0230 §4.2 (Proposed): which view each decision governs, now the store's changes are known.
|
|
1196
|
+
out.set("adrs", deriveAdrGoverns(out.get("adrs") ?? [], out.get("adr_supersedes") ?? [], r.changes, findings));
|
|
1197
|
+
// M-ADR 0230 §4.2 (Proposed): each contract's revisions by digest, active beside agreed.
|
|
1198
|
+
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
|
|
1200
|
+
// journey feature's living spec.
|
|
1201
|
+
out.set("journey_requirement", await buildJourneyRequirement({
|
|
1202
|
+
sddPath,
|
|
1203
|
+
findings,
|
|
1204
|
+
testPaths: manifest.journeys?.testPaths ?? [],
|
|
1205
|
+
features: out.get("features") ?? [],
|
|
1206
|
+
requirements: r.requirements,
|
|
1207
|
+
knownJourneyIds: new Set((out.get("user_journeys") ?? []).map((row) => String(row["id"]))),
|
|
1208
|
+
}));
|
|
1173
1209
|
}
|
|
1174
1210
|
// 15. C4 systems + containers + components (L1 + L2 + L3). The L3 walker
|
|
1175
1211
|
// is the forward-method authoring path reading
|
|
@@ -1388,6 +1424,13 @@ async function collectRows(schema, manifest, findings, sddPath, snapshotStore) {
|
|
|
1388
1424
|
entityRows,
|
|
1389
1425
|
});
|
|
1390
1426
|
out.set("entities", enrichedEntityRows);
|
|
1427
|
+
// 17a. The domain models' own content (M-ADR 0230 §4.3, §4.11 proof 7, Proposed; schema 6.0.0): each entity's
|
|
1428
|
+
// attributes, the relationships with both ends' cardinality and roles, and the keys as invariants, from the same
|
|
1429
|
+
// parse the entities came from.
|
|
1430
|
+
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);
|
|
1431
|
+
out.set("entity_attributes", dataModel.entityAttributes);
|
|
1432
|
+
out.set("entity_relationships", dataModel.entityRelationships);
|
|
1433
|
+
out.set("domain_invariants", dataModel.domainInvariants);
|
|
1391
1434
|
const serviceEntityRows = await buildServiceEntityFromTables({
|
|
1392
1435
|
sddPath,
|
|
1393
1436
|
findings,
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export function deriveAdrGoverns(adrs, supersedes, changes, findings) {
|
|
2
|
+
const changeState = (name) => {
|
|
3
|
+
const rows = changes.filter((c) => c["name"] === name);
|
|
4
|
+
if (rows.some((c) => c["status"] === "archived"))
|
|
5
|
+
return "archived";
|
|
6
|
+
if (rows.some((c) => c["status"] === "withdrawn" || c["status"] === "superseded"))
|
|
7
|
+
return "closed";
|
|
8
|
+
return rows.length > 0 ? "open" : "absent";
|
|
9
|
+
};
|
|
10
|
+
const accepted = new Map();
|
|
11
|
+
for (const a of adrs) {
|
|
12
|
+
if (!/^accepted\b/i.test(String(a["status"] ?? "")))
|
|
13
|
+
continue;
|
|
14
|
+
const change = String(a["effective_change"] ?? "");
|
|
15
|
+
if (change === "") {
|
|
16
|
+
accepted.set(String(a["id"]), "current");
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
const state = changeState(change);
|
|
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)`);
|
|
22
|
+
accepted.set(String(a["id"]), state === "archived" ? "current" : state === "open" ? "intended" : "none");
|
|
23
|
+
}
|
|
24
|
+
const successors = new Map();
|
|
25
|
+
for (const s of supersedes) {
|
|
26
|
+
const old = String(s["superseded_adr_id"]);
|
|
27
|
+
successors.set(old, [...(successors.get(old) ?? []), String(s["superseding_adr_id"])]);
|
|
28
|
+
}
|
|
29
|
+
return adrs.map((a) => {
|
|
30
|
+
const id = String(a["id"]);
|
|
31
|
+
let governs = accepted.get(id) ?? "none";
|
|
32
|
+
if (/^superseded\b/i.test(String(a["status"] ?? ""))) {
|
|
33
|
+
const next = (successors.get(id) ?? []).map((s) => accepted.get(s) ?? "none");
|
|
34
|
+
governs = next.length > 0 && !next.includes("current") ? "current" : "none";
|
|
35
|
+
}
|
|
36
|
+
return { ...a, governs };
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
//# sourceMappingURL=adr-governs.js.map
|
|
@@ -19,6 +19,8 @@
|
|
|
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):
|
|
23
|
+
// effective_change holds the change; governs is derived once the store's changes are known (adr-governs.ts)
|
|
22
24
|
//
|
|
23
25
|
// ID canonicalisation: filename stem `ADR XXXX[: Title]` → `ADR-{0-padded}`.
|
|
24
26
|
// Title = the portion after the colon when present, else the file stem
|
|
@@ -80,6 +82,12 @@ export async function buildAdrs(inputs) {
|
|
|
80
82
|
const decisionDate = extractField(text, "Date") || "";
|
|
81
83
|
const supersedesRaw = extractField(text, "Supersedes") || "";
|
|
82
84
|
const supersededByRaw = extractField(text, "Superseded By") || "";
|
|
85
|
+
const effectiveRaw = (extractField(text, "Effective") || "").trim();
|
|
86
|
+
const onArchive = /^on archive of\s+`?([A-Za-z0-9][A-Za-z0-9._-]*)`?\s*\.?$/i.exec(effectiveRaw);
|
|
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)`);
|
|
89
|
+
}
|
|
90
|
+
const effectiveChange = onArchive?.[1] ?? "";
|
|
83
91
|
if (scope === "domain" && !inputs.knownDomainIds.has(domainId)) {
|
|
84
92
|
inputs.findings.add("orphan", `${relPath}: ADR is in domain "${domainId}" but no docs/domains/${domainId}/ exists`);
|
|
85
93
|
}
|
|
@@ -97,6 +105,7 @@ export async function buildAdrs(inputs) {
|
|
|
97
105
|
docPath: relPath,
|
|
98
106
|
supersedesRaw,
|
|
99
107
|
supersededByRaw,
|
|
108
|
+
effectiveChange,
|
|
100
109
|
});
|
|
101
110
|
}
|
|
102
111
|
if (methodScopeSkipCount > 0) {
|
|
@@ -112,6 +121,8 @@ export async function buildAdrs(inputs) {
|
|
|
112
121
|
superseded_by_id: "", // populated in a backfill pass after adr_supersedes builds
|
|
113
122
|
inference_confidence: "",
|
|
114
123
|
supersedes_raw: a.supersedesRaw,
|
|
124
|
+
effective_change: a.effectiveChange,
|
|
125
|
+
governs: "", // derived once the store's changes are known (adr-governs.ts)
|
|
115
126
|
source_sdd_id: "",
|
|
116
127
|
}));
|
|
117
128
|
return { rows, parsed };
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// contract_revisions: a contract version's content revisions, by digest (M-ADR 0230 §4.2, Proposed; schema 6.0.0).
|
|
2
|
+
// active the contract file's current content (deprecated where the contract is)
|
|
3
|
+
// agreed a revision an agreed open change agrees (agreement record 1.2.0, contracts[].agrees)
|
|
4
|
+
// retired a revision an archived change agreed that the file no longer holds
|
|
5
|
+
// 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.
|
|
7
|
+
//
|
|
8
|
+
// A record names a contract by the file in its own repository (`file:`, or a `files:` list), which the catalogue holds
|
|
9
|
+
// under the SDD's mount of that repository; so a record's file matches the contract whose spec_path ends with it, and
|
|
10
|
+
// a file matching no contract, or several, is a finding and produces no row. `relies_on` and `agrees` are a digest
|
|
11
|
+
// beside `file:`, or a map from each file to its digest beside `files:`. A `relies_on` that no longer matches the
|
|
12
|
+
// file's content is reported: the change relies on a revision that has moved, and is re-agreed.
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
14
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { parse as parseYaml } from "yaml";
|
|
17
|
+
const AGREEMENTS = join("docs", "agreements");
|
|
18
|
+
const DIGEST = /^sha256:[0-9a-f]{64}$/;
|
|
19
|
+
export function contentDigest(bytes) {
|
|
20
|
+
return `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
|
|
21
|
+
}
|
|
22
|
+
const revisionId = (contract, digest) => `${contract}@${digest.slice("sha256:".length, "sha256:".length + 12)}`;
|
|
23
|
+
/** Each (file, relies_on, agrees) a record's contracts list pins. */
|
|
24
|
+
function pins(record, where, findings) {
|
|
25
|
+
const r = record?.agreement_record;
|
|
26
|
+
if (!Array.isArray(r?.contracts))
|
|
27
|
+
return [];
|
|
28
|
+
const out = [];
|
|
29
|
+
for (const [i, raw] of r.contracts.entries()) {
|
|
30
|
+
const c = raw;
|
|
31
|
+
if (!c)
|
|
32
|
+
continue;
|
|
33
|
+
const files = typeof c["file"] === "string" ? [c["file"]] : Array.isArray(c["files"]) ? c["files"].map(String) : [];
|
|
34
|
+
const digestFor = (key, file) => {
|
|
35
|
+
const v = c[key];
|
|
36
|
+
if (v === undefined)
|
|
37
|
+
return undefined;
|
|
38
|
+
const d = typeof v === "string" && typeof c["file"] === "string" ? v : typeof v === "object" && v !== null ? v[file] : undefined;
|
|
39
|
+
if (d === undefined)
|
|
40
|
+
return undefined;
|
|
41
|
+
if (typeof d !== "string" || !DIGEST.test(d)) {
|
|
42
|
+
findings.add("convention", `${where}: contracts[${i}].${key} for ${file} is not a sha256:<64 hex> digest`);
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
return d;
|
|
46
|
+
};
|
|
47
|
+
for (const file of files) {
|
|
48
|
+
const reliesOn = digestFor("relies_on", file);
|
|
49
|
+
const agrees = digestFor("agrees", file);
|
|
50
|
+
if (reliesOn || agrees)
|
|
51
|
+
out.push({ file, ...(reliesOn ? { reliesOn } : {}), ...(agrees ? { agrees } : {}) });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
export async function buildContractRevisions(inputs) {
|
|
57
|
+
const { sddPath, findings } = inputs;
|
|
58
|
+
const rows = new Map();
|
|
59
|
+
const current = new Map(); // contract id -> current digest
|
|
60
|
+
for (const c of inputs.contracts) {
|
|
61
|
+
const path = String(c["spec_path"] ?? "");
|
|
62
|
+
if (path === "")
|
|
63
|
+
continue;
|
|
64
|
+
const bytes = await readFile(join(sddPath, path)).catch(() => null);
|
|
65
|
+
if (bytes === null)
|
|
66
|
+
continue;
|
|
67
|
+
const id = String(c["id"]);
|
|
68
|
+
const digest = contentDigest(bytes);
|
|
69
|
+
current.set(id, digest);
|
|
70
|
+
const rid = revisionId(id, digest);
|
|
71
|
+
rows.set(rid, { id: rid, contract_id: id, digest, state: c["status"] === "deprecated" ? "deprecated" : "active", sunset_date: "", change_id: "", source_sdd_id: "", platform_sdd_id: "" });
|
|
72
|
+
}
|
|
73
|
+
const byFile = (file) => inputs.contracts.filter((c) => String(c["spec_path"] ?? "").endsWith(`/${file}`) || c["spec_path"] === file).map((c) => String(c["id"]));
|
|
74
|
+
let names = [];
|
|
75
|
+
try {
|
|
76
|
+
names = (await readdir(join(sddPath, AGREEMENTS))).filter((n) => n.endsWith(".yaml")).sort();
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
names = [];
|
|
80
|
+
}
|
|
81
|
+
for (const name of names) {
|
|
82
|
+
const change = name.slice(0, -".yaml".length);
|
|
83
|
+
const where = join(AGREEMENTS, name);
|
|
84
|
+
let doc;
|
|
85
|
+
try {
|
|
86
|
+
doc = parseYaml(await readFile(join(sddPath, where), "utf8"));
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
continue; // the behaviour-store check reports an unparseable record
|
|
90
|
+
}
|
|
91
|
+
const changeRows = inputs.changes.filter((c) => c["name"] === change);
|
|
92
|
+
const archived = changeRows.find((c) => c["status"] === "archived");
|
|
93
|
+
const open = changeRows.find((c) => c["status"] !== "archived");
|
|
94
|
+
const state = archived ? "archived" : open?.["status"] === "agreed" ? "agreed" : "other";
|
|
95
|
+
if (state === "other")
|
|
96
|
+
continue;
|
|
97
|
+
const changeId = String((archived ?? open)["id"]);
|
|
98
|
+
for (const p of pins(doc, where, findings)) {
|
|
99
|
+
const matches = byFile(p.file);
|
|
100
|
+
if (matches.length !== 1) {
|
|
101
|
+
findings.add("convention", `${where}: contract file ${p.file} matches ${matches.length === 0 ? "no catalogued contract" : `${matches.length} catalogued contracts (${matches.join(", ")})`}; no revision row`);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const contract = matches[0];
|
|
105
|
+
const now = current.get(contract);
|
|
106
|
+
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)`);
|
|
108
|
+
}
|
|
109
|
+
if (!p.agrees)
|
|
110
|
+
continue;
|
|
111
|
+
const rid = revisionId(contract, p.agrees);
|
|
112
|
+
const existing = rows.get(rid);
|
|
113
|
+
if (p.agrees === now) {
|
|
114
|
+
if (existing && state === "archived")
|
|
115
|
+
rows.set(rid, { ...existing, change_id: changeId });
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (state === "agreed")
|
|
119
|
+
rows.set(rid, { id: rid, contract_id: contract, digest: p.agrees, state: "agreed", sunset_date: "", change_id: changeId, source_sdd_id: "", platform_sdd_id: "" });
|
|
120
|
+
else if (!existing)
|
|
121
|
+
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
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return [...rows.values()].sort((a, b) => String(a["id"]).localeCompare(String(b["id"])));
|
|
125
|
+
}
|
|
126
|
+
//# sourceMappingURL=contract-revisions.js.map
|