@sdd-method/author-cli 0.11.0 → 0.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/lib/catalogue/build.js +33 -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/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/integration-components.js +49 -5
- package/dist/lib/catalogue/builders/quality-scores.js +17 -0
- package/dist/lib/catalogue/builders/requirements.js +132 -23
- package/dist/lib/catalogue/builders/user-journeys.js +8 -13
- package/dist/lib/catalogue/canonical-schema.yaml +767 -17
- package/dist/lib/catalogue/retired-catalogues.js +25 -0
- package/dist/lib/catalogue/rubric-coverage-map.yaml +90 -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/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 +20 -8
- package/dist/lib/validate/behaviour-store.js +54 -13
- package/dist/lib/validate/dispatch.js +7 -0
- package/dist/lib/validate/journey-impact.js +17 -2
- 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
|
@@ -14,20 +14,19 @@
|
|
|
14
14
|
// - Files may mix `erDiagram` with other Mermaid diagram types (flowchart,
|
|
15
15
|
// sequenceDiagram). Each fenced block is independently checked for the
|
|
16
16
|
// erDiagram opener; non-ER blocks are skipped.
|
|
17
|
+
// The file is read by the data-model parser (data-model.ts, schema 6.0.0), the
|
|
18
|
+
// one parser every reader of a domain model shares; an entity on one line
|
|
19
|
+
// (`NAME { type a; type b }`) is an entity too, and a `data-model.tql` in the
|
|
20
|
+
// documented TypeQL subset gives its entities the same way.
|
|
17
21
|
import { readFile } from "node:fs/promises";
|
|
22
|
+
import { parseMermaidDataModel, parseTypeqlDataModel } from "./data-model.js";
|
|
18
23
|
import { entitySlug } from "./entity-slug.js";
|
|
19
24
|
import { extractFileEvidenceClass } from "./evidence-class.js";
|
|
20
|
-
const RE_MERMAID_OPEN = /^```\s*mermaid\s*$/i;
|
|
21
|
-
const RE_FENCE_CLOSE = /^```\s*$/;
|
|
22
|
-
const RE_COMMENT = /^\s*%%/;
|
|
23
|
-
const RE_ENTITY_OPEN = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*\{\s*$/;
|
|
24
|
-
const RE_REL_LINE = /^\s*[A-Za-z_][A-Za-z0-9_]*\s+\|?\|?[o|\-]+\{?\s+[A-Za-z]/;
|
|
25
|
-
const MERMAID_KEYWORDS = new Set(["erdiagram", "subgraph", "classdef", "style", "class"]);
|
|
26
25
|
export async function buildEntitiesFromMermaid(inputs) {
|
|
27
26
|
const rows = [];
|
|
28
27
|
const seenAcrossFiles = new Map(); // id → originating doc_path
|
|
29
28
|
for (const dm of inputs.domainModels) {
|
|
30
|
-
if (!dm.doc_path.endsWith(".mermaid.md"))
|
|
29
|
+
if (!dm.doc_path.endsWith(".mermaid.md") && !dm.doc_path.endsWith(".tql"))
|
|
31
30
|
continue;
|
|
32
31
|
const docPath = dm.doc_path.startsWith("/")
|
|
33
32
|
? dm.doc_path
|
|
@@ -74,87 +73,21 @@ export async function buildEntitiesFromMermaid(inputs) {
|
|
|
74
73
|
return rows;
|
|
75
74
|
}
|
|
76
75
|
function parseFile(text, dm, findings) {
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
let isEr = false;
|
|
82
|
-
let firstKeywordSeen = false;
|
|
83
|
-
let inEntity = false;
|
|
84
|
-
let entityName = null;
|
|
85
|
-
let entityColumns = 0;
|
|
86
|
-
for (let i = 0; i < lines.length; i++) {
|
|
87
|
-
const raw = lines[i] ?? "";
|
|
88
|
-
if (!inMermaid) {
|
|
89
|
-
if (RE_MERMAID_OPEN.test(raw)) {
|
|
90
|
-
inMermaid = true;
|
|
91
|
-
isEr = false;
|
|
92
|
-
firstKeywordSeen = false;
|
|
93
|
-
}
|
|
94
|
-
continue;
|
|
95
|
-
}
|
|
96
|
-
// in_mermaid == true
|
|
97
|
-
if (RE_FENCE_CLOSE.test(raw)) {
|
|
98
|
-
inMermaid = false;
|
|
99
|
-
isEr = false;
|
|
100
|
-
firstKeywordSeen = false;
|
|
101
|
-
// If we exit mid-entity, drop it silently — malformed block.
|
|
102
|
-
inEntity = false;
|
|
103
|
-
entityName = null;
|
|
104
|
-
continue;
|
|
105
|
-
}
|
|
106
|
-
const stripped = raw.trim();
|
|
107
|
-
if (!firstKeywordSeen && stripped !== "" && !RE_COMMENT.test(raw)) {
|
|
108
|
-
firstKeywordSeen = true;
|
|
109
|
-
isEr = stripped.toLowerCase().startsWith("erdiagram");
|
|
110
|
-
}
|
|
111
|
-
if (!isEr)
|
|
112
|
-
continue;
|
|
113
|
-
if (inEntity) {
|
|
114
|
-
if (stripped.startsWith("}")) {
|
|
115
|
-
if (entityName !== null) {
|
|
116
|
-
const slug = entitySlug(entityName);
|
|
117
|
-
if (!seenInFile.has(slug)) {
|
|
118
|
-
seenInFile.add(slug);
|
|
119
|
-
out.push({
|
|
120
|
-
name: entityName,
|
|
121
|
-
domainId: dm.domain_id,
|
|
122
|
-
sourceDomainModelId: dm.id,
|
|
123
|
-
columnCount: entityColumns,
|
|
124
|
-
});
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
inEntity = false;
|
|
128
|
-
entityName = null;
|
|
129
|
-
entityColumns = 0;
|
|
130
|
-
continue;
|
|
131
|
-
}
|
|
132
|
-
if (RE_COMMENT.test(raw))
|
|
133
|
-
continue;
|
|
134
|
-
if (stripped)
|
|
135
|
-
entityColumns++;
|
|
136
|
-
continue;
|
|
137
|
-
}
|
|
138
|
-
// Not in an entity block — look for an opener.
|
|
139
|
-
if (RE_COMMENT.test(raw))
|
|
140
|
-
continue;
|
|
141
|
-
if (RE_REL_LINE.test(raw))
|
|
142
|
-
continue;
|
|
143
|
-
const m = RE_ENTITY_OPEN.exec(raw);
|
|
144
|
-
if (m) {
|
|
145
|
-
const ident = m[1] ?? "";
|
|
146
|
-
if (MERMAID_KEYWORDS.has(ident.toLowerCase()))
|
|
147
|
-
continue;
|
|
148
|
-
entityName = ident;
|
|
149
|
-
inEntity = true;
|
|
150
|
-
entityColumns = 0;
|
|
151
|
-
}
|
|
76
|
+
const parsed = dm.doc_path.endsWith(".tql") ? parseTypeqlDataModel(text, dm.doc_path) : parseMermaidDataModel(text, dm.doc_path);
|
|
77
|
+
if ("refused" in parsed) {
|
|
78
|
+
findings.add("extraction", `entities: ${dm.doc_path} is outside the documented TypeQL subset, so none of its entities is catalogued: ${parsed.refused}`, "warn", dm.doc_path);
|
|
79
|
+
return [];
|
|
152
80
|
}
|
|
153
81
|
// Defensive: if a block ended without closing an entity, log it once.
|
|
154
|
-
if (
|
|
155
|
-
findings.add("convention", `entities-mermaid: ${dm.doc_path}: unclosed entity block for "${
|
|
82
|
+
if (parsed.unclosed !== null) {
|
|
83
|
+
findings.add("convention", `entities-mermaid: ${dm.doc_path}: unclosed entity block for "${parsed.unclosed}" — discarded`);
|
|
156
84
|
}
|
|
157
|
-
return
|
|
85
|
+
return parsed.entities.map((e) => ({
|
|
86
|
+
name: e.name,
|
|
87
|
+
domainId: dm.domain_id,
|
|
88
|
+
sourceDomainModelId: dm.id,
|
|
89
|
+
columnCount: e.columnCount,
|
|
90
|
+
}));
|
|
158
91
|
}
|
|
159
92
|
function resolveGlossaryTermBySlug(entityName, domainId, knownGlossaryIds) {
|
|
160
93
|
const slug = entitySlug(entityName);
|
|
@@ -176,9 +176,8 @@ export async function buildIntegrationComponents(inputs) {
|
|
|
176
176
|
}
|
|
177
177
|
// SECOND HOME (§9.1): a full `integration-manifest.yaml` per component repo under
|
|
178
178
|
// `repos/<component>/`. Read after the array form so `seen` already holds the
|
|
179
|
-
// array-form ids
|
|
180
|
-
//
|
|
181
|
-
// independently and nothing stops them meeting.
|
|
179
|
+
// array-form ids: a component in both homes that agrees on every tier field is
|
|
180
|
+
// one declaration stated twice, and one that disagrees is a divergence (below).
|
|
182
181
|
rows.push(...(await readComponentManifests({
|
|
183
182
|
sddPath: inputs.sddPath,
|
|
184
183
|
findings: inputs.findings,
|
|
@@ -186,6 +185,7 @@ export async function buildIntegrationComponents(inputs) {
|
|
|
186
185
|
knownRepoIds: inputs.knownRepoIds,
|
|
187
186
|
knownServiceIds: inputs.knownServiceIds,
|
|
188
187
|
seen,
|
|
188
|
+
arrayRows: new Map(rows.map((r) => [String(r["id"]), r])),
|
|
189
189
|
})));
|
|
190
190
|
checkAggregate({
|
|
191
191
|
findings: inputs.findings,
|
|
@@ -196,6 +196,43 @@ export async function buildIntegrationComponents(inputs) {
|
|
|
196
196
|
});
|
|
197
197
|
return rows;
|
|
198
198
|
}
|
|
199
|
+
/** The fields the component tier reads, compared when a component is declared in both homes. */
|
|
200
|
+
const TIER_FIELDS = [
|
|
201
|
+
"component_type",
|
|
202
|
+
"direction",
|
|
203
|
+
"frequency",
|
|
204
|
+
"transport",
|
|
205
|
+
"format",
|
|
206
|
+
"auth",
|
|
207
|
+
"ingestion_pattern",
|
|
208
|
+
"invocation_pattern",
|
|
209
|
+
"pattern_qualifier",
|
|
210
|
+
"primary_entities",
|
|
211
|
+
];
|
|
212
|
+
function tierValue(m, f) {
|
|
213
|
+
switch (f) {
|
|
214
|
+
case "component_type":
|
|
215
|
+
return (m.componentType ?? "").trim();
|
|
216
|
+
case "direction":
|
|
217
|
+
return (m.dataFlow?.direction ?? "").trim();
|
|
218
|
+
case "frequency":
|
|
219
|
+
return (m.dataFlow?.frequency ?? "").trim();
|
|
220
|
+
case "transport":
|
|
221
|
+
return (m.protocol?.transport ?? "").trim();
|
|
222
|
+
case "format":
|
|
223
|
+
return (m.protocol?.format ?? "").trim();
|
|
224
|
+
case "auth":
|
|
225
|
+
return (m.protocol?.auth ?? "").trim();
|
|
226
|
+
case "ingestion_pattern":
|
|
227
|
+
return (m.ingestionPattern ?? "").trim();
|
|
228
|
+
case "invocation_pattern":
|
|
229
|
+
return (m.invocationPattern ?? "").trim();
|
|
230
|
+
case "pattern_qualifier":
|
|
231
|
+
return (m.patternQualifier ?? "").trim();
|
|
232
|
+
case "primary_entities":
|
|
233
|
+
return (m.dataFlow?.primaryEntities ?? []).join(";");
|
|
234
|
+
}
|
|
235
|
+
}
|
|
199
236
|
/** `repos/` — where the per-component manifests live (M-ADR 0171 §9.1, second home). */
|
|
200
237
|
const REPOS_DIR = "repos";
|
|
201
238
|
/**
|
|
@@ -261,8 +298,15 @@ async function readComponentManifests(args) {
|
|
|
261
298
|
const componentId = (parsed.data.integrationId ?? "").trim() || dir;
|
|
262
299
|
const id = `${args.integrationId}__${componentId}`;
|
|
263
300
|
if (args.seen.has(id)) {
|
|
264
|
-
|
|
265
|
-
|
|
301
|
+
// The reverse template has the array mirror the per-component manifests, so a component in both homes is
|
|
302
|
+
// expected. A mirror that agrees on every field the tier reads is one declaration stated twice, and is not a
|
|
303
|
+
// finding; one that disagrees is, and the array's declaration is kept.
|
|
304
|
+
const mirrored = args.arrayRows.get(id);
|
|
305
|
+
const differs = mirrored === undefined ? ["(not an array row)"] : TIER_FIELDS.filter((f) => String(mirrored[f] ?? "") !== tierValue(parsed.data, f));
|
|
306
|
+
if (differs.length > 0) {
|
|
307
|
+
args.findings.add("divergence", `integration_component: component id="${id}" is declared in both §9.1 homes (components array and ${rel}) and they disagree on ${differs.join(", ")}; ` +
|
|
308
|
+
`the components array's declaration is kept. Correct whichever is stale.`);
|
|
309
|
+
}
|
|
266
310
|
continue;
|
|
267
311
|
}
|
|
268
312
|
args.seen.add(id);
|
|
@@ -118,6 +118,19 @@ const num = (v) => {
|
|
|
118
118
|
}
|
|
119
119
|
return null;
|
|
120
120
|
};
|
|
121
|
+
/**
|
|
122
|
+
* The review's other assessed inputs (M-ADR 0230 §4.9, Proposed): `inputs:` as a list of paths, or of `{path}`
|
|
123
|
+
* entries, each read at the review's corpus_ref. ';'-joined; null when the review names none.
|
|
124
|
+
*/
|
|
125
|
+
function inputPaths(v) {
|
|
126
|
+
if (!Array.isArray(v))
|
|
127
|
+
return null;
|
|
128
|
+
const paths = v
|
|
129
|
+
.map((x) => (typeof x === "string" ? x : isRecord(x) && typeof x["path"] === "string" ? x["path"] : ""))
|
|
130
|
+
.map((x) => x.trim())
|
|
131
|
+
.filter((x) => x !== "");
|
|
132
|
+
return paths.length > 0 ? paths.join(";") : null;
|
|
133
|
+
}
|
|
121
134
|
/**
|
|
122
135
|
* Count raised flags across the two authored dialects:
|
|
123
136
|
* `flags: { over_scope_risk: true, ... }` -> count the true ones
|
|
@@ -258,6 +271,7 @@ function bandedRows(block, ctx) {
|
|
|
258
271
|
band_total: num(block["total"]),
|
|
259
272
|
scored_at: ctx.scoredAt,
|
|
260
273
|
corpus_ref: ctx.corpusRef,
|
|
274
|
+
input_paths: ctx.inputPaths,
|
|
261
275
|
staleness_basis: stalenessBasis,
|
|
262
276
|
review_run_id: null,
|
|
263
277
|
selection_method: null,
|
|
@@ -371,6 +385,7 @@ export async function buildQualityScores(inputs) {
|
|
|
371
385
|
targetPath,
|
|
372
386
|
scoredAt: fileScoredAt,
|
|
373
387
|
corpusRef,
|
|
388
|
+
inputPaths: inputPaths(unitVal["inputs"]),
|
|
374
389
|
assessedBy: fileAssessedBy,
|
|
375
390
|
sddId,
|
|
376
391
|
});
|
|
@@ -396,6 +411,7 @@ export async function buildQualityScores(inputs) {
|
|
|
396
411
|
targetPath: str(unitVal["doc_path"]),
|
|
397
412
|
scoredAt: str(current["scored_at"]) ?? fileScoredAt,
|
|
398
413
|
corpusRef,
|
|
414
|
+
inputPaths: inputPaths(current["inputs"] ?? unitVal["inputs"]),
|
|
399
415
|
assessedBy: fileAssessedBy,
|
|
400
416
|
sddId,
|
|
401
417
|
});
|
|
@@ -540,6 +556,7 @@ export async function buildQualityScores(inputs) {
|
|
|
540
556
|
band_total: null,
|
|
541
557
|
scored_at: str(current["scored_at"]) ?? str(entryVal["scored_at"]),
|
|
542
558
|
corpus_ref: corpusRef,
|
|
559
|
+
input_paths: inputPaths(current["inputs"] ?? entryVal["inputs"]),
|
|
543
560
|
staleness_basis: stalenessBasis,
|
|
544
561
|
review_run_id: str(current["review_run_id"]),
|
|
545
562
|
selection_method: selectionMethod,
|
|
@@ -18,10 +18,17 @@
|
|
|
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
|
-
//
|
|
21
|
+
// requirement_revisions one row per distinct text of a requirement (schema 6.0.0, M-ADR 0230 §4.7, Proposed): the
|
|
22
|
+
// living spec's is current, an agreed open change's ADDED or MODIFIED block intended, an
|
|
23
|
+
// archived change's block the living spec no longer holds history. Identified by its digest.
|
|
24
|
+
// requirement_assertions per (revision, repository): observed from a baseline's `**Observed in:**` line;
|
|
22
25
|
// declared-shipped from an archived change whose agreement record's verdict is PASS, through
|
|
23
|
-
// its work orders' `implements` lists;
|
|
24
|
-
//
|
|
26
|
+
// its work orders' `implements` lists; each about the revision that change's delta states, so
|
|
27
|
+
// evidence about one revision is never read as evidence about another. The latest wins, in
|
|
28
|
+
// archive order. An agreed change is not an assertion: it is an intended revision.
|
|
29
|
+
// change_effects per (change, element): each requirement a change's delta writes, from its base revision to
|
|
30
|
+
// its target, open and archived changes alike; and each structural element its structural
|
|
31
|
+
// deltas write (an archived change's from its effect record, structure/applied.yaml).
|
|
25
32
|
//
|
|
26
33
|
// Archive order is ../../behaviour-store/archive.ts's; same-day archives the history cannot order are a finding.
|
|
27
34
|
//
|
|
@@ -29,8 +36,25 @@
|
|
|
29
36
|
import { readdir, readFile, stat } from "node:fs/promises";
|
|
30
37
|
import { join } from "node:path";
|
|
31
38
|
import { parse as parseYaml } from "yaml";
|
|
39
|
+
import { createHash } from "node:crypto";
|
|
32
40
|
import { observedIn, orderArchives, parseRequirements, renamePairs, resolveImplements } from "../../behaviour-store/archive.js";
|
|
41
|
+
import { effectsOf, parseAppliedRecord } from "../../structure/effects.js";
|
|
42
|
+
import { APPLIED_FILE, loadChangeStructure, loadModel } from "../../structure/model.js";
|
|
43
|
+
import { viewOf } from "../../structure/view.js";
|
|
44
|
+
import { requirementBlock } from "../../validate/agreement-evidence.js";
|
|
33
45
|
export { observedIn, parseRequirements, resolveImplements };
|
|
46
|
+
/** `sha256:` of a requirement block, its lines trimmed and blank lines dropped (M-ADR 0230 §4.7). */
|
|
47
|
+
export function requirementDigest(spec, name) {
|
|
48
|
+
const block = requirementBlock(spec, name);
|
|
49
|
+
if (block === null)
|
|
50
|
+
return undefined;
|
|
51
|
+
const normal = block.split("\n").map((l) => l.trim()).filter((l) => l !== "").join("\n");
|
|
52
|
+
return `sha256:${createHash("sha256").update(normal, "utf8").digest("hex")}`;
|
|
53
|
+
}
|
|
54
|
+
/** A revision's id: <requirement_id>@<the digest's first 12 hex digits>. */
|
|
55
|
+
export function revisionId(requirementId, digest) {
|
|
56
|
+
return `${requirementId}@${digest.slice("sha256:".length, "sha256:".length + 12)}`;
|
|
57
|
+
}
|
|
34
58
|
const STORE = "openspec";
|
|
35
59
|
const AGREEMENTS = join("docs", "agreements");
|
|
36
60
|
async function isDir(p) {
|
|
@@ -65,8 +89,10 @@ async function deltaOf(changeDir) {
|
|
|
65
89
|
if (text === null)
|
|
66
90
|
continue;
|
|
67
91
|
for (const r of parseRequirements(text)) {
|
|
68
|
-
if (r.operation)
|
|
69
|
-
|
|
92
|
+
if (!r.operation)
|
|
93
|
+
continue;
|
|
94
|
+
const digest = r.operation === "ADDED" || r.operation === "MODIFIED" ? requirementDigest(text, r.name) : undefined;
|
|
95
|
+
out.push({ feature, name: r.name, operation: r.operation, file, scenarios: r.scenarios, ...(digest ? { digest } : {}) });
|
|
70
96
|
}
|
|
71
97
|
}
|
|
72
98
|
return out;
|
|
@@ -126,14 +152,24 @@ export function parseWorkItems(proposal) {
|
|
|
126
152
|
return ids;
|
|
127
153
|
}
|
|
128
154
|
const rel = (sddPath, p) => p.slice(sddPath.length + 1).split("\\").join("/");
|
|
155
|
+
/** One effect per (change, element): a change that renames and modifies a requirement records MODIFIED, as its lineage does. */
|
|
156
|
+
function dedupeEffects(rows) {
|
|
157
|
+
const byId = new Map();
|
|
158
|
+
for (const r of rows) {
|
|
159
|
+
const prior = byId.get(String(r["id"]));
|
|
160
|
+
byId.set(String(r["id"]), prior && r["operation"] === "RENAMED" ? prior : r);
|
|
161
|
+
}
|
|
162
|
+
return [...byId.values()];
|
|
163
|
+
}
|
|
129
164
|
export async function buildRequirements(inputs) {
|
|
130
165
|
const { sddPath, findings } = inputs;
|
|
131
166
|
const scoped = (change) => `${inputs.sddId}::${change}`;
|
|
132
|
-
const empty = { requirements: [], lineage: [],
|
|
167
|
+
const empty = { requirements: [], lineage: [], revisions: [], assertions: [], effects: [], changes: [], workOrders: [], workItems: [] };
|
|
133
168
|
const store = join(sddPath, STORE);
|
|
134
169
|
if (!(await isDir(store)))
|
|
135
170
|
return empty;
|
|
136
171
|
const requirements = new Map();
|
|
172
|
+
const livingDigest = new Map();
|
|
137
173
|
const unknownFeatures = new Set();
|
|
138
174
|
const known = (feature) => {
|
|
139
175
|
if (inputs.knownFeatureIds.has(feature))
|
|
@@ -154,9 +190,26 @@ export async function buildRequirements(inputs) {
|
|
|
154
190
|
continue;
|
|
155
191
|
}
|
|
156
192
|
requirements.set(id, { id, feature_id: feature, name: r.name, stage: "living", spec_path: rel(sddPath, file), scenario_count: r.scenarios, source_sdd_id: "", platform_sdd_id: "" });
|
|
193
|
+
const digest = requirementDigest(text, r.name);
|
|
194
|
+
if (digest)
|
|
195
|
+
livingDigest.set(id, digest);
|
|
157
196
|
}
|
|
158
197
|
}
|
|
159
198
|
const events = [];
|
|
199
|
+
const revisions = new Map();
|
|
200
|
+
const requirementEffects = [];
|
|
201
|
+
const structureEffects = [];
|
|
202
|
+
const effectRow = (changeId, kind, element, operation, base, target) => ({
|
|
203
|
+
id: `${changeId}__${kind}__${element}`,
|
|
204
|
+
change_id: changeId,
|
|
205
|
+
element_kind: kind,
|
|
206
|
+
element_id: element,
|
|
207
|
+
operation,
|
|
208
|
+
base_revision: base,
|
|
209
|
+
target_revision: target,
|
|
210
|
+
source_sdd_id: "",
|
|
211
|
+
platform_sdd_id: "",
|
|
212
|
+
});
|
|
160
213
|
const lineage = [];
|
|
161
214
|
const unknownRepos = new Set();
|
|
162
215
|
const changes = [];
|
|
@@ -214,7 +267,13 @@ export async function buildRequirements(inputs) {
|
|
|
214
267
|
lineage: { change_id: scoped(dir), change_kind: kind, operation: d.operation, archived_on: archivedOn, source_sdd_id: "", platform_sdd_id: "" },
|
|
215
268
|
...(d.operation === "REMOVED" ? { removedIn: rel(sddPath, d.file) } : {}),
|
|
216
269
|
});
|
|
270
|
+
archived.push({ index, feature: d.feature, name: d.name, effect: { change: scoped(dir), operation: d.operation, ...(d.digest ? { digest: d.digest } : {}) } });
|
|
217
271
|
}
|
|
272
|
+
// Structural effects, from the effect record the method's archive wrote (M-ADR 0230 §4.6).
|
|
273
|
+
const applied = await readText(join(changeDir, "structure", APPLIED_FILE));
|
|
274
|
+
const rec = applied === null ? null : parseAppliedRecord(applied);
|
|
275
|
+
for (const e of rec?.effects ?? [])
|
|
276
|
+
structureEffects.push(effectRow(scoped(dir), "structure", e.element, e.operation, e.base ?? "", e.target ?? ""));
|
|
218
277
|
if (evidence !== null) {
|
|
219
278
|
const repos = observedIn(evidence);
|
|
220
279
|
if (repos.length === 0) {
|
|
@@ -222,8 +281,8 @@ export async function buildRequirements(inputs) {
|
|
|
222
281
|
}
|
|
223
282
|
for (const repo of repos)
|
|
224
283
|
for (const d of delta)
|
|
225
|
-
if (d.
|
|
226
|
-
archived.push({ index, feature: d.feature, name: d.name, event: { repo, status: "observed", basis: scoped(dir) } });
|
|
284
|
+
if (d.digest)
|
|
285
|
+
archived.push({ index, feature: d.feature, name: d.name, event: { repo, status: "observed", basis: scoped(dir), digest: d.digest } });
|
|
227
286
|
continue;
|
|
228
287
|
}
|
|
229
288
|
const record = await agreementRecord(sddPath, change);
|
|
@@ -242,7 +301,8 @@ export async function buildRequirements(inputs) {
|
|
|
242
301
|
findings.add("convention", `${AGREEMENTS}/${change}.yaml: work order for ${wo.repo} implements "${name}", which the change's delta does not name`);
|
|
243
302
|
}
|
|
244
303
|
for (const d of match)
|
|
245
|
-
|
|
304
|
+
if (d.digest)
|
|
305
|
+
archived.push({ index, feature: d.feature, name: d.name, event: { repo: wo.repo, status: "declared-shipped", basis: scoped(dir), digest: d.digest } });
|
|
246
306
|
}
|
|
247
307
|
}
|
|
248
308
|
}
|
|
@@ -266,8 +326,25 @@ export async function buildRequirements(inputs) {
|
|
|
266
326
|
// One lineage row per (requirement, change): a change that renames and modifies a requirement records MODIFIED,
|
|
267
327
|
// the behaviour change, and its delta still shows the rename.
|
|
268
328
|
const lineageById = new Map();
|
|
329
|
+
// Revisions in archive order: each ADDED or MODIFIED block is a revision its change introduced; the last one of a
|
|
330
|
+
// requirement is the base of the next change's effect on it.
|
|
331
|
+
const latest = new Map();
|
|
269
332
|
for (const a of archived) {
|
|
270
333
|
const requirementId = current(a.feature, a.name, a.index);
|
|
334
|
+
if (a.effect) {
|
|
335
|
+
if (!requirements.has(requirementId))
|
|
336
|
+
continue;
|
|
337
|
+
const base = latest.get(requirementId);
|
|
338
|
+
const target = a.effect.digest ? revisionId(requirementId, a.effect.digest) : undefined;
|
|
339
|
+
if (a.effect.digest && target) {
|
|
340
|
+
revisions.set(target, { id: target, requirement_id: requirementId, digest: a.effect.digest, state: "history", change_id: a.effect.change, source_sdd_id: "", platform_sdd_id: "" });
|
|
341
|
+
latest.set(requirementId, target);
|
|
342
|
+
}
|
|
343
|
+
if (a.effect.operation === "REMOVED")
|
|
344
|
+
latest.delete(requirementId);
|
|
345
|
+
requirementEffects.push(effectRow(a.effect.change, "requirement", requirementId, a.effect.operation, base ?? "", a.effect.operation === "RENAMED" ? base ?? "" : target ?? ""));
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
271
348
|
if (a.event)
|
|
272
349
|
pushEvent({ requirementId, ...a.event });
|
|
273
350
|
else if (a.lineage && requirements.has(requirementId)) {
|
|
@@ -278,6 +355,12 @@ export async function buildRequirements(inputs) {
|
|
|
278
355
|
}
|
|
279
356
|
}
|
|
280
357
|
lineage.push(...lineageById.values());
|
|
358
|
+
// The living text is the current revision, whichever change introduced it (none, for an edit no change made).
|
|
359
|
+
for (const [id, digest] of livingDigest) {
|
|
360
|
+
const rid = revisionId(id, digest);
|
|
361
|
+
const prior = revisions.get(rid);
|
|
362
|
+
revisions.set(rid, { id: rid, requirement_id: id, digest, state: "current", change_id: prior?.["change_id"] ?? "", source_sdd_id: "", platform_sdd_id: "" });
|
|
363
|
+
}
|
|
281
364
|
// 3. Open changes whose agreement record is agreed.
|
|
282
365
|
for (const dir of (await dirs(join(store, "changes"))).filter((d) => d !== "archive")) {
|
|
283
366
|
const record = await agreementRecord(sddPath, dir);
|
|
@@ -288,44 +371,70 @@ export async function buildRequirements(inputs) {
|
|
|
288
371
|
openStatus = "proposed";
|
|
289
372
|
}
|
|
290
373
|
await recordChange(dir, dir, openKind, openStatus, "", join(store, "changes", dir), record);
|
|
291
|
-
if (record?.status !== "agreed")
|
|
292
|
-
continue;
|
|
293
374
|
const delta = await deltaOf(join(store, "changes", dir));
|
|
375
|
+
const agreed = record?.status === "agreed";
|
|
294
376
|
for (const d of delta) {
|
|
295
377
|
const id = `${d.feature}::${d.name}`;
|
|
296
|
-
if (d.operation === "ADDED" && !requirements.has(id) && known(d.feature)) {
|
|
378
|
+
if (agreed && d.operation === "ADDED" && !requirements.has(id) && known(d.feature)) {
|
|
297
379
|
requirements.set(id, { id, feature_id: d.feature, name: d.name, stage: "agreed", spec_path: rel(sddPath, d.file), scenario_count: d.scenarios, source_sdd_id: "", platform_sdd_id: "" });
|
|
298
380
|
}
|
|
381
|
+
// An effect for every element the change writes: a requirement a change not yet agreed ADDs has no
|
|
382
|
+
// requirements row yet, and still has its effect (change_effects.element_id names no foreign key).
|
|
383
|
+
if (!requirements.has(id) && !(d.operation === "ADDED" && known(d.feature)))
|
|
384
|
+
continue;
|
|
385
|
+
const living = livingDigest.get(id);
|
|
386
|
+
const base = living ? revisionId(id, living) : "";
|
|
387
|
+
const target = d.digest ? revisionId(id, d.digest) : "";
|
|
388
|
+
// An agreed change's text is an intended revision; one not yet agreed has an effect and no revision row.
|
|
389
|
+
if (agreed && d.digest && !revisions.has(target)) {
|
|
390
|
+
revisions.set(target, { id: target, requirement_id: id, digest: d.digest, state: "intended", change_id: scoped(dir), source_sdd_id: "", platform_sdd_id: "" });
|
|
391
|
+
}
|
|
392
|
+
requirementEffects.push(effectRow(scoped(dir), "requirement", id, d.operation, d.operation === "ADDED" ? "" : base, d.operation === "REMOVED" ? "" : d.operation === "RENAMED" ? base : target));
|
|
299
393
|
}
|
|
394
|
+
if (!agreed)
|
|
395
|
+
continue;
|
|
300
396
|
for (const wo of record.work_orders ?? []) {
|
|
301
397
|
if (!wo.repo)
|
|
302
398
|
continue;
|
|
303
399
|
for (const name of wo.implements ?? []) {
|
|
304
|
-
const {
|
|
400
|
+
const { ambiguous } = resolveImplements(delta.filter((x) => x.operation !== "REMOVED"), name);
|
|
305
401
|
if (ambiguous.length > 0) {
|
|
306
|
-
findings.add("divergence", `${AGREEMENTS}/${dir}.yaml: work order for ${wo.repo} implements "${name}", a name ${ambiguous.length} features in the change share (${ambiguous.join(", ")}); qualify it as <feature>::<name
|
|
307
|
-
}
|
|
308
|
-
for (const d of matches) {
|
|
309
|
-
pushEvent({ requirementId: `${d.feature}::${d.name}`, repo: wo.repo, status: "agreed", basis: scoped(dir) });
|
|
402
|
+
findings.add("divergence", `${AGREEMENTS}/${dir}.yaml: work order for ${wo.repo} implements "${name}", a name ${ambiguous.length} features in the change share (${ambiguous.join(", ")}); qualify it as <feature>::<name>`);
|
|
310
403
|
}
|
|
311
404
|
}
|
|
312
405
|
}
|
|
313
406
|
}
|
|
407
|
+
// Open changes' structural effects, against the model as it stands (M-ADR 0230 §4.4, §4.7).
|
|
408
|
+
{
|
|
409
|
+
const problems = [];
|
|
410
|
+
const files = await loadModel(sddPath, problems);
|
|
411
|
+
const view = viewOf(files ?? [], problems);
|
|
412
|
+
for (const dir of (await dirs(join(store, "changes"))).filter((d) => d !== "archive")) {
|
|
413
|
+
const s = await loadChangeStructure(sddPath, dir, problems);
|
|
414
|
+
for (const e of effectsOf(s, view))
|
|
415
|
+
structureEffects.push(effectRow(scoped(dir), "structure", e.element, e.operation, e.base ?? "", e.target ?? ""));
|
|
416
|
+
}
|
|
417
|
+
}
|
|
314
418
|
for (const f of unknownFeatures)
|
|
315
419
|
findings.add("convention", `${STORE}/: a spec keyed "${f}" names no catalogue feature, so its requirements are not catalogued (M-ADR 0221 §4.7)`);
|
|
316
420
|
for (const r of unknownRepos)
|
|
317
|
-
findings.add("convention", `${STORE}/:
|
|
318
|
-
// Latest
|
|
319
|
-
const
|
|
421
|
+
findings.add("convention", `${STORE}/: an assertion names repository "${r}", which the catalogue does not hold; no assertion row`);
|
|
422
|
+
// Latest assertion per (revision, repository) wins.
|
|
423
|
+
const assertions = new Map();
|
|
320
424
|
for (const e of events) {
|
|
321
|
-
const
|
|
322
|
-
|
|
425
|
+
const rid = revisionId(e.requirementId, e.digest);
|
|
426
|
+
if (!revisions.has(rid))
|
|
427
|
+
continue;
|
|
428
|
+
const id = `${rid}__${e.repo}`;
|
|
429
|
+
assertions.set(id, { id, revision_id: rid, requirement_id: e.requirementId, repo_id: e.repo, kind: e.status, basis: e.basis, source_sdd_id: "", platform_sdd_id: "" });
|
|
323
430
|
}
|
|
324
431
|
const byId = (a, b) => String(a["id"]).localeCompare(String(b["id"]));
|
|
325
432
|
return {
|
|
326
433
|
requirements: [...requirements.values()].sort(byId),
|
|
327
434
|
lineage,
|
|
328
|
-
|
|
435
|
+
revisions: [...revisions.values()].sort(byId),
|
|
436
|
+
assertions: [...assertions.values()].sort(byId),
|
|
437
|
+
effects: [...dedupeEffects(requirementEffects), ...structureEffects].sort(byId),
|
|
329
438
|
changes: changes.sort(byId),
|
|
330
439
|
workOrders: workOrders.sort(byId),
|
|
331
440
|
workItems: workItems.sort(byId),
|
|
@@ -14,7 +14,10 @@
|
|
|
14
14
|
// ## Trigger
|
|
15
15
|
// ...
|
|
16
16
|
//
|
|
17
|
-
//
|
|
17
|
+
// Any SDD catalogues the journeys it owns: an application its own, and a platform root a journey that no single
|
|
18
|
+
// application owns (a cross-application journey, M-ADR 0146, or one that runs through platform services only), whose
|
|
19
|
+
// journey feature names it with @journey:<id> (M-ADR 0229 §4.2, option B). **App:** is omitted where no app owns it.
|
|
20
|
+
// Only an application is expected to have journeys/, so only there is its absence a coverage finding.
|
|
18
21
|
// id = `{product_id}-{slug}`, slug from filename stem.
|
|
19
22
|
import { readdir, readFile } from "node:fs/promises";
|
|
20
23
|
import { basename, join, relative } from "node:path";
|
|
@@ -26,17 +29,6 @@ export async function buildUserJourneys(inputs) {
|
|
|
26
29
|
const relatedFeatureSlugsById = new Map();
|
|
27
30
|
const primaryPersonaById = new Map();
|
|
28
31
|
const supportingPersonasById = new Map();
|
|
29
|
-
if (inputs.profile !== "application-product") {
|
|
30
|
-
return {
|
|
31
|
-
rows: [],
|
|
32
|
-
idByPath,
|
|
33
|
-
idBySlug,
|
|
34
|
-
featurePathsById,
|
|
35
|
-
relatedFeatureSlugsById,
|
|
36
|
-
primaryPersonaById,
|
|
37
|
-
supportingPersonasById,
|
|
38
|
-
};
|
|
39
|
-
}
|
|
40
32
|
const dir = join(inputs.sddPath, JOURNEYS_DIR_REL);
|
|
41
33
|
let entries;
|
|
42
34
|
try {
|
|
@@ -45,7 +37,10 @@ export async function buildUserJourneys(inputs) {
|
|
|
45
37
|
catch (err) {
|
|
46
38
|
if (err.code !== "ENOENT")
|
|
47
39
|
throw err;
|
|
48
|
-
|
|
40
|
+
// An application is expected to describe its journeys; another SDD holds journeys only where it owns one.
|
|
41
|
+
if (inputs.profile === "application-product") {
|
|
42
|
+
inputs.findings.add("coverage", `${JOURNEYS_DIR_REL}/ not found — emitting empty user_journeys.csv`);
|
|
43
|
+
}
|
|
49
44
|
return {
|
|
50
45
|
rows: [],
|
|
51
46
|
idByPath,
|