@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,174 @@
|
|
|
1
|
+
// Views over the structural model, and how changes compose (M-ADR 0230 §4.5, §4.7, Proposed).
|
|
2
|
+
// current the model as published
|
|
3
|
+
// at a change current with that change's deltas and those of its dependency closure, dependencies first
|
|
4
|
+
// intended current with every agreed change's deltas
|
|
5
|
+
// Distinct ids do not prove independence, so three checks run:
|
|
6
|
+
// 1 write overlap two agreed changes never MODIFY or REMOVE one element, and never ADD one id; a change
|
|
7
|
+
// not yet agreed that overlaps an agreed one cannot be agreed while that one is open
|
|
8
|
+
// 2 composed validation each view is validated on its own; what fails in a view and not in current is reported
|
|
9
|
+
// against the changes that write the elements it names
|
|
10
|
+
// 3 read dependencies base revisions still current (validateDeltas); a dependency withdrawn invalidates its
|
|
11
|
+
// dependants; an agreed change depending on one not yet agreed; a cycle of dependencies
|
|
12
|
+
// Pure: reads nothing; the structural-model validator uses it.
|
|
13
|
+
import { elementKey } from "./model.js";
|
|
14
|
+
/** Element key -> the write a change makes to it. */
|
|
15
|
+
export function writesOf(s) {
|
|
16
|
+
const out = new Map();
|
|
17
|
+
for (const d of s.deltas) {
|
|
18
|
+
for (const e of d.added)
|
|
19
|
+
out.set(elementKey(e.catalogue, e.id), "ADDED");
|
|
20
|
+
for (const e of d.modified)
|
|
21
|
+
out.set(elementKey(e.catalogue, e.id), "MODIFIED");
|
|
22
|
+
for (const r of d.removed)
|
|
23
|
+
out.set(elementKey(r.catalogue, r.id), "REMOVED");
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
function copy(view) {
|
|
28
|
+
const out = new Map();
|
|
29
|
+
for (const [c, m] of view)
|
|
30
|
+
out.set(c, new Map(m));
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
/** The view with one change's deltas applied: an addition or modification replaces whole, a removal deletes. */
|
|
34
|
+
export function apply(view, s) {
|
|
35
|
+
const out = copy(view);
|
|
36
|
+
const put = (e) => {
|
|
37
|
+
const m = out.get(e.catalogue) ?? new Map();
|
|
38
|
+
m.set(e.id, e);
|
|
39
|
+
out.set(e.catalogue, m);
|
|
40
|
+
};
|
|
41
|
+
for (const d of s.deltas) {
|
|
42
|
+
for (const e of d.added)
|
|
43
|
+
put(e);
|
|
44
|
+
for (const e of d.modified)
|
|
45
|
+
put(e);
|
|
46
|
+
for (const r of d.removed)
|
|
47
|
+
out.get(r.catalogue)?.delete(r.id);
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* A change's dependency closure, dependencies first and the change last. A dependency that is not open is skipped
|
|
53
|
+
* here (archived: its additions are current; otherwise reported as withdrawn by checkDependencies). A cycle stops the
|
|
54
|
+
* walk and is reported there too.
|
|
55
|
+
*/
|
|
56
|
+
export function closure(change, open) {
|
|
57
|
+
const order = [];
|
|
58
|
+
const visiting = new Set();
|
|
59
|
+
const visit = (c) => {
|
|
60
|
+
if (order.includes(c) || visiting.has(c))
|
|
61
|
+
return;
|
|
62
|
+
const oc = open.get(c);
|
|
63
|
+
if (!oc)
|
|
64
|
+
return;
|
|
65
|
+
visiting.add(c);
|
|
66
|
+
for (const d of oc.structure.deltas)
|
|
67
|
+
for (const dep of d.dependsOn)
|
|
68
|
+
visit(dep);
|
|
69
|
+
visiting.delete(c);
|
|
70
|
+
order.push(c);
|
|
71
|
+
};
|
|
72
|
+
visit(change);
|
|
73
|
+
return order;
|
|
74
|
+
}
|
|
75
|
+
export function viewAt(current, change, open) {
|
|
76
|
+
let v = current;
|
|
77
|
+
for (const c of closure(change, open))
|
|
78
|
+
v = apply(v, open.get(c).structure);
|
|
79
|
+
return v;
|
|
80
|
+
}
|
|
81
|
+
/** Agreed changes in an order that applies each after its dependencies. */
|
|
82
|
+
export function agreedOrder(open) {
|
|
83
|
+
const order = [];
|
|
84
|
+
for (const [name, oc] of [...open].sort(([a], [b]) => a.localeCompare(b))) {
|
|
85
|
+
if (oc.status !== "agreed")
|
|
86
|
+
continue;
|
|
87
|
+
for (const c of closure(name, open))
|
|
88
|
+
if (!order.includes(c) && open.get(c).status === "agreed")
|
|
89
|
+
order.push(c);
|
|
90
|
+
}
|
|
91
|
+
return order;
|
|
92
|
+
}
|
|
93
|
+
export function intended(current, open) {
|
|
94
|
+
let v = current;
|
|
95
|
+
for (const c of agreedOrder(open))
|
|
96
|
+
v = apply(v, open.get(c).structure);
|
|
97
|
+
return v;
|
|
98
|
+
}
|
|
99
|
+
/** Check 1: write overlap. */
|
|
100
|
+
export function checkOverlap(open, problems) {
|
|
101
|
+
const names = [...open.keys()].sort();
|
|
102
|
+
const writes = new Map(names.map((n) => [n, writesOf(open.get(n).structure)]));
|
|
103
|
+
for (let i = 0; i < names.length; i++) {
|
|
104
|
+
for (let j = i + 1; j < names.length; j++) {
|
|
105
|
+
const a = names[i];
|
|
106
|
+
const b = names[j];
|
|
107
|
+
const sa = open.get(a).status;
|
|
108
|
+
const sb = open.get(b).status;
|
|
109
|
+
if (sa !== "agreed" && sb !== "agreed")
|
|
110
|
+
continue;
|
|
111
|
+
for (const [key, wa] of writes.get(a)) {
|
|
112
|
+
const wb = writes.get(b).get(key);
|
|
113
|
+
if (wb === undefined)
|
|
114
|
+
continue;
|
|
115
|
+
// One adding what the other modifies or removes is reported by the other's own deltas, whose base cannot exist.
|
|
116
|
+
// Two additions of one id are one id claimed twice, whatever their content.
|
|
117
|
+
if ((wa === "ADDED") !== (wb === "ADDED"))
|
|
118
|
+
continue;
|
|
119
|
+
const verb = wa === "ADDED" ? "both ADD" : wa === wb ? `both ${wa}` : `${wa} and ${wb}`;
|
|
120
|
+
if (sa === "agreed" && sb === "agreed")
|
|
121
|
+
problems.push(`write overlap: agreed changes ${a} and ${b} ${verb} ${key}; reorder or merge them`);
|
|
122
|
+
else {
|
|
123
|
+
const [held, waiting] = sa === "agreed" ? [a, b] : [b, a];
|
|
124
|
+
problems.push(`write overlap: ${waiting} ${verb === "both ADD" ? "adds" : "writes"} ${key}, which agreed change ${held} also ${verb === "both ADD" ? "adds" : "writes"}; it cannot be agreed while ${held} is open`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/** Check 3, beyond base revisions: withdrawn dependencies, agreement before a dependency, cycles. */
|
|
131
|
+
export function checkDependencies(open, archived, problems) {
|
|
132
|
+
for (const [name, oc] of [...open].sort(([a], [b]) => a.localeCompare(b))) {
|
|
133
|
+
const deps = new Set(oc.structure.deltas.flatMap((d) => [...d.dependsOn]));
|
|
134
|
+
for (const dep of deps) {
|
|
135
|
+
const target = open.get(dep);
|
|
136
|
+
if (target === undefined) {
|
|
137
|
+
if (!archived.has(dep))
|
|
138
|
+
problems.push(`read dependency: ${name} depends on ${dep}, which is neither open nor archived: withdrawn, so ${name} is invalidated until it no longer needs it`);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (target.status === "withdrawn" || target.status === "superseded") {
|
|
142
|
+
problems.push(`read dependency: ${name} depends on ${dep}, whose agreement record is ${target.status}: ${name} is invalidated until it no longer needs it`);
|
|
143
|
+
}
|
|
144
|
+
else if (oc.status === "agreed" && target.status !== "agreed") {
|
|
145
|
+
problems.push(`read dependency: ${name} is agreed and depends on ${dep}, which is not: a change is agreed with its dependency closure`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
// A cycle through this change.
|
|
149
|
+
const seen = new Set();
|
|
150
|
+
const stack = [...deps];
|
|
151
|
+
while (stack.length > 0) {
|
|
152
|
+
const c = stack.pop();
|
|
153
|
+
if (c === name) {
|
|
154
|
+
problems.push(`read dependency: ${name} depends on itself through depends_on; a cycle can never archive`);
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
if (seen.has(c))
|
|
158
|
+
continue;
|
|
159
|
+
seen.add(c);
|
|
160
|
+
for (const d of open.get(c)?.structure.deltas ?? [])
|
|
161
|
+
stack.push(...d.dependsOn);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const KEY_RE = /\b([a-z][a-z0-9_]*)\/([^\s,;:()"]+)/g;
|
|
166
|
+
/** The changes that write any element a finding names. */
|
|
167
|
+
export function attribute(problem, open, among) {
|
|
168
|
+
const keys = new Set([...problem.matchAll(KEY_RE)].map((m) => `${m[1]}/${m[2]}`));
|
|
169
|
+
return among.filter((c) => {
|
|
170
|
+
const w = writesOf(open.get(c).structure);
|
|
171
|
+
return [...keys].some((k) => w.has(k));
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
//# sourceMappingURL=compose.js.map
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// A change's structural effect, and the effect record an archive leaves (M-ADR 0230 §4.6, §4.7, Proposed). Pure reads,
|
|
2
|
+
// so the catalogue builder and the verify-only CLI can carry them; writing the record is apply.ts's.
|
|
3
|
+
import { parseDocument } from "yaml";
|
|
4
|
+
import { elementKey, revision } from "./model.js";
|
|
5
|
+
export function effectsOf(s, current) {
|
|
6
|
+
const out = [];
|
|
7
|
+
const rev = (catalogue, id) => {
|
|
8
|
+
const e = current.get(catalogue)?.get(id);
|
|
9
|
+
return e ? revision(e.catalogue, e.id, e.columns) : undefined;
|
|
10
|
+
};
|
|
11
|
+
for (const d of s.deltas) {
|
|
12
|
+
for (const e of d.added)
|
|
13
|
+
out.push({ element: elementKey(e.catalogue, e.id), operation: "ADDED", target: revision(e.catalogue, e.id, e.columns) });
|
|
14
|
+
for (const e of d.modified) {
|
|
15
|
+
const base = rev(e.catalogue, e.id);
|
|
16
|
+
out.push({ element: elementKey(e.catalogue, e.id), operation: "MODIFIED", ...(base ? { base } : {}), target: revision(e.catalogue, e.id, e.columns) });
|
|
17
|
+
}
|
|
18
|
+
for (const r of d.removed) {
|
|
19
|
+
const base = rev(r.catalogue, r.id);
|
|
20
|
+
out.push({ element: elementKey(r.catalogue, r.id), operation: "REMOVED", ...(base ? { base } : {}) });
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return out.sort((a, b) => a.element.localeCompare(b.element));
|
|
24
|
+
}
|
|
25
|
+
export function parseAppliedRecord(text) {
|
|
26
|
+
try {
|
|
27
|
+
const v = parseDocument(text).toJS();
|
|
28
|
+
if (!v || typeof v.change !== "string" || !Array.isArray(v.effects))
|
|
29
|
+
return null;
|
|
30
|
+
return { change: v.change, baseCommit: String(v.base_commit ?? ""), effects: v.effects };
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
//# sourceMappingURL=effects.js.map
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// The structural model and a change's structural deltas (M-ADR 0230 §4.3, §4.4, Proposed). Pure reads, so the
|
|
2
|
+
// verify-only CLI can carry them. The format is docs/method/methods/guides/structural-model-format.md:
|
|
3
|
+
// openspec/structure/<partition>.yaml the model, one file per partition
|
|
4
|
+
// openspec/changes/<change>/structure/<partition>.yaml a change's delta for that partition
|
|
5
|
+
// openspec/changes/<change>/structure/none.yaml a change's typed claim of no structural effect
|
|
6
|
+
// An element is a catalogue row of a `structure` catalogue, keyed by its immutable id. Its revision is the
|
|
7
|
+
// SHA-256 of its canonical JSON, so two readers agree on it without agreeing on anything else.
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { parseDocument } from "yaml";
|
|
12
|
+
export const STRUCTURE_DIR = join("openspec", "structure");
|
|
13
|
+
export const NO_OP_FILE = "none.yaml";
|
|
14
|
+
/** An archived change's record of its structural effect, beside its deltas (M-ADR 0230 §4.6). */
|
|
15
|
+
export const APPLIED_FILE = "applied.yaml";
|
|
16
|
+
/** Partition names a file name would confuse with the no-op claim or the effect record. */
|
|
17
|
+
export const RESERVED_PARTITIONS = new Set(["none", "applied"]);
|
|
18
|
+
export const FORMAT_VERSION = "1.0.0";
|
|
19
|
+
/** `sha256:<hex>` of the element's canonical JSON: {catalogue, columns (sorted), id}, no whitespace, UTF-8. */
|
|
20
|
+
export function revision(catalogue, id, columns) {
|
|
21
|
+
const sorted = {};
|
|
22
|
+
for (const k of Object.keys(columns).sort())
|
|
23
|
+
sorted[k] = columns[k];
|
|
24
|
+
const canonical = JSON.stringify({ catalogue, columns: sorted, id });
|
|
25
|
+
return `sha256:${createHash("sha256").update(canonical, "utf8").digest("hex")}`;
|
|
26
|
+
}
|
|
27
|
+
export function elementKey(catalogue, id) {
|
|
28
|
+
return `${catalogue}/${id}`;
|
|
29
|
+
}
|
|
30
|
+
async function readText(p) {
|
|
31
|
+
try {
|
|
32
|
+
return await readFile(p, "utf8");
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
async function yamlFiles(dir) {
|
|
39
|
+
try {
|
|
40
|
+
return (await readdir(dir, { withFileTypes: true }))
|
|
41
|
+
.filter((e) => e.isFile() && e.name.endsWith(".yaml"))
|
|
42
|
+
.map((e) => e.name)
|
|
43
|
+
.sort();
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function parseYamlMap(text, where, problems) {
|
|
50
|
+
const doc = parseDocument(text);
|
|
51
|
+
if (doc.errors.length > 0) {
|
|
52
|
+
problems.push(`${where}: not valid YAML (${doc.errors[0].message.split("\n")[0]})`);
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
const v = doc.toJS();
|
|
56
|
+
if (v === null || typeof v !== "object" || Array.isArray(v)) {
|
|
57
|
+
problems.push(`${where}: not a YAML mapping`);
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
return v;
|
|
61
|
+
}
|
|
62
|
+
function checkVersion(m, where, problems) {
|
|
63
|
+
if (m["schema_version"] === undefined) {
|
|
64
|
+
problems.push(`${where}: no schema_version`);
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
if (String(m["schema_version"]) !== FORMAT_VERSION) {
|
|
68
|
+
problems.push(`${where}: schema_version ${JSON.stringify(String(m["schema_version"]))} is not ${FORMAT_VERSION}, the format this CLI reads`);
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
/** Elements under a `<catalogue>: [ {id, ...} ]` map. */
|
|
74
|
+
function readElements(block, where, partition, file, problems) {
|
|
75
|
+
const out = [];
|
|
76
|
+
if (block === undefined || block === null)
|
|
77
|
+
return out;
|
|
78
|
+
if (typeof block !== "object" || Array.isArray(block)) {
|
|
79
|
+
problems.push(`${where}: must map catalogue names to lists of elements`);
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
for (const [catalogue, list] of Object.entries(block)) {
|
|
83
|
+
if (!Array.isArray(list)) {
|
|
84
|
+
problems.push(`${where}.${catalogue}: must be a list of elements`);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
list.forEach((raw, i) => {
|
|
88
|
+
const at = `${where}.${catalogue}[${i}]`;
|
|
89
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
90
|
+
problems.push(`${at}: an element must be a mapping`);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const r = raw;
|
|
94
|
+
const id = r["id"];
|
|
95
|
+
if (typeof id !== "string" || id === "") {
|
|
96
|
+
problems.push(`${at}: no id`);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const columns = {};
|
|
100
|
+
for (const [k, v] of Object.entries(r)) {
|
|
101
|
+
if (k === "id")
|
|
102
|
+
continue;
|
|
103
|
+
if (typeof v === "string" || typeof v === "number" || typeof v === "boolean")
|
|
104
|
+
columns[k] = v;
|
|
105
|
+
else
|
|
106
|
+
problems.push(`${at} (${id}).${k}: a value must be a string, number or boolean`);
|
|
107
|
+
}
|
|
108
|
+
out.push({ catalogue, id, columns, partition, file });
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
/** The model, or null when the SDD has no openspec/structure/ directory. */
|
|
114
|
+
export async function loadModel(repo, problems) {
|
|
115
|
+
const names = await yamlFiles(join(repo, STRUCTURE_DIR));
|
|
116
|
+
if (names === null)
|
|
117
|
+
return null;
|
|
118
|
+
const files = [];
|
|
119
|
+
for (const name of names) {
|
|
120
|
+
const file = join(STRUCTURE_DIR, name);
|
|
121
|
+
const m = parseYamlMap((await readText(join(repo, file))) ?? "", file, problems);
|
|
122
|
+
if (m === null || !checkVersion(m, file, problems))
|
|
123
|
+
continue;
|
|
124
|
+
const partition = name.slice(0, -".yaml".length);
|
|
125
|
+
if (RESERVED_PARTITIONS.has(partition)) {
|
|
126
|
+
problems.push(`${file}: ${partition} is a reserved name, not a partition`);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (m["partition"] !== partition) {
|
|
130
|
+
problems.push(`${file}: partition ${JSON.stringify(m["partition"] ?? null)} does not match the file name (${partition})`);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
for (const k of Object.keys(m))
|
|
134
|
+
if (!["schema_version", "partition", "elements"].includes(k))
|
|
135
|
+
problems.push(`${file}: unknown key ${k}`);
|
|
136
|
+
files.push({ partition, file, elements: readElements(m["elements"], `${file}: elements`, partition, file, problems) });
|
|
137
|
+
}
|
|
138
|
+
return files;
|
|
139
|
+
}
|
|
140
|
+
/** A change's structural artifact. */
|
|
141
|
+
export async function loadChangeStructure(repo, change, problems) {
|
|
142
|
+
const dir = join("openspec", "changes", change, "structure");
|
|
143
|
+
const names = await yamlFiles(join(repo, dir));
|
|
144
|
+
if (names === null)
|
|
145
|
+
return { change, deltas: [], noOp: null, present: false };
|
|
146
|
+
const deltas = [];
|
|
147
|
+
let noOp = null;
|
|
148
|
+
for (const name of names) {
|
|
149
|
+
if (name === APPLIED_FILE)
|
|
150
|
+
continue;
|
|
151
|
+
const file = join(dir, name);
|
|
152
|
+
const m = parseYamlMap((await readText(join(repo, file))) ?? "", file, problems);
|
|
153
|
+
if (m === null || !checkVersion(m, file, problems))
|
|
154
|
+
continue;
|
|
155
|
+
if (name === NO_OP_FILE) {
|
|
156
|
+
const reason = typeof m["reason"] === "string" ? m["reason"].trim() : "";
|
|
157
|
+
if (m["no_structural_effect"] !== true)
|
|
158
|
+
problems.push(`${file}: a no-op claim states no_structural_effect: true`);
|
|
159
|
+
else if (reason === "")
|
|
160
|
+
problems.push(`${file}: a no-op claim states its reason`);
|
|
161
|
+
else
|
|
162
|
+
noOp = { file, reason };
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const partition = name.slice(0, -".yaml".length);
|
|
166
|
+
if (m["partition"] !== partition) {
|
|
167
|
+
problems.push(`${file}: partition ${JSON.stringify(m["partition"] ?? null)} does not match the file name (${partition})`);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
const dependsOn = m["depends_on"] ?? [];
|
|
171
|
+
if (!Array.isArray(dependsOn) || dependsOn.some((d) => typeof d !== "string"))
|
|
172
|
+
problems.push(`${file}: depends_on must be a list of change names`);
|
|
173
|
+
const base = new Map();
|
|
174
|
+
const rawBase = m["base"] ?? {};
|
|
175
|
+
if (typeof rawBase !== "object" || rawBase === null || Array.isArray(rawBase))
|
|
176
|
+
problems.push(`${file}: base must map <catalogue>/<id> to a revision`);
|
|
177
|
+
else {
|
|
178
|
+
for (const [k, v] of Object.entries(rawBase)) {
|
|
179
|
+
if (typeof v !== "string" || !/^sha256:[0-9a-f]{64}$/.test(v))
|
|
180
|
+
problems.push(`${file}: base ${k} is not a revision (sha256:<64 hex digits>)`);
|
|
181
|
+
else
|
|
182
|
+
base.set(k, v);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
const removed = [];
|
|
186
|
+
for (const r of readElements(m["REMOVED"], `${file}: REMOVED`, partition, file, problems)) {
|
|
187
|
+
const reason = r.columns["reason"];
|
|
188
|
+
if (typeof reason !== "string" || reason.trim() === "")
|
|
189
|
+
problems.push(`${file}: REMOVED ${elementKey(r.catalogue, r.id)} gives no reason`);
|
|
190
|
+
const extra = Object.keys(r.columns).filter((k) => k !== "reason");
|
|
191
|
+
if (extra.length > 0)
|
|
192
|
+
problems.push(`${file}: REMOVED ${elementKey(r.catalogue, r.id)} states ${extra.join(", ")}; a removal gives its id and a reason only`);
|
|
193
|
+
removed.push({ catalogue: r.catalogue, id: r.id, reason: typeof reason === "string" ? reason : "" });
|
|
194
|
+
}
|
|
195
|
+
for (const k of Object.keys(m)) {
|
|
196
|
+
if (!["schema_version", "partition", "depends_on", "ADDED", "MODIFIED", "REMOVED", "base"].includes(k))
|
|
197
|
+
problems.push(`${file}: unknown key ${k}`);
|
|
198
|
+
}
|
|
199
|
+
deltas.push({
|
|
200
|
+
change,
|
|
201
|
+
partition,
|
|
202
|
+
file,
|
|
203
|
+
dependsOn: Array.isArray(dependsOn) ? dependsOn.map(String) : [],
|
|
204
|
+
added: readElements(m["ADDED"], `${file}: ADDED`, partition, file, problems),
|
|
205
|
+
modified: readElements(m["MODIFIED"], `${file}: MODIFIED`, partition, file, problems),
|
|
206
|
+
removed,
|
|
207
|
+
base,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
return { change, deltas, noOp, present: true };
|
|
211
|
+
}
|
|
212
|
+
//# sourceMappingURL=model.js.map
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// The generated views of the structural model (M-ADR 0230 §4.3, Proposed): projections, never edited. Each file is
|
|
2
|
+
// rendered from the current view alone, in id order, so the same model renders the same bytes. Its first line names
|
|
3
|
+
// the generator and the digest of the body below it, which tells three states apart:
|
|
4
|
+
// missing the file is absent
|
|
5
|
+
// stale the body matches its header but not a render of the model: the model moved; render again
|
|
6
|
+
// hand-edited the body no longer matches its header: someone edited a generated view
|
|
7
|
+
// Pure: the verb reads and writes the files.
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
import { VIEW_PATHS } from "./catalogue.js";
|
|
10
|
+
export { VIEW_PATHS };
|
|
11
|
+
const HEADER = /^<!-- Generated by sdd-cli structure render from openspec\/structure\/ \(M-ADR 0230\)\. Do not edit: change the model through a change's structural deltas\. body (sha256:[0-9a-f]{64}) -->\n/;
|
|
12
|
+
const digest = (s) => `sha256:${createHash("sha256").update(s, "utf8").digest("hex")}`;
|
|
13
|
+
function withHeader(body) {
|
|
14
|
+
return `<!-- Generated by sdd-cli structure render from openspec/structure/ (M-ADR 0230). Do not edit: change the model through a change's structural deltas. body ${digest(body)} -->\n${body}`;
|
|
15
|
+
}
|
|
16
|
+
const elements = (view, catalogue) => [...(view.get(catalogue)?.values() ?? [])].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
17
|
+
const col = (e, c) => (e.columns[c] === undefined ? "" : String(e.columns[c]));
|
|
18
|
+
const nameOf = (e) => col(e, "name") || e.id;
|
|
19
|
+
/** A string inside a Mermaid quoted argument: one line, no double quote. */
|
|
20
|
+
const q = (s) => s.replace(/\s+/g, " ").replace(/"/g, "'").trim();
|
|
21
|
+
/** A cell of a Markdown table. */
|
|
22
|
+
const td = (s) => s.replace(/\s+/g, " ").replace(/\|/g, "\\|").trim();
|
|
23
|
+
/** Mermaid aliases for ids, which may hold characters an alias cannot; a clash after cleaning takes a suffix. */
|
|
24
|
+
function aliases() {
|
|
25
|
+
const taken = new Map();
|
|
26
|
+
const used = new Set();
|
|
27
|
+
return (id) => {
|
|
28
|
+
const known = taken.get(id);
|
|
29
|
+
if (known)
|
|
30
|
+
return known;
|
|
31
|
+
const base = id.replace(/[^A-Za-z0-9_]/g, "_").replace(/^(?=[0-9])/, "_") || "_";
|
|
32
|
+
let alias = base;
|
|
33
|
+
for (let n = 2; used.has(alias); n++)
|
|
34
|
+
alias = `${base}_${n}`;
|
|
35
|
+
used.add(alias);
|
|
36
|
+
taken.set(id, alias);
|
|
37
|
+
return alias;
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
function c4Context(view) {
|
|
41
|
+
const a = aliases();
|
|
42
|
+
const links = elements(view, "persona_c4system");
|
|
43
|
+
const personas = new Set(links.map((l) => col(l, "persona_id")));
|
|
44
|
+
const lines = ["# System context", "", "```mermaid", "C4Context", " title System context"];
|
|
45
|
+
for (const p of elements(view, "personas"))
|
|
46
|
+
if (personas.has(p.id))
|
|
47
|
+
lines.push(` Person(${a(p.id)}, "${q(nameOf(p))}", "${q(col(p, "role"))}")`);
|
|
48
|
+
for (const s of elements(view, "c4_systems"))
|
|
49
|
+
lines.push(` System(${a(s.id)}, "${q(nameOf(s))}", "${q(col(s, "description"))}")`);
|
|
50
|
+
for (const x of elements(view, "c4_external_systems"))
|
|
51
|
+
lines.push(` System_Ext(${a(x.id)}, "${q(nameOf(x))}", "${q(col(x, "description"))}")`);
|
|
52
|
+
for (const l of links)
|
|
53
|
+
lines.push(` Rel(${a(col(l, "persona_id"))}, ${a(col(l, "c4_system_id"))}, "${q(col(l, "interaction"))}")`);
|
|
54
|
+
for (const l of elements(view, "system_external_dependency"))
|
|
55
|
+
lines.push(` Rel(${a(col(l, "c4_system_id"))}, ${a(col(l, "external_system_id"))}, "${q(col(l, "interaction"))}")`);
|
|
56
|
+
lines.push("```", "");
|
|
57
|
+
return lines.join("\n");
|
|
58
|
+
}
|
|
59
|
+
function c4Containers(view) {
|
|
60
|
+
const containers = elements(view, "c4_containers");
|
|
61
|
+
const systemOf = new Map(containers.map((c) => [c.id, col(c, "system_id")]));
|
|
62
|
+
const deps = elements(view, "container_dependency");
|
|
63
|
+
const lines = ["# Containers", ""];
|
|
64
|
+
for (const s of elements(view, "c4_systems")) {
|
|
65
|
+
const a = aliases();
|
|
66
|
+
const inside = containers.filter((c) => col(c, "system_id") === s.id);
|
|
67
|
+
lines.push(`## ${nameOf(s)} (\`${s.id}\`)`, "", "```mermaid", "C4Container", ` title Containers of ${q(nameOf(s))}`, ` System_Boundary(${a(s.id)}, "${q(nameOf(s))}") {`);
|
|
68
|
+
for (const c of inside)
|
|
69
|
+
lines.push(` Container(${a(c.id)}, "${q(nameOf(c))}", "${q(col(c, "technology"))}", "${q(col(c, "description"))}")`);
|
|
70
|
+
lines.push(" }");
|
|
71
|
+
const outbound = deps.filter((d) => systemOf.get(col(d, "from_container_id")) === s.id);
|
|
72
|
+
const outside = [...new Set(outbound.map((d) => col(d, "to_container_id")).filter((t) => systemOf.get(t) !== s.id))].sort();
|
|
73
|
+
for (const t of outside) {
|
|
74
|
+
const c = view.get("c4_containers")?.get(t);
|
|
75
|
+
lines.push(` Container_Ext(${a(t)}, "${q(c ? nameOf(c) : t)}", "${q(c ? col(c, "technology") : "")}", "")`);
|
|
76
|
+
}
|
|
77
|
+
for (const d of outbound)
|
|
78
|
+
lines.push(` Rel(${a(col(d, "from_container_id"))}, ${a(col(d, "to_container_id"))}, "${q(col(d, "interaction"))}")`);
|
|
79
|
+
lines.push("```", "");
|
|
80
|
+
}
|
|
81
|
+
return lines.join("\n");
|
|
82
|
+
}
|
|
83
|
+
function services(view) {
|
|
84
|
+
const lines = [
|
|
85
|
+
"# Services",
|
|
86
|
+
"",
|
|
87
|
+
"| Service | Name | Domain | Primary repository | Runtime | Language | Component type | Status |",
|
|
88
|
+
"| --- | --- | --- | --- | --- | --- | --- | --- |",
|
|
89
|
+
];
|
|
90
|
+
for (const s of elements(view, "services")) {
|
|
91
|
+
const cells = [`\`${s.id}\``, nameOf(s), col(s, "domain_id"), col(s, "primary_repo_id"), col(s, "runtime_type"), col(s, "language"), col(s, "component_type"), col(s, "status")];
|
|
92
|
+
lines.push(`| ${cells.map(td).join(" | ")} |`);
|
|
93
|
+
}
|
|
94
|
+
lines.push("");
|
|
95
|
+
return lines.join("\n");
|
|
96
|
+
}
|
|
97
|
+
function featureTree(view) {
|
|
98
|
+
const caps = elements(view, "capabilities");
|
|
99
|
+
const groups = elements(view, "feature_groups");
|
|
100
|
+
const features = elements(view, "features");
|
|
101
|
+
const linked = new Map();
|
|
102
|
+
for (const l of elements(view, "capability_feature")) {
|
|
103
|
+
const set = linked.get(col(l, "capability_id")) ?? new Set();
|
|
104
|
+
set.add(col(l, "feature_id"));
|
|
105
|
+
linked.set(col(l, "capability_id"), set);
|
|
106
|
+
}
|
|
107
|
+
const placed = new Set();
|
|
108
|
+
const lines = ["# Feature tree", ""];
|
|
109
|
+
const feature = (f, depth) => {
|
|
110
|
+
placed.add(f.id);
|
|
111
|
+
const facts = [col(f, "kind"), col(f, "status")].filter((x) => x !== "").join(", ");
|
|
112
|
+
lines.push(`${" ".repeat(depth)}- ${td(nameOf(f))} (\`${f.id}\`)${facts ? `: ${facts}` : ""}`);
|
|
113
|
+
for (const c of features)
|
|
114
|
+
if (col(c, "parent_feature_id") === f.id && !placed.has(c.id))
|
|
115
|
+
feature(c, depth + 1);
|
|
116
|
+
};
|
|
117
|
+
const group = (g, depth) => {
|
|
118
|
+
lines.push(`${" ".repeat(depth)}- **${td(nameOf(g))}** (\`${g.id}\`)`);
|
|
119
|
+
for (const c of groups)
|
|
120
|
+
if (col(c, "parent_group_id") === g.id)
|
|
121
|
+
group(c, depth + 1);
|
|
122
|
+
for (const f of features)
|
|
123
|
+
if (col(f, "parent_group_id") === g.id && col(f, "parent_feature_id") === "" && !placed.has(f.id))
|
|
124
|
+
feature(f, depth + 1);
|
|
125
|
+
};
|
|
126
|
+
const isRoot = (f) => col(f, "parent_group_id") === "" && col(f, "parent_feature_id") === "";
|
|
127
|
+
const capability = (c, depth) => {
|
|
128
|
+
lines.push(`${"#".repeat(Math.min(depth + 2, 6))} ${nameOf(c)} (\`${c.id}\`)`, "");
|
|
129
|
+
const before = lines.length;
|
|
130
|
+
for (const g of groups)
|
|
131
|
+
if (col(g, "capability_id") === c.id && col(g, "parent_group_id") === "")
|
|
132
|
+
group(g, 0);
|
|
133
|
+
for (const f of features)
|
|
134
|
+
if (isRoot(f) && !placed.has(f.id) && (col(f, "capability_id") === c.id || linked.get(c.id)?.has(f.id)))
|
|
135
|
+
feature(f, 0);
|
|
136
|
+
if (lines.length > before)
|
|
137
|
+
lines.push("");
|
|
138
|
+
for (const child of caps)
|
|
139
|
+
if (col(child, "parent_capability_id") === c.id)
|
|
140
|
+
capability(child, depth + 1);
|
|
141
|
+
};
|
|
142
|
+
for (const c of caps)
|
|
143
|
+
if (col(c, "parent_capability_id") === "" || !view.get("capabilities")?.has(col(c, "parent_capability_id")))
|
|
144
|
+
capability(c, 0);
|
|
145
|
+
const rest = features.filter((f) => !placed.has(f.id));
|
|
146
|
+
const loose = groups.filter((g) => col(g, "capability_id") === "" && col(g, "parent_group_id") === "");
|
|
147
|
+
if (loose.length > 0 || rest.length > 0) {
|
|
148
|
+
lines.push("## Not under a capability", "");
|
|
149
|
+
for (const g of loose)
|
|
150
|
+
group(g, 0);
|
|
151
|
+
for (const f of features)
|
|
152
|
+
if (!placed.has(f.id) && isRoot(f))
|
|
153
|
+
feature(f, 0);
|
|
154
|
+
for (const f of features)
|
|
155
|
+
if (!placed.has(f.id))
|
|
156
|
+
feature(f, 0);
|
|
157
|
+
lines.push("");
|
|
158
|
+
}
|
|
159
|
+
return lines.join("\n");
|
|
160
|
+
}
|
|
161
|
+
/** Mermaid's end markers for relationship_end_cardinality, written at the from end and at the to end. */
|
|
162
|
+
const FROM_END = { "exactly-one": "||", "zero-or-one": "|o", "one-or-more": "}|", "zero-or-more": "}o" };
|
|
163
|
+
const TO_END = { "exactly-one": "||", "zero-or-one": "o|", "one-or-more": "|{", "zero-or-more": "o{" };
|
|
164
|
+
function dataModel(view) {
|
|
165
|
+
const entities = elements(view, "entities");
|
|
166
|
+
const attributes = elements(view, "entity_attributes");
|
|
167
|
+
const relationships = elements(view, "entity_relationships");
|
|
168
|
+
const invariants = elements(view, "domain_invariants");
|
|
169
|
+
const word = (s) => s.replace(/[^A-Za-z0-9_]/g, "_") || "_";
|
|
170
|
+
const sections = elements(view, "domains").map((d) => [`${nameOf(d)} (\`${d.id}\`)`, entities.filter((e) => col(e, "domain_id") === d.id)]);
|
|
171
|
+
const domains = new Set(view.get("domains")?.keys() ?? []);
|
|
172
|
+
sections.push(["Entities without a domain", entities.filter((e) => !domains.has(col(e, "domain_id")))]);
|
|
173
|
+
const lines = ["# Data model", ""];
|
|
174
|
+
for (const [title, members] of sections) {
|
|
175
|
+
if (members.length === 0)
|
|
176
|
+
continue;
|
|
177
|
+
const a = aliases();
|
|
178
|
+
const ids = new Set(members.map((e) => e.id));
|
|
179
|
+
lines.push(`## ${title}`, "", "```mermaid", "erDiagram");
|
|
180
|
+
for (const e of members) {
|
|
181
|
+
const attrs = attributes.filter((x) => col(x, "entity_id") === e.id);
|
|
182
|
+
if (attrs.length === 0) {
|
|
183
|
+
lines.push(` ${a(e.id)}["${q(nameOf(e))}"]`);
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
lines.push(` ${a(e.id)}["${q(nameOf(e))}"] {`);
|
|
187
|
+
for (const x of attrs)
|
|
188
|
+
lines.push(` ${word(col(x, "type"))} ${word(col(x, "name"))}`);
|
|
189
|
+
lines.push(" }");
|
|
190
|
+
}
|
|
191
|
+
for (const r of relationships) {
|
|
192
|
+
if (!ids.has(col(r, "from_entity_id")))
|
|
193
|
+
continue;
|
|
194
|
+
const ends = `${FROM_END[col(r, "from_cardinality")] ?? "}o"}--${TO_END[col(r, "to_cardinality")] ?? "o{"}`;
|
|
195
|
+
lines.push(` ${a(col(r, "from_entity_id"))} ${ends} ${a(col(r, "to_entity_id"))} : "${q(nameOf(r))}"`);
|
|
196
|
+
}
|
|
197
|
+
lines.push("```", "");
|
|
198
|
+
const rules = invariants.filter((i) => ids.has(col(i, "entity_id")));
|
|
199
|
+
if (rules.length > 0) {
|
|
200
|
+
lines.push("Invariants:", "");
|
|
201
|
+
for (const i of rules)
|
|
202
|
+
lines.push(`- \`${i.id}\` (${col(i, "kind")}) on \`${col(i, "entity_id")}\`: ${td(col(i, "statement"))}`);
|
|
203
|
+
lines.push("");
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return lines.join("\n");
|
|
207
|
+
}
|
|
208
|
+
/** Every generated view of the current view, by path relative to the SDD. */
|
|
209
|
+
export function renderViews(view) {
|
|
210
|
+
return new Map([
|
|
211
|
+
[VIEW_PATHS.c4Context, withHeader(c4Context(view))],
|
|
212
|
+
[VIEW_PATHS.c4Containers, withHeader(c4Containers(view))],
|
|
213
|
+
[VIEW_PATHS.services, withHeader(services(view))],
|
|
214
|
+
[VIEW_PATHS.featureTree, withHeader(featureTree(view))],
|
|
215
|
+
[VIEW_PATHS.dataModel, withHeader(dataModel(view))],
|
|
216
|
+
]);
|
|
217
|
+
}
|
|
218
|
+
/** A view file's state against its render. `text` is the file's content, or null when it is absent. */
|
|
219
|
+
export function viewState(rendered, text) {
|
|
220
|
+
if (text === null)
|
|
221
|
+
return "missing";
|
|
222
|
+
if (text === rendered)
|
|
223
|
+
return "current";
|
|
224
|
+
const m = HEADER.exec(text);
|
|
225
|
+
if (m === null || digest(text.slice(m[0].length)) !== m[1])
|
|
226
|
+
return "hand-edited";
|
|
227
|
+
return "stale";
|
|
228
|
+
}
|
|
229
|
+
/** One line per view that is not current. */
|
|
230
|
+
export function describeState(path, state) {
|
|
231
|
+
switch (state) {
|
|
232
|
+
case "missing":
|
|
233
|
+
return `${path}: missing; run \`sdd-cli structure render\``;
|
|
234
|
+
case "stale":
|
|
235
|
+
return `${path}: stale, rendered from an earlier model; run \`sdd-cli structure render\``;
|
|
236
|
+
case "hand-edited":
|
|
237
|
+
return `${path}: edited by hand since it was generated; a generated view is never edited. Make the change in the model, through a change's structural deltas, and render again`;
|
|
238
|
+
default:
|
|
239
|
+
return `${path}: current`;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
//# sourceMappingURL=render.js.map
|