@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
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// journey_requirement: the requirements a user journey cites for its steps (M-ADR 0229 §4.4, Proposed; schema 4.4.0).
|
|
2
|
+
//
|
|
3
|
+
// Derived, never authored, from either master (§4.2):
|
|
4
|
+
// tag option A: the end-to-end `.feature` files under the manifest's `journeys.test_paths`. A file tagged
|
|
5
|
+
// `@journey:<journey-id>` cites every `@req:<feature-id>/<requirement-slug>` tag it carries.
|
|
6
|
+
// citation option B: the living spec (openspec/specs/<feature-id>/spec.md) of a feature of kind journey names its
|
|
7
|
+
// journey with the same `@journey:<journey-id>` tag and cites each step's requirement with `@req:`.
|
|
8
|
+
// One tag grammar for both, so the derivation needs only the files and the catalogue's rows. Both are read whenever
|
|
9
|
+
// present, so an estate moving from A to B shows both while it moves.
|
|
10
|
+
//
|
|
11
|
+
// A requirement tag cannot carry spaces, so it names the requirement by slug: its name lower-cased, each run of
|
|
12
|
+
// anything but a-z and 0-9 turned into one hyphen. A tag resolves when exactly one requirement of that feature,
|
|
13
|
+
// living or agreed (not retired), has that slug; a tag naming a retired requirement names none, so it is a finding. A tag that resolves to none or to several, a file with two journey tags, or a test
|
|
14
|
+
// path that does not exist is a finding and produces no row, so no foreign key dangles. A tag naming a feature or a
|
|
15
|
+
// journey this catalogue does not hold is reported as external: journeys are often catalogued in application SDDs
|
|
16
|
+
// and requirements in the platform's store, so the row is derived at aggregation, where both are merged
|
|
17
|
+
// (catalogue aggregate re-runs this builder over each contributor). A journey with no row is untraced, which the
|
|
18
|
+
// journey-impact check reports.
|
|
19
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
20
|
+
import { join, relative } from "node:path";
|
|
21
|
+
const REQ_TAG = /@req:([a-z0-9][a-z0-9-]*)\/([a-z0-9][a-z0-9-]*)/g;
|
|
22
|
+
const JOURNEY_TAG = /@journey:([a-z0-9][a-z0-9-]*)/g;
|
|
23
|
+
/** "Order shipped (REST)" -> "order-shipped-rest". */
|
|
24
|
+
export function requirementSlug(name) {
|
|
25
|
+
return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
26
|
+
}
|
|
27
|
+
async function featureFiles(dir) {
|
|
28
|
+
const out = [];
|
|
29
|
+
let entries;
|
|
30
|
+
try {
|
|
31
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
for (const e of entries) {
|
|
37
|
+
if (e.name === "node_modules" || e.name.startsWith("."))
|
|
38
|
+
continue;
|
|
39
|
+
const p = join(dir, e.name);
|
|
40
|
+
if (e.isDirectory())
|
|
41
|
+
out.push(...(await featureFiles(p)));
|
|
42
|
+
else if (e.isFile() && e.name.endsWith(".feature"))
|
|
43
|
+
out.push(p);
|
|
44
|
+
}
|
|
45
|
+
return out.sort();
|
|
46
|
+
}
|
|
47
|
+
export async function buildJourneyRequirement(inputs) {
|
|
48
|
+
// At aggregation the member builds have already reported every finding about their own files and features.
|
|
49
|
+
const atAggregate = inputs.reportedFeatureIds !== undefined;
|
|
50
|
+
const { sddPath, findings } = inputs;
|
|
51
|
+
const bySlug = new Map();
|
|
52
|
+
for (const r of inputs.requirements) {
|
|
53
|
+
if (String(r["stage"] ?? "") === "retired")
|
|
54
|
+
continue; // a journey step cannot rely on behaviour that has been removed
|
|
55
|
+
const key = `${String(r["feature_id"])}/${requirementSlug(String(r["name"]))}`;
|
|
56
|
+
const ids = bySlug.get(key) ?? [];
|
|
57
|
+
if (!ids.includes(String(r["id"])))
|
|
58
|
+
ids.push(String(r["id"]));
|
|
59
|
+
bySlug.set(key, ids);
|
|
60
|
+
}
|
|
61
|
+
const rows = new Map();
|
|
62
|
+
const knownFeatures = new Set(inputs.features.map((f) => String(f["id"])));
|
|
63
|
+
const cite = (journeyId, text, where, basis) => {
|
|
64
|
+
for (const m of text.matchAll(REQ_TAG)) {
|
|
65
|
+
const quiet = atAggregate && inputs.reportedFeatureIds.has(m[1]);
|
|
66
|
+
if (!knownFeatures.has(m[1])) {
|
|
67
|
+
findings.add("external", `${where}: @req:${m[1]}/${m[2]} names a feature this catalogue does not hold; resolves at aggregation (M-ADR 0229 §4.4, Proposed)`);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const ids = bySlug.get(`${m[1]}/${m[2]}`) ?? [];
|
|
71
|
+
if (ids.length !== 1) {
|
|
72
|
+
if (!quiet)
|
|
73
|
+
findings.add("orphan", `${where}: @req:${m[1]}/${m[2]} ${ids.length === 0 ? "names no requirement of that feature" : `matches ${ids.length} requirements of that feature`}; no journey_requirement row (M-ADR 0229 §4.4, Proposed)`);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const id = `${journeyId}__${ids[0]}`;
|
|
77
|
+
if (!rows.has(id))
|
|
78
|
+
rows.set(id, { id, journey_id: journeyId, requirement_id: ids[0], basis, source_sdd_id: "", platform_sdd_id: "" });
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
// Option A: tagged end-to-end files.
|
|
82
|
+
for (const rel of inputs.testPaths) {
|
|
83
|
+
const dir = join(sddPath, rel);
|
|
84
|
+
const exists = await stat(dir).then((s) => s.isDirectory(), () => false);
|
|
85
|
+
if (!exists) {
|
|
86
|
+
if (!atAggregate)
|
|
87
|
+
findings.add("orphan", `.sdd/sdd-manifest.yaml: journeys.test_paths names ${rel}, which is not a directory in this SDD`);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
for (const file of await featureFiles(dir)) {
|
|
91
|
+
const where = relative(sddPath, file);
|
|
92
|
+
const text = await readFile(file, "utf8");
|
|
93
|
+
const journeys = [...new Set([...text.matchAll(JOURNEY_TAG)].map((m) => m[1]))];
|
|
94
|
+
if (journeys.length === 0)
|
|
95
|
+
continue;
|
|
96
|
+
if (journeys.length > 1) {
|
|
97
|
+
if (!atAggregate)
|
|
98
|
+
findings.add("divergence", `${where}: tagged with ${journeys.length} journeys (${journeys.join(", ")}); one file is one journey's master (M-ADR 0229 §4.2, Proposed)`);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const journeyId = journeys[0];
|
|
102
|
+
if (!inputs.knownJourneyIds.has(journeyId)) {
|
|
103
|
+
findings.add("external", `${where}: @journey:${journeyId} is not a user journey this catalogue holds; resolves at aggregation`);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
cite(journeyId, text, where, "tag");
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
// Option B: journey features in the store, each naming its journey in its living spec.
|
|
110
|
+
const kindById = new Map(inputs.features.map((f) => [String(f["id"]), String(f["kind"] ?? "")]));
|
|
111
|
+
let specDirs = [];
|
|
112
|
+
try {
|
|
113
|
+
specDirs = (await readdir(join(sddPath, "openspec", "specs"), { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name).sort();
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
// no store here
|
|
117
|
+
}
|
|
118
|
+
for (const featureId of specDirs) {
|
|
119
|
+
if (kindById.get(featureId) !== "journey")
|
|
120
|
+
continue;
|
|
121
|
+
const where = join("openspec", "specs", featureId, "spec.md");
|
|
122
|
+
let spec;
|
|
123
|
+
try {
|
|
124
|
+
spec = await readFile(join(sddPath, where), "utf8");
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const journeys = [...new Set([...spec.matchAll(JOURNEY_TAG)].map((m) => m[1]))];
|
|
130
|
+
if (journeys.length !== 1) {
|
|
131
|
+
if (!atAggregate)
|
|
132
|
+
findings.add(journeys.length === 0 ? "orphan" : "divergence", `${where}: a journey feature's living spec ${journeys.length === 0 ? "names no journey (@journey:<journey-id>)" : `names ${journeys.length} journeys`}; one journey feature masters one journey (M-ADR 0229 §4.2, Proposed)`);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const journeyId = journeys[0];
|
|
136
|
+
if (!inputs.knownJourneyIds.has(journeyId)) {
|
|
137
|
+
findings.add("external", `${where}: @journey:${journeyId} is not a user journey this catalogue holds; resolves at aggregation`);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
cite(journeyId, spec, where, "citation");
|
|
141
|
+
}
|
|
142
|
+
return [...rows.values()].sort((a, b) => String(a["id"]).localeCompare(String(b["id"])));
|
|
143
|
+
}
|
|
144
|
+
//# sourceMappingURL=journey-requirement.js.map
|
|
@@ -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,
|
|
@@ -7,18 +7,28 @@
|
|
|
7
7
|
// requirements one row per `### Requirement:` in a living spec (stage living), keyed by the spec's
|
|
8
8
|
// directory name, which is the catalogue feature id; plus one per requirement an open,
|
|
9
9
|
// agreed change ADDS (stage agreed).
|
|
10
|
-
// requirement_lineage each archived change, in archive order, whose delta ADDED, MODIFIED or
|
|
11
|
-
// requirement
|
|
10
|
+
// requirement_lineage each archived change, in archive order, whose delta ADDED, MODIFIED, RENAMED or REMOVED a
|
|
11
|
+
// requirement, under any name a later RENAMED gave it. A change carrying
|
|
12
12
|
// baseline-evidence.md is a baseline. Statuses follow renames the same way.
|
|
13
|
+
// A requirement REMOVED by an archived change and not in a living spec now is kept in
|
|
14
|
+
// requirements as stage retired (schema 4.3.0), so its lineage ends in the REMOVED row;
|
|
15
|
+
// it carries no status.
|
|
13
16
|
// changes each change in the store, open or archived (M-ADR 0223 §4.2): its directory name, its
|
|
14
17
|
// undated name, kind, status (archived, or its agreement record's) and verdict.
|
|
15
18
|
// change_work_order per (change, repository): the record's work order, agreed or completed.
|
|
16
19
|
// change_work_item per (change, work item): the ids its proposal's `## Work Items` section lists, keyed by
|
|
17
20
|
// the undated name so the row survives archive.
|
|
18
|
-
//
|
|
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;
|
|
19
25
|
// declared-shipped from an archived change whose agreement record's verdict is PASS, through
|
|
20
|
-
// its work orders' `implements` lists;
|
|
21
|
-
//
|
|
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).
|
|
22
32
|
//
|
|
23
33
|
// Archive order is ../../behaviour-store/archive.ts's; same-day archives the history cannot order are a finding.
|
|
24
34
|
//
|
|
@@ -26,8 +36,25 @@
|
|
|
26
36
|
import { readdir, readFile, stat } from "node:fs/promises";
|
|
27
37
|
import { join } from "node:path";
|
|
28
38
|
import { parse as parseYaml } from "yaml";
|
|
29
|
-
import {
|
|
30
|
-
|
|
39
|
+
import { createHash } from "node:crypto";
|
|
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";
|
|
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
|
+
}
|
|
31
58
|
const STORE = "openspec";
|
|
32
59
|
const AGREEMENTS = join("docs", "agreements");
|
|
33
60
|
async function isDir(p) {
|
|
@@ -62,8 +89,10 @@ async function deltaOf(changeDir) {
|
|
|
62
89
|
if (text === null)
|
|
63
90
|
continue;
|
|
64
91
|
for (const r of parseRequirements(text)) {
|
|
65
|
-
if (r.operation)
|
|
66
|
-
|
|
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 } : {}) });
|
|
67
96
|
}
|
|
68
97
|
}
|
|
69
98
|
return out;
|
|
@@ -123,13 +152,24 @@ export function parseWorkItems(proposal) {
|
|
|
123
152
|
return ids;
|
|
124
153
|
}
|
|
125
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
|
+
}
|
|
126
164
|
export async function buildRequirements(inputs) {
|
|
127
165
|
const { sddPath, findings } = inputs;
|
|
128
|
-
const
|
|
166
|
+
const scoped = (change) => `${inputs.sddId}::${change}`;
|
|
167
|
+
const empty = { requirements: [], lineage: [], revisions: [], assertions: [], effects: [], changes: [], workOrders: [], workItems: [] };
|
|
129
168
|
const store = join(sddPath, STORE);
|
|
130
169
|
if (!(await isDir(store)))
|
|
131
170
|
return empty;
|
|
132
171
|
const requirements = new Map();
|
|
172
|
+
const livingDigest = new Map();
|
|
133
173
|
const unknownFeatures = new Set();
|
|
134
174
|
const known = (feature) => {
|
|
135
175
|
if (inputs.knownFeatureIds.has(feature))
|
|
@@ -150,17 +190,35 @@ export async function buildRequirements(inputs) {
|
|
|
150
190
|
continue;
|
|
151
191
|
}
|
|
152
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);
|
|
153
196
|
}
|
|
154
197
|
}
|
|
155
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
|
+
});
|
|
156
213
|
const lineage = [];
|
|
157
214
|
const unknownRepos = new Set();
|
|
158
215
|
const changes = [];
|
|
159
216
|
const workOrders = [];
|
|
160
217
|
const workItems = [];
|
|
161
218
|
// The change itself, its work orders and its work items (M-ADR 0223 §4.2).
|
|
162
|
-
const recordChange = async (
|
|
219
|
+
const recordChange = async (localId, name, kind, status, archivedOn, changeDir, record) => {
|
|
163
220
|
const verdict = record?.verdict?.result === "PASS" || record?.verdict?.result === "FAIL" ? record.verdict.result : "";
|
|
221
|
+
const id = scoped(localId);
|
|
164
222
|
changes.push({ id, name, change_kind: kind, status, archived_on: archivedOn, verdict, source_sdd_id: "", platform_sdd_id: "" });
|
|
165
223
|
for (const wo of record?.work_orders ?? []) {
|
|
166
224
|
if (!wo.repo)
|
|
@@ -174,7 +232,7 @@ export async function buildRequirements(inputs) {
|
|
|
174
232
|
}
|
|
175
233
|
const proposal = await readText(join(changeDir, "proposal.md"));
|
|
176
234
|
for (const item of proposal === null ? [] : parseWorkItems(proposal)) {
|
|
177
|
-
workItems.push({ id: `${name}__${item}`, change_id: id, change_name: name, work_item_id: item, source_sdd_id: "", platform_sdd_id: "" });
|
|
235
|
+
workItems.push({ id: `${scoped(name)}__${item}`, change_id: id, change_name: name, work_item_id: item, source_sdd_id: "", platform_sdd_id: "" });
|
|
178
236
|
}
|
|
179
237
|
};
|
|
180
238
|
const pushEvent = (e) => {
|
|
@@ -182,7 +240,8 @@ export async function buildRequirements(inputs) {
|
|
|
182
240
|
unknownRepos.add(e.repo);
|
|
183
241
|
return;
|
|
184
242
|
}
|
|
185
|
-
|
|
243
|
+
// A retired requirement has no status: it is not where any repository's behaviour is.
|
|
244
|
+
if (requirements.has(e.requirementId) && requirements.get(e.requirementId)["stage"] !== "retired")
|
|
186
245
|
events.push(e);
|
|
187
246
|
};
|
|
188
247
|
// 2. Archived changes, in archive order. A requirement is named as it was when each change archived, so its
|
|
@@ -201,10 +260,20 @@ export async function buildRequirements(inputs) {
|
|
|
201
260
|
await recordChange(dir, change, kind, "archived", archivedOn, changeDir, await agreementRecord(sddPath, change));
|
|
202
261
|
const delta = await deltaOf(changeDir);
|
|
203
262
|
for (const d of delta) {
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
263
|
+
archived.push({
|
|
264
|
+
index,
|
|
265
|
+
feature: d.feature,
|
|
266
|
+
name: d.name,
|
|
267
|
+
lineage: { change_id: scoped(dir), change_kind: kind, operation: d.operation, archived_on: archivedOn, source_sdd_id: "", platform_sdd_id: "" },
|
|
268
|
+
...(d.operation === "REMOVED" ? { removedIn: rel(sddPath, d.file) } : {}),
|
|
269
|
+
});
|
|
270
|
+
archived.push({ index, feature: d.feature, name: d.name, effect: { change: scoped(dir), operation: d.operation, ...(d.digest ? { digest: d.digest } : {}) } });
|
|
207
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 ?? ""));
|
|
208
277
|
if (evidence !== null) {
|
|
209
278
|
const repos = observedIn(evidence);
|
|
210
279
|
if (repos.length === 0) {
|
|
@@ -212,8 +281,8 @@ export async function buildRequirements(inputs) {
|
|
|
212
281
|
}
|
|
213
282
|
for (const repo of repos)
|
|
214
283
|
for (const d of delta)
|
|
215
|
-
if (d.
|
|
216
|
-
archived.push({ index, feature: d.feature, name: d.name, event: { repo, status: "observed", basis: 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 } });
|
|
217
286
|
continue;
|
|
218
287
|
}
|
|
219
288
|
const record = await agreementRecord(sddPath, change);
|
|
@@ -223,12 +292,17 @@ export async function buildRequirements(inputs) {
|
|
|
223
292
|
if (!wo.repo)
|
|
224
293
|
continue;
|
|
225
294
|
for (const name of wo.implements ?? []) {
|
|
226
|
-
const
|
|
227
|
-
|
|
295
|
+
const { matches, ambiguous } = resolveImplements(delta.filter((d) => d.operation !== "REMOVED"), name);
|
|
296
|
+
const match = matches;
|
|
297
|
+
if (ambiguous.length > 0) {
|
|
298
|
+
findings.add("divergence", `${AGREEMENTS}/${change}.yaml: work order for ${wo.repo} implements "${name}", a name ${ambiguous.length} features in the change share (${ambiguous.join(", ")}); qualify it as <feature>::<name>. No status is derived from it`);
|
|
299
|
+
}
|
|
300
|
+
else if (match.length === 0) {
|
|
228
301
|
findings.add("convention", `${AGREEMENTS}/${change}.yaml: work order for ${wo.repo} implements "${name}", which the change's delta does not name`);
|
|
229
302
|
}
|
|
230
303
|
for (const d of match)
|
|
231
|
-
|
|
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 } });
|
|
232
306
|
}
|
|
233
307
|
}
|
|
234
308
|
}
|
|
@@ -240,11 +314,37 @@ export async function buildRequirements(inputs) {
|
|
|
240
314
|
n = r.to;
|
|
241
315
|
return `${feature}::${n}`;
|
|
242
316
|
};
|
|
317
|
+
// A requirement removed and not living now is retired: kept, so its lineage can end in the removal.
|
|
318
|
+
for (const a of archived) {
|
|
319
|
+
if (!a.removedIn)
|
|
320
|
+
continue;
|
|
321
|
+
const id = current(a.feature, a.name, a.index);
|
|
322
|
+
if (requirements.has(id) || !known(a.feature))
|
|
323
|
+
continue;
|
|
324
|
+
requirements.set(id, { id, feature_id: a.feature, name: id.slice(a.feature.length + 2), stage: "retired", spec_path: a.removedIn, scenario_count: 0, source_sdd_id: "", platform_sdd_id: "" });
|
|
325
|
+
}
|
|
243
326
|
// One lineage row per (requirement, change): a change that renames and modifies a requirement records MODIFIED,
|
|
244
327
|
// the behaviour change, and its delta still shows the rename.
|
|
245
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();
|
|
246
332
|
for (const a of archived) {
|
|
247
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
|
+
}
|
|
248
348
|
if (a.event)
|
|
249
349
|
pushEvent({ requirementId, ...a.event });
|
|
250
350
|
else if (a.lineage && requirements.has(requirementId)) {
|
|
@@ -255,6 +355,12 @@ export async function buildRequirements(inputs) {
|
|
|
255
355
|
}
|
|
256
356
|
}
|
|
257
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
|
+
}
|
|
258
364
|
// 3. Open changes whose agreement record is agreed.
|
|
259
365
|
for (const dir of (await dirs(join(store, "changes"))).filter((d) => d !== "archive")) {
|
|
260
366
|
const record = await agreementRecord(sddPath, dir);
|
|
@@ -265,40 +371,70 @@ export async function buildRequirements(inputs) {
|
|
|
265
371
|
openStatus = "proposed";
|
|
266
372
|
}
|
|
267
373
|
await recordChange(dir, dir, openKind, openStatus, "", join(store, "changes", dir), record);
|
|
268
|
-
if (record?.status !== "agreed")
|
|
269
|
-
continue;
|
|
270
374
|
const delta = await deltaOf(join(store, "changes", dir));
|
|
375
|
+
const agreed = record?.status === "agreed";
|
|
271
376
|
for (const d of delta) {
|
|
272
377
|
const id = `${d.feature}::${d.name}`;
|
|
273
|
-
if (d.operation === "ADDED" && !requirements.has(id) && known(d.feature)) {
|
|
378
|
+
if (agreed && d.operation === "ADDED" && !requirements.has(id) && known(d.feature)) {
|
|
274
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: "" });
|
|
275
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));
|
|
276
393
|
}
|
|
394
|
+
if (!agreed)
|
|
395
|
+
continue;
|
|
277
396
|
for (const wo of record.work_orders ?? []) {
|
|
278
397
|
if (!wo.repo)
|
|
279
398
|
continue;
|
|
280
399
|
for (const name of wo.implements ?? []) {
|
|
281
|
-
|
|
282
|
-
|
|
400
|
+
const { ambiguous } = resolveImplements(delta.filter((x) => x.operation !== "REMOVED"), name);
|
|
401
|
+
if (ambiguous.length > 0) {
|
|
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>`);
|
|
283
403
|
}
|
|
284
404
|
}
|
|
285
405
|
}
|
|
286
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
|
+
}
|
|
287
418
|
for (const f of unknownFeatures)
|
|
288
419
|
findings.add("convention", `${STORE}/: a spec keyed "${f}" names no catalogue feature, so its requirements are not catalogued (M-ADR 0221 §4.7)`);
|
|
289
420
|
for (const r of unknownRepos)
|
|
290
|
-
findings.add("convention", `${STORE}/:
|
|
291
|
-
// Latest
|
|
292
|
-
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();
|
|
293
424
|
for (const e of events) {
|
|
294
|
-
const
|
|
295
|
-
|
|
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: "" });
|
|
296
430
|
}
|
|
297
431
|
const byId = (a, b) => String(a["id"]).localeCompare(String(b["id"]));
|
|
298
432
|
return {
|
|
299
433
|
requirements: [...requirements.values()].sort(byId),
|
|
300
434
|
lineage,
|
|
301
|
-
|
|
435
|
+
revisions: [...revisions.values()].sort(byId),
|
|
436
|
+
assertions: [...assertions.values()].sort(byId),
|
|
437
|
+
effects: [...dedupeEffects(requirementEffects), ...structureEffects].sort(byId),
|
|
302
438
|
changes: changes.sort(byId),
|
|
303
439
|
workOrders: workOrders.sort(byId),
|
|
304
440
|
workItems: workItems.sort(byId),
|