@sdd-method/maintain-cli 0.12.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/catalogue/aggregate.js +2 -0
- 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/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/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,265 @@
|
|
|
1
|
+
// structural-model: the structural model and each open change's structural artifact (M-ADR 0230 §4.3, §4.4, Proposed;
|
|
2
|
+
// docs/method/methods/guides/structural-model-format.md).
|
|
3
|
+
//
|
|
4
|
+
// The model (openspec/structure/<partition>.yaml), as the current view:
|
|
5
|
+
// files valid YAML, schema_version, a partition matching its file name and naming one this SDD has
|
|
6
|
+
// (a platform SDD: its domains and `platform`; an application or integration SDD: its sdd_id)
|
|
7
|
+
// elements a `structure` catalogue, its `structure` columns only, an id held once, required columns,
|
|
8
|
+
// value types and vocabularies (the catalogue metamodel's)
|
|
9
|
+
// references resolved in the view; into a catalogue of another class, against that catalogue as built
|
|
10
|
+
// hierarchies no cycle through a self-reference; natural keys unique; domain invariants naming their entity's own
|
|
11
|
+
// attributes and relationships
|
|
12
|
+
// views each generated view under docs/structure/ missing, stale (rendered from an earlier model) or edited by
|
|
13
|
+
// hand (its body no longer matches the digest in its header)
|
|
14
|
+
// Each open change (openspec/changes/<change>/structure/):
|
|
15
|
+
// artifact where the model exists, a change has partition deltas or none.yaml, never both
|
|
16
|
+
// deltas well formed; ADDED ids new; MODIFIED and REMOVED ids held by the delta's partition; a base
|
|
17
|
+
// revision for every element modified, removed or read, still the current one; a reference into an
|
|
18
|
+
// element another open change adds names that change in depends_on
|
|
19
|
+
// no-op contradicted by a behaviour delta for a feature the model does not hold; a structural delta
|
|
20
|
+
// must add that feature
|
|
21
|
+
// Composition (§4.5; lib/structure/compose.ts), with a change agreed when its agreement record says so:
|
|
22
|
+
// overlap two agreed changes writing one element or adding one id; a change overlapping an agreed one
|
|
23
|
+
// composed each change's view (with its dependency closure) and the intended view (every agreed change)
|
|
24
|
+
// validated on their own; what fails there and not in current is reported against the changes
|
|
25
|
+
// that write the elements it names
|
|
26
|
+
// reads a dependency withdrawn (record withdrawn or superseded, or neither open nor archived), an agreed
|
|
27
|
+
// change depending on one not agreed, a cycle of dependencies
|
|
28
|
+
// Archived changes (§4.6):
|
|
29
|
+
// archived a change archived with structural deltas and no effect record (a raw `openspec archive`): its
|
|
30
|
+
// structure is not applied, so its transition is not shipped
|
|
31
|
+
//
|
|
32
|
+
// Report only while M-ADR 0230 is Proposed (ENFORCED): every finding is a [WARN] and the check exits 0. Without
|
|
33
|
+
// openspec/structure/ and with no change carrying a structure/ directory it reports [N/A].
|
|
34
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
35
|
+
import { join } from "node:path";
|
|
36
|
+
import { parseDocument } from "yaml";
|
|
37
|
+
import { parseCatalogueCsv } from "../catalogue/csv-reader.js";
|
|
38
|
+
import { loadCanonicalSchema } from "../catalogue/schema-loader.js";
|
|
39
|
+
import { APPLIED_FILE, elementKey, loadChangeStructure, loadModel, STRUCTURE_DIR } from "../structure/model.js";
|
|
40
|
+
import { agreedOrder, attribute, checkDependencies, checkOverlap, intended, viewAt } from "../structure/compose.js";
|
|
41
|
+
import { describeState, renderViews, VIEW_PATHS, viewState } from "../structure/render.js";
|
|
42
|
+
import { validateDeltas, validateView, viewOf } from "../structure/view.js";
|
|
43
|
+
const VIEW_COUNT = Object.keys(VIEW_PATHS).length;
|
|
44
|
+
/** Flipped when M-ADR 0230 is accepted: findings then FAIL. */
|
|
45
|
+
export const ENFORCED = false;
|
|
46
|
+
const CHANGES_DIR = join("openspec", "changes");
|
|
47
|
+
async function readText(p) {
|
|
48
|
+
try {
|
|
49
|
+
return await readFile(p, "utf8");
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
async function subdirs(p) {
|
|
56
|
+
try {
|
|
57
|
+
return (await readdir(p, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name).sort();
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** The change's agreement record status (docs/agreements/<change>.yaml), or `unrecorded`. */
|
|
64
|
+
async function agreementStatus(repo, change) {
|
|
65
|
+
const text = await readText(join(repo, "docs", "agreements", `${change}.yaml`));
|
|
66
|
+
if (text === null)
|
|
67
|
+
return "unrecorded";
|
|
68
|
+
try {
|
|
69
|
+
const status = String(parseDocument(text).toJS()?.agreement_record?.status ?? "");
|
|
70
|
+
return ["proposed", "agreed", "withdrawn", "superseded"].find((s) => s === status) ?? "unrecorded";
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return "unrecorded";
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/** Names of archived changes: openspec/changes/archive/<YYYY-MM-DD>-<name>. */
|
|
77
|
+
async function archivedNames(repo) {
|
|
78
|
+
return new Set((await subdirs(join(repo, CHANGES_DIR, "archive"))).map((d) => d.replace(/^\d{4}-\d{2}-\d{2}-/, "")));
|
|
79
|
+
}
|
|
80
|
+
function builtLookup(repo) {
|
|
81
|
+
const cache = new Map();
|
|
82
|
+
const fn = ((catalogue, column) => {
|
|
83
|
+
const rows = cache.get(catalogue);
|
|
84
|
+
if (rows === undefined || rows === null)
|
|
85
|
+
return null;
|
|
86
|
+
return new Set(rows.map((r) => r[column] ?? ""));
|
|
87
|
+
});
|
|
88
|
+
fn.load = async (catalogue) => {
|
|
89
|
+
if (cache.has(catalogue))
|
|
90
|
+
return;
|
|
91
|
+
const text = await readText(join(repo, "catalogues", `${catalogue}.csv`));
|
|
92
|
+
cache.set(catalogue, text === null ? null : parseCatalogueCsv(text).rows);
|
|
93
|
+
};
|
|
94
|
+
return fn;
|
|
95
|
+
}
|
|
96
|
+
export async function checkStructuralModel(repo, options = {}) {
|
|
97
|
+
const enforced = options.enforced ?? ENFORCED;
|
|
98
|
+
const problems = [];
|
|
99
|
+
const lines = [];
|
|
100
|
+
const files = await loadModel(repo, problems);
|
|
101
|
+
const changes = (await subdirs(join(repo, CHANGES_DIR))).filter((c) => c !== "archive");
|
|
102
|
+
const structures = [];
|
|
103
|
+
for (const c of changes)
|
|
104
|
+
structures.push(await loadChangeStructure(repo, c, problems));
|
|
105
|
+
if (files === null && !structures.some((s) => s.present)) {
|
|
106
|
+
return { exitCode: 0, stdout: `[N/A] structural-model: no structural model (${STRUCTURE_DIR}/) and no change with structural deltas in this repository\n` };
|
|
107
|
+
}
|
|
108
|
+
const schema = options.schema ?? (await loadCanonicalSchema());
|
|
109
|
+
// Partitions this SDD has.
|
|
110
|
+
const manifest = (await readText(join(repo, ".sdd", "sdd-manifest.yaml"))) ?? "";
|
|
111
|
+
let profile = "";
|
|
112
|
+
let sddId = "";
|
|
113
|
+
try {
|
|
114
|
+
const m = parseDocument(manifest).toJS();
|
|
115
|
+
profile = String(m?.profile ?? "");
|
|
116
|
+
sddId = String(m?.sdd_id ?? "");
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
// the manifest check reports an unparseable manifest
|
|
120
|
+
}
|
|
121
|
+
const view = viewOf(files ?? [], problems);
|
|
122
|
+
const built = builtLookup(repo);
|
|
123
|
+
for (const c of schema.catalogues)
|
|
124
|
+
if (c.sourceOfTruth !== "structure")
|
|
125
|
+
await built.load(c.name);
|
|
126
|
+
let allowed = null;
|
|
127
|
+
if (profile === "platform-profile") {
|
|
128
|
+
allowed = new Set(["platform", ...(view.get("domains")?.keys() ?? []), ...(built("domains", "id") ?? [])]);
|
|
129
|
+
}
|
|
130
|
+
else if (sddId !== "") {
|
|
131
|
+
allowed = new Set([sddId]);
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
lines.push("partitions: not checked (no profile or sdd_id in .sdd/sdd-manifest.yaml)");
|
|
135
|
+
}
|
|
136
|
+
const checkPartition = (partition, file) => {
|
|
137
|
+
if (allowed !== null && !allowed.has(partition)) {
|
|
138
|
+
problems.push(`${file}: partition ${partition} is not one this SDD has (${profile === "platform-profile" ? "a domain id, or platform" : `its sdd_id, ${sddId}`})`);
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
for (const f of files ?? [])
|
|
142
|
+
checkPartition(f.partition, f.file);
|
|
143
|
+
// The current view.
|
|
144
|
+
const unresolved = new Set();
|
|
145
|
+
const currentProblems = [];
|
|
146
|
+
validateView(schema, view, built, currentProblems, unresolved);
|
|
147
|
+
problems.push(...currentProblems);
|
|
148
|
+
const elementCount = [...view.values()].reduce((n, m) => n + m.size, 0);
|
|
149
|
+
lines.push(files === null ? `model: none (${STRUCTURE_DIR}/ absent)` : `model: ${files.length} partition file(s), ${elementCount} element(s) in ${view.size} catalogue(s)`);
|
|
150
|
+
if (unresolved.size > 0)
|
|
151
|
+
lines.push(`references into ${[...unresolved].sort().join(", ")}: unresolved (no built catalogue)`);
|
|
152
|
+
// The generated views, against a render of the current view. An SDD that has not rendered them yet (no
|
|
153
|
+
// docs/structure/) is adopting the model, which keeps its originals until the review is complete (§6 step 1).
|
|
154
|
+
if (files !== null && (await subdirs(join(repo, "docs"))).includes("structure")) {
|
|
155
|
+
let current = 0;
|
|
156
|
+
for (const [path, rendered] of renderViews(view)) {
|
|
157
|
+
const state = viewState(rendered, await readText(join(repo, path)));
|
|
158
|
+
if (state === "current")
|
|
159
|
+
current++;
|
|
160
|
+
else
|
|
161
|
+
problems.push(describeState(path, state));
|
|
162
|
+
}
|
|
163
|
+
lines.push(`generated views: ${current} of ${VIEW_COUNT} current`);
|
|
164
|
+
}
|
|
165
|
+
else if (files !== null) {
|
|
166
|
+
lines.push("generated views: not rendered (no docs/structure/); `sdd-cli structure render` writes them");
|
|
167
|
+
}
|
|
168
|
+
// Open changes.
|
|
169
|
+
const addedBy = new Map();
|
|
170
|
+
for (const s of structures)
|
|
171
|
+
for (const d of s.deltas)
|
|
172
|
+
for (const e of d.added)
|
|
173
|
+
addedBy.set(elementKey(e.catalogue, e.id), s.change);
|
|
174
|
+
const features = new Set(view.get("features")?.keys() ?? []);
|
|
175
|
+
let withDeltas = 0;
|
|
176
|
+
let noOps = 0;
|
|
177
|
+
let without = 0;
|
|
178
|
+
for (const s of structures) {
|
|
179
|
+
const dir = join(CHANGES_DIR, s.change);
|
|
180
|
+
for (const d of s.deltas)
|
|
181
|
+
checkPartition(d.partition, d.file);
|
|
182
|
+
if (s.deltas.length > 0)
|
|
183
|
+
withDeltas++;
|
|
184
|
+
if (s.noOp)
|
|
185
|
+
noOps++;
|
|
186
|
+
if (!s.present || (s.deltas.length === 0 && !s.noOp)) {
|
|
187
|
+
without++;
|
|
188
|
+
if (files !== null)
|
|
189
|
+
problems.push(`${dir}: no structural artifact; the model exists, so a change states its structural deltas or claims none (structure/none.yaml)`);
|
|
190
|
+
}
|
|
191
|
+
if (s.noOp && s.deltas.length > 0)
|
|
192
|
+
problems.push(`${s.noOp.file}: a no-op claim beside ${s.deltas.length} partition delta(s); state one or the other`);
|
|
193
|
+
const elsewhere = new Map([...addedBy].filter(([, c]) => c !== s.change));
|
|
194
|
+
validateDeltas(schema, view, s.deltas, elsewhere, problems);
|
|
195
|
+
// A behaviour delta for a feature the model does not hold needs that feature added here.
|
|
196
|
+
const adds = new Set(s.deltas.flatMap((d) => d.added.filter((e) => e.catalogue === "features").map((e) => e.id)));
|
|
197
|
+
for (const feature of await subdirs(join(repo, dir, "specs"))) {
|
|
198
|
+
if (features.has(feature) || adds.has(feature))
|
|
199
|
+
continue;
|
|
200
|
+
if (files === null && features.size === 0 && !s.present)
|
|
201
|
+
continue;
|
|
202
|
+
if (s.noOp)
|
|
203
|
+
problems.push(`${s.noOp.file}: claims no structural effect, and the change's behaviour deltas create a living spec for ${feature}, a feature the model does not hold`);
|
|
204
|
+
else
|
|
205
|
+
problems.push(`${dir}: a behaviour delta for ${feature}, a feature the model does not hold, and no structural delta adds features/${feature}`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
lines.push(`changes: ${changes.length} open, ${withDeltas} with structural deltas, ${noOps} claiming none, ${without} without the artifact`);
|
|
209
|
+
// Composition (M-ADR 0230 §4.5): the three views and the three checks.
|
|
210
|
+
const open = new Map();
|
|
211
|
+
for (const s of structures)
|
|
212
|
+
open.set(s.change, { structure: s, status: await agreementStatus(repo, s.change) });
|
|
213
|
+
const archived = await archivedNames(repo);
|
|
214
|
+
checkOverlap(open, problems);
|
|
215
|
+
checkDependencies(open, archived, problems);
|
|
216
|
+
const known = new Set(currentProblems);
|
|
217
|
+
const atAgreed = new Set();
|
|
218
|
+
for (const [name, oc] of open) {
|
|
219
|
+
if (oc.structure.deltas.length === 0)
|
|
220
|
+
continue;
|
|
221
|
+
const at = [];
|
|
222
|
+
validateView(schema, viewAt(view, name, open), built, at, new Set());
|
|
223
|
+
for (const p of at) {
|
|
224
|
+
if (known.has(p))
|
|
225
|
+
continue;
|
|
226
|
+
problems.push(`at ${name}: ${p}`);
|
|
227
|
+
if (oc.status === "agreed")
|
|
228
|
+
atAgreed.add(p);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
const agreed = agreedOrder(open);
|
|
232
|
+
if (agreed.length > 0) {
|
|
233
|
+
const inIntended = [];
|
|
234
|
+
validateView(schema, intended(view, open), built, inIntended, new Set());
|
|
235
|
+
for (const p of inIntended) {
|
|
236
|
+
if (known.has(p) || atAgreed.has(p))
|
|
237
|
+
continue;
|
|
238
|
+
const by = attribute(p, open, agreed);
|
|
239
|
+
problems.push(`in the intended view, from ${by.length > 0 ? by.join(" with ") : "the agreed changes together"}: ${p}`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
// An archived change with structural deltas and no effect record: archived by a raw `openspec archive`, so its
|
|
243
|
+
// behaviour moved and its structure did not. Its structural transition is not shipped (§4.6).
|
|
244
|
+
let raw = 0;
|
|
245
|
+
for (const dir of await subdirs(join(repo, CHANGES_DIR, "archive"))) {
|
|
246
|
+
const ap = [];
|
|
247
|
+
const s = await loadChangeStructure(repo, join("archive", dir), ap);
|
|
248
|
+
if (s.deltas.length === 0)
|
|
249
|
+
continue;
|
|
250
|
+
if ((await readText(join(repo, CHANGES_DIR, "archive", dir, "structure", APPLIED_FILE))) !== null)
|
|
251
|
+
continue;
|
|
252
|
+
raw++;
|
|
253
|
+
problems.push(`${join(CHANGES_DIR, "archive", dir)}: archived with structural deltas and no effect record (structure/${APPLIED_FILE}), as a raw \`openspec archive\` leaves it; its structure is not applied, so the transition is not shipped. Run \`sdd-cli change archive ${dir.replace(/^\d{4}-\d{2}-\d{2}-/, "")}\` to apply it, guarded by its base revisions`);
|
|
254
|
+
}
|
|
255
|
+
if (raw > 0)
|
|
256
|
+
lines.push(`archived changes whose structure is not applied: ${raw}`);
|
|
257
|
+
lines.push(`views: current; at each of ${[...open.values()].filter((o) => o.structure.deltas.length > 0).length} change(s) with deltas; intended from ${agreed.length} agreed change(s)`);
|
|
258
|
+
const body = lines.map((l) => `${l}\n`).join("");
|
|
259
|
+
if (problems.length === 0)
|
|
260
|
+
return { exitCode: 0, stdout: `structural-model:\n${body}[PASS] structural-model: ok\n` };
|
|
261
|
+
const tag = enforced ? "FAIL" : "WARN";
|
|
262
|
+
const tail = enforced ? `[FAIL] structural-model: ${problems.length} finding(s)\n` : `[WARN] structural-model: ${problems.length} finding(s), report only (M-ADR 0230, Proposed)\n`;
|
|
263
|
+
return { exitCode: enforced ? 1 : 0, stdout: `structural-model:\n${body}${problems.map((p) => `[${tag}] ${p}\n`).join("")}${tail}` };
|
|
264
|
+
}
|
|
265
|
+
//# sourceMappingURL=structural-model.js.map
|
package/dist/verify-version.json
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sdd-method/maintain-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"sddCliVersion": "
|
|
3
|
+
"version": "0.13.0",
|
|
4
|
+
"sddCliVersion": "4.0.0",
|
|
5
5
|
"description": "Bring an SDD repository current and rebuild its derived model — sync, briefing, catalogue build and aggregate, pack build, context pull — plus every read-only verify verb. Ships only the code those verbs reach; it cannot author specification content. Authoring is in @sdd-method/sdd-cli.",
|
|
6
6
|
"license": "UNLICENSED",
|
|
7
7
|
"type": "module",
|