@sdd-method/author-cli 0.13.0 → 0.14.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/README.md +1 -1
- package/dist/author-program.js +10 -2
- package/dist/lib/catalogue/build.js +58 -12
- package/dist/lib/catalogue/builders/adr-governs.js +1 -1
- package/dist/lib/catalogue/builders/adrs.js +2 -2
- package/dist/lib/catalogue/builders/contract-revisions.js +45 -7
- package/dist/lib/catalogue/builders/data-model.js +1 -1
- package/dist/lib/catalogue/builders/entity-archetype.js +121 -0
- package/dist/lib/catalogue/builders/quality-scores.js +1 -1
- package/dist/lib/catalogue/builders/requirements.js +1 -1
- package/dist/lib/catalogue/canonical-schema.yaml +41 -21
- package/dist/lib/catalogue/retired-catalogues.js +1 -1
- package/dist/lib/catalogue/rubric-coverage-map.yaml +7 -3
- package/dist/lib/catalogue/staleness-verdicts.js +3 -3
- package/dist/lib/structure/apply.js +71 -0
- package/dist/lib/structure/archive.js +330 -0
- package/dist/lib/structure/catalogue.js +31 -10
- package/dist/lib/structure/compose.js +1 -1
- package/dist/lib/structure/effects.js +1 -1
- package/dist/lib/structure/extract.js +123 -0
- package/dist/lib/structure/intended.js +199 -0
- package/dist/lib/structure/model.js +1 -1
- package/dist/lib/structure/render.js +1 -1
- package/dist/lib/structure/view.js +8 -3
- package/dist/lib/validate/agreement-evidence.js +2 -2
- package/dist/lib/validate/behaviour-store.js +49 -12
- package/dist/lib/validate/dispatch.js +1 -1
- package/dist/lib/validate/structural-model.js +48 -35
- package/dist/maintain-version.json +2 -2
- package/dist/verbs/change.js +15 -0
- package/dist/verbs/migrate-structure.js +36 -0
- package/dist/verify-version.json +2 -2
- package/package.json +2 -2
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
// The method's archive operation (M-ADR 0230 §4.6): behaviour and structure published as one unit.
|
|
2
|
+
// 1. prepare the full candidate in a scratch worktree at the store's current commit: OpenSpec archives the
|
|
3
|
+
// change there (behaviour), and its structural deltas are applied to the model with a record of
|
|
4
|
+
// their effect (structure/applied.yaml in the archived change)
|
|
5
|
+
// 2. validate the candidate as a whole: the change's deltas still apply (§4.5 again at archive), the model as it
|
|
6
|
+
// would stand fails nowhere the current model does not, and the behaviour-store check finds nothing
|
|
7
|
+
// wrong with the archived change (its agreed content, its verdict)
|
|
8
|
+
// 3. publish one commit, fast-forwarded onto the store only if the store has not moved since preparation; the
|
|
9
|
+
// model's generated views (docs/structure/) are rendered into it, since they are required and derived
|
|
10
|
+
// (M-ADR 0230 §4.3, at its acceptance)
|
|
11
|
+
// Readers take a published commit, so they see the old state or the new. A retry is idempotent: an archived change
|
|
12
|
+
// whose effect record holds at the commit that wrote it is reported as already archived. Recovery works from what
|
|
13
|
+
// the store holds: an archived change with structural deltas and no effect record (a raw `openspec archive`) has its
|
|
14
|
+
// deltas applied only if each element it writes is still at the revision it was written against.
|
|
15
|
+
//
|
|
16
|
+
// The store is the SDD's own `openspec/` in a git repository. Writes; never part of the verify-only CLI.
|
|
17
|
+
import { execFile as execFileCb } from "node:child_process";
|
|
18
|
+
import { mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
19
|
+
import { tmpdir } from "node:os";
|
|
20
|
+
import { dirname, join, relative } from "node:path";
|
|
21
|
+
import { promisify } from "node:util";
|
|
22
|
+
import { parseRequirements, resolveImplements } from "../behaviour-store/archive.js";
|
|
23
|
+
import { buildRequirements } from "../catalogue/builders/requirements.js";
|
|
24
|
+
import { parseCatalogueCsv } from "../catalogue/csv-reader.js";
|
|
25
|
+
import { FindingsLog } from "../catalogue/findings.js";
|
|
26
|
+
import { loadCanonicalSchema } from "../catalogue/schema-loader.js";
|
|
27
|
+
import { checkBehaviourStore, parseAgreementRecord } from "../validate/behaviour-store.js";
|
|
28
|
+
import { appliedRecord, applyToModel, effectsOf, parseAppliedRecord } from "./apply.js";
|
|
29
|
+
import { APPLIED_FILE, loadChangeStructure, loadModel } from "./model.js";
|
|
30
|
+
import { renderViews } from "./render.js";
|
|
31
|
+
import { revisionOf, validateDeltas, validateView, viewOf } from "./view.js";
|
|
32
|
+
const execFile = promisify(execFileCb);
|
|
33
|
+
const CHANGES = join("openspec", "changes");
|
|
34
|
+
/** Exit codes: 0 archived or already archived, 1 refused, 75 interrupted (tests). */
|
|
35
|
+
export const INTERRUPTED = 75;
|
|
36
|
+
async function git(cwd, ...args) {
|
|
37
|
+
return (await execFile("git", ["-C", cwd, ...args], { maxBuffer: 64 * 1024 * 1024 })).stdout.trim();
|
|
38
|
+
}
|
|
39
|
+
async function exists(p) {
|
|
40
|
+
return stat(p).then(() => true, () => false);
|
|
41
|
+
}
|
|
42
|
+
async function subdirs(p) {
|
|
43
|
+
try {
|
|
44
|
+
return (await readdir(p, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name).sort();
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
async function archivedDir(sddPath, change) {
|
|
51
|
+
const found = (await subdirs(join(sddPath, CHANGES, "archive"))).filter((d) => d.replace(/^\d{4}-\d{2}-\d{2}-/, "") === change);
|
|
52
|
+
return found.length > 0 ? found[found.length - 1] : null;
|
|
53
|
+
}
|
|
54
|
+
async function defaultOpenspecArchive(cwd, change) {
|
|
55
|
+
try {
|
|
56
|
+
const r = await execFile("openspec", ["archive", change, "--yes"], { cwd, env: { ...process.env, OPENSPEC_TELEMETRY: "0" }, maxBuffer: 16 * 1024 * 1024 });
|
|
57
|
+
return { code: 0, output: r.stdout + r.stderr };
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
const e = err;
|
|
61
|
+
return { code: typeof e.code === "number" ? e.code : 127, output: `${e.stdout ?? ""}${e.stderr ?? ""}` };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
async function defaultBehaviourFindings(sddPath, archiveDir) {
|
|
65
|
+
const r = await checkBehaviourStore(sddPath);
|
|
66
|
+
return r.stdout.split("\n").filter((l) => l.startsWith("[FAIL]") && l.includes(archiveDir));
|
|
67
|
+
}
|
|
68
|
+
function viewProblems(schema, view) {
|
|
69
|
+
const out = [];
|
|
70
|
+
validateView(schema, view, () => null, out, new Set());
|
|
71
|
+
return new Set(out);
|
|
72
|
+
}
|
|
73
|
+
/** Does the effect record hold at `commit`? Each added or modified element at its target, each removed one absent. */
|
|
74
|
+
async function verifyAt(root, relSdd, commit, effects) {
|
|
75
|
+
const scratch = await mkdtemp(join(tmpdir(), "sdd-archive-verify-"));
|
|
76
|
+
try {
|
|
77
|
+
await git(root, "worktree", "add", "--detach", "-q", scratch, commit);
|
|
78
|
+
const problems = [];
|
|
79
|
+
const files = (await loadModel(join(scratch, relSdd), problems)) ?? [];
|
|
80
|
+
const view = viewOf(files, problems);
|
|
81
|
+
const bad = [];
|
|
82
|
+
for (const e of effects) {
|
|
83
|
+
const [catalogue, ...rest] = e.element.split("/");
|
|
84
|
+
const el = view.get(catalogue)?.get(rest.join("/"));
|
|
85
|
+
if (e.operation === "REMOVED") {
|
|
86
|
+
if (el)
|
|
87
|
+
bad.push(`${e.element} is still present`);
|
|
88
|
+
}
|
|
89
|
+
else if (!el)
|
|
90
|
+
bad.push(`${e.element} is absent`);
|
|
91
|
+
else if (revisionOf(el) !== e.target)
|
|
92
|
+
bad.push(`${e.element} is at ${revisionOf(el).slice(0, 19)}…, not the recorded ${String(e.target).slice(0, 19)}…`);
|
|
93
|
+
}
|
|
94
|
+
return bad;
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
await git(root, "worktree", "remove", "--force", scratch).catch(() => undefined);
|
|
98
|
+
await rm(scratch, { recursive: true, force: true });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
export async function archiveChange(o) {
|
|
102
|
+
const say = (s) => o.write(`${s}\n`);
|
|
103
|
+
const refuse = (s) => {
|
|
104
|
+
say(`[FAIL] change archive ${o.change}: ${s}`);
|
|
105
|
+
return 1;
|
|
106
|
+
};
|
|
107
|
+
let root;
|
|
108
|
+
try {
|
|
109
|
+
root = await git(o.sddPath, "rev-parse", "--show-toplevel");
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return refuse(`${o.sddPath} is not in a git repository; the archive is published as a commit`);
|
|
113
|
+
}
|
|
114
|
+
const relSdd = relative(root, o.sddPath) || ".";
|
|
115
|
+
const schema = o.schema ?? (await loadCanonicalSchema());
|
|
116
|
+
const openDir = join(CHANGES, o.change);
|
|
117
|
+
const isOpen = await exists(join(o.sddPath, openDir));
|
|
118
|
+
const archived = await archivedDir(o.sddPath, o.change);
|
|
119
|
+
// Already archived: verify, or recover a raw archive.
|
|
120
|
+
if (!isOpen && archived !== null) {
|
|
121
|
+
const archRel = join(CHANGES, "archive", archived);
|
|
122
|
+
const problems = [];
|
|
123
|
+
const s = await loadChangeStructure(o.sddPath, join("archive", archived), problems);
|
|
124
|
+
if (problems.length > 0)
|
|
125
|
+
return refuse(`its archived structural artifact is malformed: ${problems.join("; ")}`);
|
|
126
|
+
if (s.deltas.length === 0) {
|
|
127
|
+
say(`[PASS] change archive ${o.change}: already archived (${archRel}); it carries no structural deltas`);
|
|
128
|
+
return 0;
|
|
129
|
+
}
|
|
130
|
+
const recordPath = join(o.sddPath, archRel, "structure", APPLIED_FILE);
|
|
131
|
+
const text = await readFile(recordPath, "utf8").catch(() => null);
|
|
132
|
+
if (text !== null) {
|
|
133
|
+
const rec = parseAppliedRecord(text);
|
|
134
|
+
if (rec === null)
|
|
135
|
+
return refuse(`${join(archRel, "structure", APPLIED_FILE)} is not a valid effect record`);
|
|
136
|
+
const at = await git(root, "log", "-1", "--format=%H", "--", relative(root, recordPath));
|
|
137
|
+
if (at === "")
|
|
138
|
+
return refuse(`${join(archRel, "structure", APPLIED_FILE)} is not committed; the archive was not published`);
|
|
139
|
+
const bad = await verifyAt(root, relSdd, at, rec.effects);
|
|
140
|
+
if (bad.length > 0)
|
|
141
|
+
return refuse(`its effect record does not hold at ${at.slice(0, 12)}, the commit that wrote it: ${bad.join("; ")}`);
|
|
142
|
+
say(`[PASS] change archive ${o.change}: already archived at ${at.slice(0, 12)}; its ${rec.effects.length} structural effect(s) verified there`);
|
|
143
|
+
return 0;
|
|
144
|
+
}
|
|
145
|
+
// A raw archive: the behaviour moved, the structure did not. Apply it, guarded by each written element's base.
|
|
146
|
+
return publish(o, root, relSdd, schema, s, archRel, "recover");
|
|
147
|
+
}
|
|
148
|
+
if (!isOpen)
|
|
149
|
+
return refuse(`no open change ${openDir}, and none archived`);
|
|
150
|
+
// An open change: preconditions.
|
|
151
|
+
const baseline = await exists(join(o.sddPath, openDir, "baseline-evidence.md"));
|
|
152
|
+
if (!baseline) {
|
|
153
|
+
const recFile = join("docs", "agreements", `${o.change}.yaml`);
|
|
154
|
+
const recText = await readFile(join(o.sddPath, recFile), "utf8").catch(() => null);
|
|
155
|
+
if (recText === null)
|
|
156
|
+
return refuse(`no agreement record (${recFile}); a change archives on its judge's PASS`);
|
|
157
|
+
const { record } = parseAgreementRecord(recFile, recText);
|
|
158
|
+
if (!record)
|
|
159
|
+
return refuse(`${recFile} does not parse as an agreement record`);
|
|
160
|
+
if (record.status !== "agreed")
|
|
161
|
+
return refuse(`${recFile} says status ${record.status}, not agreed`);
|
|
162
|
+
if (String(record.verdict?.result ?? "") !== "PASS")
|
|
163
|
+
return refuse(`${recFile} has no verdict.result PASS; a change archives on its judge's PASS`);
|
|
164
|
+
const left = await leftOut(o.sddPath, o.change, record.workOrders, record.noLongerImplements);
|
|
165
|
+
if (left.length > 0)
|
|
166
|
+
return refuse(left.join("\n "));
|
|
167
|
+
}
|
|
168
|
+
const problems = [];
|
|
169
|
+
const s = await loadChangeStructure(o.sddPath, o.change, problems);
|
|
170
|
+
if (problems.length > 0)
|
|
171
|
+
return refuse(`its structural artifact is malformed: ${problems.join("; ")}`);
|
|
172
|
+
for (const dep of new Set(s.deltas.flatMap((d) => [...d.dependsOn]))) {
|
|
173
|
+
if ((await archivedDir(o.sddPath, dep)) === null)
|
|
174
|
+
return refuse(`it depends on ${dep}, which has not archived; a change archives after its dependencies`);
|
|
175
|
+
}
|
|
176
|
+
return publish(o, root, relSdd, schema, s, openDir, "archive");
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* M-ADR 0230 §4.7: when a change moves a requirement to a new revision, each repository with evidence about the
|
|
180
|
+
* current one implements the new one too, through a work order, or the record says it no longer implements it
|
|
181
|
+
* (`no_longer_implements`). Otherwise the archive would leave that repository's evidence about a revision that is no
|
|
182
|
+
* longer live, and nothing about the one that is. Reads the store as the catalogue builder does.
|
|
183
|
+
*/
|
|
184
|
+
async function leftOut(sddPath, change, workOrders, noLonger) {
|
|
185
|
+
// Only a change that modifies a requirement can leave a repository behind; any change can name a requirement
|
|
186
|
+
// ambiguously.
|
|
187
|
+
const delta = [];
|
|
188
|
+
let modifies = false;
|
|
189
|
+
for (const feature of await subdirs(join(sddPath, CHANGES, change, "specs"))) {
|
|
190
|
+
const text = await readFile(join(sddPath, CHANGES, change, "specs", feature, "spec.md"), "utf8").catch(() => "");
|
|
191
|
+
for (const x of parseRequirements(text)) {
|
|
192
|
+
if (x.operation === "MODIFIED")
|
|
193
|
+
modifies = true;
|
|
194
|
+
if (x.operation !== "REMOVED")
|
|
195
|
+
delta.push({ feature, name: x.name });
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
// A requirement named in a work order or under no_longer_implements: `<feature>::<name>`, or a bare name only one
|
|
199
|
+
// feature in the change has, as the record schema says. An ambiguous bare name names neither, and is refused.
|
|
200
|
+
const ambiguous = [];
|
|
201
|
+
const resolve = (entry, where) => {
|
|
202
|
+
const r = resolveImplements(delta, entry);
|
|
203
|
+
if (r.ambiguous.length > 0)
|
|
204
|
+
ambiguous.push(`${where} names "${entry}", a requirement ${r.ambiguous.length} features in the change share (${r.ambiguous.join(", ")}); qualify it as <feature>::<name>`);
|
|
205
|
+
return new Set(r.matches.map((d) => `${d.feature}::${d.name}`));
|
|
206
|
+
};
|
|
207
|
+
const implementsOf = workOrders.map((w) => ({ repo: w.repo, ids: new Set(w.implements.flatMap((i) => [...resolve(i, `the work order for ${w.repo}`)])) }));
|
|
208
|
+
const excusedOf = noLonger.map((n) => ({ repo: n.repo, ids: resolve(n.requirement, `no_longer_implements for ${n.repo}`) }));
|
|
209
|
+
if (ambiguous.length > 0)
|
|
210
|
+
return ambiguous;
|
|
211
|
+
if (!modifies)
|
|
212
|
+
return [];
|
|
213
|
+
const ids = async (catalogue) => {
|
|
214
|
+
const text = await readFile(join(sddPath, "catalogues", `${catalogue}.csv`), "utf8").catch(() => null);
|
|
215
|
+
return text === null ? null : new Set(parseCatalogueCsv(text).rows.map((r) => String(r["id"])));
|
|
216
|
+
};
|
|
217
|
+
const features = await ids("features");
|
|
218
|
+
const repos = await ids("repositories");
|
|
219
|
+
if (features === null || repos === null)
|
|
220
|
+
return ["the catalogue (catalogues/features.csv and repositories.csv) is needed to tell which repositories implement the requirements this change modifies; build it first"];
|
|
221
|
+
const r = await buildRequirements({ sddPath, findings: new FindingsLog(), knownFeatureIds: features, knownRepoIds: repos, sddId: "local" });
|
|
222
|
+
const state = new Map(r.revisions.map((x) => [String(x["id"]), String(x["state"])]));
|
|
223
|
+
const modified = r.effects
|
|
224
|
+
.filter((e) => e["change_id"] === `local::${change}` && e["element_kind"] === "requirement" && e["operation"] === "MODIFIED")
|
|
225
|
+
.map((e) => String(e["element_id"]));
|
|
226
|
+
const out = [];
|
|
227
|
+
for (const req of modified) {
|
|
228
|
+
const implementers = new Set(r.assertions.filter((a) => a["requirement_id"] === req && state.get(String(a["revision_id"])) === "current").map((a) => String(a["repo_id"])));
|
|
229
|
+
const covered = new Set(implementsOf.filter((w) => w.ids.has(req)).map((w) => w.repo));
|
|
230
|
+
const excused = new Set(excusedOf.filter((n) => n.ids.has(req)).map((n) => n.repo));
|
|
231
|
+
const missing = [...implementers].filter((repo) => !covered.has(repo) && !excused.has(repo)).sort();
|
|
232
|
+
if (missing.length > 0) {
|
|
233
|
+
out.push(`${req}: ${missing.join(", ")} ${missing.length === 1 ? "implements" : "implement"} its current revision, and no work order of this change implements the new one; add a work order for ${missing.length === 1 ? "it" : "each"}, or record it under no_longer_implements`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return out;
|
|
237
|
+
}
|
|
238
|
+
async function publish(o, root, relSdd, schema, s, changeRel, mode) {
|
|
239
|
+
const say = (x) => o.write(`${x}\n`);
|
|
240
|
+
const refuse = (x) => {
|
|
241
|
+
say(`[FAIL] change archive ${o.change}: ${x}`);
|
|
242
|
+
return 1;
|
|
243
|
+
};
|
|
244
|
+
const storeRel = join(relSdd, "openspec");
|
|
245
|
+
const viewsRel = join(relSdd, "docs", "structure");
|
|
246
|
+
const dirty = await git(root, "status", "--porcelain", "--", storeRel, viewsRel);
|
|
247
|
+
if (dirty !== "")
|
|
248
|
+
return refuse(`the store has uncommitted changes (${dirty.split("\n").length} path(s) under ${storeRel} or ${viewsRel}); the archive is published as one commit onto a clean store`);
|
|
249
|
+
const base = await git(root, "rev-parse", "HEAD");
|
|
250
|
+
// §4.5 again, against the model as it now is.
|
|
251
|
+
const problems = [];
|
|
252
|
+
const current = viewOf((await loadModel(o.sddPath, problems)) ?? [], problems);
|
|
253
|
+
const before = viewProblems(schema, current);
|
|
254
|
+
const deltaProblems = [];
|
|
255
|
+
validateDeltas(schema, current, s.deltas, new Map(), deltaProblems);
|
|
256
|
+
if (deltaProblems.length > 0)
|
|
257
|
+
return refuse(`its structural deltas no longer apply to the model as it stands:\n ${deltaProblems.join("\n ")}`);
|
|
258
|
+
const effects = effectsOf(s, current);
|
|
259
|
+
const scratch = await mkdtemp(join(tmpdir(), "sdd-archive-"));
|
|
260
|
+
let added = false;
|
|
261
|
+
try {
|
|
262
|
+
await git(root, "worktree", "add", "--detach", "-q", scratch, base);
|
|
263
|
+
added = true;
|
|
264
|
+
const scratchSdd = join(scratch, relSdd);
|
|
265
|
+
// 1. Prepare.
|
|
266
|
+
let archiveRel = changeRel;
|
|
267
|
+
if (mode === "archive") {
|
|
268
|
+
const beforeDirs = new Set(await subdirs(join(scratchSdd, CHANGES, "archive")));
|
|
269
|
+
const r = await (o.openspecArchive ?? defaultOpenspecArchive)(scratchSdd, o.change);
|
|
270
|
+
if (r.code !== 0)
|
|
271
|
+
return refuse(`openspec archive failed (exit ${r.code}): ${r.output.trim().split("\n").slice(-3).join(" ")}`);
|
|
272
|
+
const fresh = (await subdirs(join(scratchSdd, CHANGES, "archive"))).filter((d) => !beforeDirs.has(d));
|
|
273
|
+
if (fresh.length !== 1)
|
|
274
|
+
return refuse(`openspec archive left ${fresh.length} new archive folder(s), not one`);
|
|
275
|
+
archiveRel = join(CHANGES, "archive", fresh[0]);
|
|
276
|
+
}
|
|
277
|
+
await applyToModel(scratchSdd, s);
|
|
278
|
+
if (s.deltas.length > 0)
|
|
279
|
+
await writeFile(join(scratchSdd, archiveRel, "structure", APPLIED_FILE), appliedRecord(o.change, base, effects), "utf8");
|
|
280
|
+
// 2. Validate the candidate as a whole.
|
|
281
|
+
const cp = [];
|
|
282
|
+
const candidate = viewOf((await loadModel(scratchSdd, cp)) ?? [], cp);
|
|
283
|
+
const after = viewProblems(schema, candidate);
|
|
284
|
+
const worse = [...cp, ...[...after].filter((p) => !before.has(p))];
|
|
285
|
+
if (worse.length > 0)
|
|
286
|
+
return refuse(`the model as it would stand fails where it did not:\n ${worse.join("\n ")}`);
|
|
287
|
+
if (mode === "archive") {
|
|
288
|
+
const bf = await (o.behaviourFindings ?? defaultBehaviourFindings)(scratchSdd, archiveRel.split("/").pop());
|
|
289
|
+
if (bf.length > 0)
|
|
290
|
+
return refuse(`the behaviour-store check refuses the archived change:\n ${bf.join("\n ")}`);
|
|
291
|
+
}
|
|
292
|
+
if (o.interrupt === "prepared")
|
|
293
|
+
return INTERRUPTED;
|
|
294
|
+
// The generated views move with the model they render, in the same commit.
|
|
295
|
+
if (s.deltas.length > 0) {
|
|
296
|
+
for (const [path, text] of renderViews(candidate)) {
|
|
297
|
+
await mkdir(dirname(join(scratchSdd, path)), { recursive: true });
|
|
298
|
+
await writeFile(join(scratchSdd, path), text, "utf8");
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
// 3. Publish: one commit, fast-forwarded only onto the commit it was prepared from.
|
|
302
|
+
const withViews = await stat(join(scratch, viewsRel)).then(() => true, () => false);
|
|
303
|
+
await git(scratch, "add", "-A", "--", storeRel, ...(withViews ? [viewsRel] : []));
|
|
304
|
+
const message = mode === "archive"
|
|
305
|
+
? `Archive ${o.change}: its behaviour and structure (M-ADR 0230 §4.6)`
|
|
306
|
+
: `Apply the structure of archived ${o.change}, which was archived without it (M-ADR 0230 §4.6)`;
|
|
307
|
+
await git(scratch, "commit", "-q", "-m", message);
|
|
308
|
+
const commit = await git(scratch, "rev-parse", "HEAD");
|
|
309
|
+
if (o.interrupt === "committed")
|
|
310
|
+
return INTERRUPTED;
|
|
311
|
+
if ((await git(root, "rev-parse", "HEAD")) !== base)
|
|
312
|
+
return refuse(`the store moved since the candidate was prepared (from ${base.slice(0, 12)}); run it again`);
|
|
313
|
+
try {
|
|
314
|
+
await git(root, "merge", "--ff-only", "-q", commit);
|
|
315
|
+
}
|
|
316
|
+
catch (err) {
|
|
317
|
+
return refuse(`the store moved since the candidate was prepared, so nothing was published: ${err.message.split("\n")[0]}`);
|
|
318
|
+
}
|
|
319
|
+
if (o.interrupt === "published")
|
|
320
|
+
return INTERRUPTED;
|
|
321
|
+
say(`[PASS] change archive ${o.change}: ${mode === "archive" ? "archived" : "its structure applied"} in ${commit.slice(0, 12)}, ${effects.length} structural effect(s)${s.deltas.length > 0 ? `, recorded in ${join(archiveRel, "structure", APPLIED_FILE)}` : ""}`);
|
|
322
|
+
return 0;
|
|
323
|
+
}
|
|
324
|
+
finally {
|
|
325
|
+
if (added)
|
|
326
|
+
await git(root, "worktree", "remove", "--force", scratch).catch(() => undefined);
|
|
327
|
+
await rm(scratch, { recursive: true, force: true });
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
//# sourceMappingURL=archive.js.map
|
|
@@ -1,13 +1,15 @@
|
|
|
1
|
-
// The catalogue from the structural model, and the model a built catalogue implies (M-ADR 0230 §4.3, §5.3
|
|
2
|
-
//
|
|
1
|
+
// The catalogue from the structural model, and the model a built catalogue implies (M-ADR 0230 §4.3, §5.3).
|
|
2
|
+
// Where an SDD has a model, it is the record of structure, so every structure catalogue is built from it whole:
|
|
3
3
|
// a row per element of the current view, with the model's structure columns;
|
|
4
4
|
// its other columns (every class but structure: derived, declaration, evidence, intent, execution) from the
|
|
5
5
|
// source-built row with the same id;
|
|
6
6
|
// for an element only the model holds, the derived columns the model itself settles (MODEL_DERIVED).
|
|
7
7
|
// What the sources hold and the model does not, and a structure column where they disagree, is reported once per
|
|
8
|
-
// catalogue. The model wins: either finding means the sources moved outside a change.
|
|
8
|
+
// catalogue. The model wins: either finding means the sources moved outside a change. A source row that states an
|
|
9
|
+
// element as an agreed open change adds or modifies it is that change's intended element, not a disagreement: it is
|
|
10
|
+
// listed apart, and catalogued once the change archives. Pure: no file is read or written.
|
|
9
11
|
import { stringify } from "yaml";
|
|
10
|
-
import { FORMAT_VERSION } from "./model.js";
|
|
12
|
+
import { elementKey, FORMAT_VERSION } from "./model.js";
|
|
11
13
|
import { STRUCTURE } from "./view.js";
|
|
12
14
|
/** The view each generated file renders, for a derived path column of an element only the model holds. */
|
|
13
15
|
export const VIEW_PATHS = {
|
|
@@ -44,8 +46,10 @@ function sample(ids) {
|
|
|
44
46
|
/**
|
|
45
47
|
* Replace every structure catalogue's rows with the view's. Rows of other catalogues are returned as they are. Source
|
|
46
48
|
* rows sharing an id are merged first, later values filling earlier gaps, as the keyed-table dedupe would.
|
|
49
|
+
* `intended` holds the elements agreed open changes add or modify (intendedWrites); a source row agreeing with one is
|
|
50
|
+
* pushed to `intendedOut` as `<key> (<change>)` instead of being reported.
|
|
47
51
|
*/
|
|
48
|
-
export function overlayModel(schema, view, rows, findings) {
|
|
52
|
+
export function overlayModel(schema, view, rows, findings, intended = new Map(), intendedOut = []) {
|
|
49
53
|
const out = new Map(rows);
|
|
50
54
|
for (const cat of schema.catalogues) {
|
|
51
55
|
if (cat.sourceOfTruth !== STRUCTURE)
|
|
@@ -66,6 +70,20 @@ export function overlayModel(schema, view, rows, findings) {
|
|
|
66
70
|
const elements = view.get(cat.name) ?? new Map();
|
|
67
71
|
const built = [];
|
|
68
72
|
const differ = [];
|
|
73
|
+
const disagree = (from, e) => structural.filter((c) => cell(from[c]) !== "" && cell(from[c]) !== cell(e.columns[c]));
|
|
74
|
+
// The source row states the element as an agreed open change does; one stating it otherwise is reported as such.
|
|
75
|
+
const differIntended = [];
|
|
76
|
+
const asIntended = (id, from) => {
|
|
77
|
+
const w = intended.get(elementKey(cat.name, id));
|
|
78
|
+
if (w === undefined)
|
|
79
|
+
return false;
|
|
80
|
+
const cols = disagree(from, w.element);
|
|
81
|
+
if (cols.length > 0)
|
|
82
|
+
differIntended.push(`${id} (${cols.join(", ")}; ${w.change})`);
|
|
83
|
+
else
|
|
84
|
+
intendedOut.push(`${elementKey(cat.name, id)} (${w.change})`);
|
|
85
|
+
return true;
|
|
86
|
+
};
|
|
69
87
|
for (const e of elements.values()) {
|
|
70
88
|
const from = source.get(e.id);
|
|
71
89
|
const row = {};
|
|
@@ -73,8 +91,8 @@ export function overlayModel(schema, view, rows, findings) {
|
|
|
73
91
|
for (const [k, v] of Object.entries(from))
|
|
74
92
|
if (!structural.includes(k))
|
|
75
93
|
row[k] = v;
|
|
76
|
-
const cols =
|
|
77
|
-
if (cols.length > 0)
|
|
94
|
+
const cols = disagree(from, e);
|
|
95
|
+
if (cols.length > 0 && !asIntended(e.id, from))
|
|
78
96
|
differ.push(`${e.id} (${cols.join(", ")})`);
|
|
79
97
|
}
|
|
80
98
|
else {
|
|
@@ -85,12 +103,15 @@ export function overlayModel(schema, view, rows, findings) {
|
|
|
85
103
|
row[c] = e.columns[c];
|
|
86
104
|
built.push(row);
|
|
87
105
|
}
|
|
88
|
-
const missing = [...source.keys()].filter((id) => id !== "" && !elements.has(id)).sort();
|
|
106
|
+
const missing = [...source.keys()].filter((id) => id !== "" && !elements.has(id) && !asIntended(id, source.get(id))).sort();
|
|
89
107
|
if (missing.length > 0) {
|
|
90
|
-
findings.add("divergence", `${cat.name}: the sources hold ${missing.length} row(s) the structural model does not (${sample(missing)}); the model is the record of structure, so they are not catalogued. Add them through a change's structural deltas, or remove them from the sources (M-ADR 0230 §4.3
|
|
108
|
+
findings.add("divergence", `${cat.name}: the sources hold ${missing.length} row(s) the structural model does not (${sample(missing)}); the model is the record of structure, so they are not catalogued. Add them through a change's structural deltas, or remove them from the sources (M-ADR 0230 §4.3)`, "warn", `catalogue:${cat.name}`);
|
|
109
|
+
}
|
|
110
|
+
if (differIntended.length > 0) {
|
|
111
|
+
findings.add("divergence", `${cat.name}: ${differIntended.length} source row(s) state an element an agreed open change adds or modifies, and disagree with it: ${sample(differIntended.sort())}. The change's delta is what archives; bring the source into line with it, or the delta with the source (M-ADR 0230 §4.3, §4.7)`, "warn", `catalogue:${cat.name}`);
|
|
91
112
|
}
|
|
92
113
|
if (differ.length > 0) {
|
|
93
|
-
findings.add("divergence", `${cat.name}: ${differ.length} element(s) where the sources and the structural model disagree, catalogued as the model states them: ${sample(differ.sort())}. A source that is a generated view is rendered again; any other is brought into line through a change (M-ADR 0230 §4.3
|
|
114
|
+
findings.add("divergence", `${cat.name}: ${differ.length} element(s) where the sources and the structural model disagree, catalogued as the model states them: ${sample(differ.sort())}. A source that is a generated view is rendered again; any other is brought into line through a change (M-ADR 0230 §4.3)`, "warn", `catalogue:${cat.name}`);
|
|
94
115
|
}
|
|
95
116
|
out.set(cat.name, built);
|
|
96
117
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Views over the structural model, and how changes compose (M-ADR 0230 §4.5, §4.7
|
|
1
|
+
// Views over the structural model, and how changes compose (M-ADR 0230 §4.5, §4.7).
|
|
2
2
|
// current the model as published
|
|
3
3
|
// at a change current with that change's deltas and those of its dependency closure, dependencies first
|
|
4
4
|
// intended current with every agreed change's deltas
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// A change's structural effect, and the effect record an archive leaves (M-ADR 0230 §4.6, §4.7
|
|
1
|
+
// A change's structural effect, and the effect record an archive leaves (M-ADR 0230 §4.6, §4.7). Pure reads,
|
|
2
2
|
// so the catalogue builder and the verify-only CLI can carry them; writing the record is apply.ts's.
|
|
3
3
|
import { parseDocument } from "yaml";
|
|
4
4
|
import { elementKey, revision } from "./model.js";
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// An SDD's first structural model, extracted from its sources (M-ADR 0230 §5.3, §6 step 1). The builders
|
|
2
|
+
// already parse the sources: the diagrams, data models, service tables, configuration, traceability manifests,
|
|
3
|
+
// feature trees and groundings. So extraction builds the catalogue from the sources into a scratch directory, and
|
|
4
|
+
// projects the model it implies (modelFromRows). It writes the partition files and an extraction report beside
|
|
5
|
+
// them, and touches no source file. From the moment openspec/structure/ exists, committed or not, the catalogue is
|
|
6
|
+
// built from the model, so writing it is the admission: review the extraction against the code first, with --dry-run
|
|
7
|
+
// (which writes nothing) or in a scratch copy, and keep the originals until that review is complete.
|
|
8
|
+
import { execFileSync } from "node:child_process";
|
|
9
|
+
import { mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { runCatalogueBuild } from "../catalogue/build.js";
|
|
13
|
+
import { readCatalogueCsv } from "../catalogue/csv-reader.js";
|
|
14
|
+
import { loadCanonicalSchema } from "../catalogue/schema-loader.js";
|
|
15
|
+
import { modelFileText, modelFromRows } from "./catalogue.js";
|
|
16
|
+
import { STRUCTURE_DIR } from "./model.js";
|
|
17
|
+
import { STRUCTURE } from "./view.js";
|
|
18
|
+
export const REPORT_FILE = "extraction-report.md";
|
|
19
|
+
/** Derived path columns of structure catalogues: the source files the elements were read from. */
|
|
20
|
+
const SOURCE_PATH_COLUMNS = ["doc_path", "diagram_path", "spec_path"];
|
|
21
|
+
function head(sddPath) {
|
|
22
|
+
try {
|
|
23
|
+
const sha = execFileSync("git", ["-C", sddPath, "rev-parse", "--short", "HEAD"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
24
|
+
const dirty = execFileSync("git", ["-C", sddPath, "status", "--porcelain"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() !== "";
|
|
25
|
+
return dirty ? `${sha}, with uncommitted changes` : sha;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return "not a git repository";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function report(sddId, at, schemaVersion, x, sources, otherFindings) {
|
|
32
|
+
const lines = [
|
|
33
|
+
"# Structural model extraction",
|
|
34
|
+
"",
|
|
35
|
+
`Extracted by \`sdd-cli migrate structure\` from ${sddId}'s sources at ${at}, against catalogue schema ${schemaVersion} (M-ADR 0230).`,
|
|
36
|
+
"",
|
|
37
|
+
"**Writing this model admits it as the baseline** (M-ADR 0230 §6 step 1). From the moment `openspec/structure/` exists, committed or not, it is the record of structure: the catalogue is built from it, and the structural-model check asks every open change for its structural deltas or a `none.yaml`, and fails a change that has neither. Review it against the code before it is written (`sdd-cli migrate structure --dry-run` prints this report and writes nothing), and keep the sources listed below until that review is complete; nothing here changed them. Removing `openspec/structure/` withdraws a model written too soon. After admission, structure changes only through a change's structural deltas, and the diagrams and tables are generated by `sdd-cli structure render`.",
|
|
38
|
+
"",
|
|
39
|
+
"## Elements",
|
|
40
|
+
"",
|
|
41
|
+
"| Catalogue | Elements |",
|
|
42
|
+
"| --- | --- |",
|
|
43
|
+
...[...x.counts].map(([c, n]) => `| ${c} | ${n} |`),
|
|
44
|
+
"",
|
|
45
|
+
`Partitions: ${[...x.files.keys()].map((p) => `\`${p}\``).join(", ") || "none"}.`,
|
|
46
|
+
"",
|
|
47
|
+
"## What the extraction could not carry",
|
|
48
|
+
"",
|
|
49
|
+
];
|
|
50
|
+
if (x.loss.length === 0 && x.extraction.length === 0)
|
|
51
|
+
lines.push("Nothing: every structural fact the sources state that a builder reads is in the model.", "");
|
|
52
|
+
for (const l of x.loss)
|
|
53
|
+
lines.push(`- ${l}`);
|
|
54
|
+
for (const f of x.extraction)
|
|
55
|
+
lines.push(`- ${f.summary}`);
|
|
56
|
+
if (x.loss.length + x.extraction.length > 0)
|
|
57
|
+
lines.push("");
|
|
58
|
+
lines.push("Prose, and anything a builder does not read, stays in the original files only. A diagram's layout and styling are not structure, and the generated views do not reproduce them.", "", "## Sources the elements were read from", "", ...(sources.length > 0 ? sources.map((s) => `- \`${s}\``) : ["- (no element names its source file)"]), "", "## Other findings of the build", "", ...(otherFindings.size > 0 ? [...otherFindings].map(([c, n]) => `- ${c}: ${n}`) : ["- none"]), "", "These are the build's findings about the sources. They are reported by `sdd-cli catalogue build` as before, and extraction does not change them.", "");
|
|
59
|
+
return lines.join("\n");
|
|
60
|
+
}
|
|
61
|
+
/** Extract the model. Writes nothing; `writeExtraction` writes it. `build` is the catalogue build, replaceable in tests. */
|
|
62
|
+
export async function extractStructure(sddPath, build = runCatalogueBuild) {
|
|
63
|
+
const none = { files: new Map(), counts: new Map(), loss: [], extraction: [], report: "" };
|
|
64
|
+
try {
|
|
65
|
+
await readdir(join(sddPath, STRUCTURE_DIR));
|
|
66
|
+
return { ...none, refused: `${STRUCTURE_DIR}/ already exists; an SDD's first model is extracted once, and after that structure changes through a change's structural deltas` };
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// no model yet
|
|
70
|
+
}
|
|
71
|
+
const schema = await loadCanonicalSchema();
|
|
72
|
+
const scratch = await mkdtemp(join(tmpdir(), "sdd-migrate-structure-"));
|
|
73
|
+
try {
|
|
74
|
+
const built = await build({ sddPath, outDir: scratch }, () => undefined);
|
|
75
|
+
if (built.exitCode !== 0) {
|
|
76
|
+
return { ...none, refused: `the sources do not build cleanly (sdd-cli catalogue build exits ${built.exitCode}: ${built.internalFkPass ? "" : "dangling references, "}${built.pkConflicts} key conflict(s)); a model extracted from them would carry the same faults. Fix the build first` };
|
|
77
|
+
}
|
|
78
|
+
const rows = new Map();
|
|
79
|
+
for (const c of schema.catalogues) {
|
|
80
|
+
if (c.sourceOfTruth !== STRUCTURE)
|
|
81
|
+
continue;
|
|
82
|
+
rows.set(c.name, (await readCatalogueCsv(join(scratch, `${c.name}.csv`)).catch(() => ({ rows: [] }))).rows);
|
|
83
|
+
}
|
|
84
|
+
const loss = [];
|
|
85
|
+
const model = modelFromRows(schema, rows, { platform: built.profile === "platform-profile", sddId: built.sddId }, loss);
|
|
86
|
+
const files = new Map([...model].sort(([a], [b]) => a.localeCompare(b)).map(([p, els]) => [p, modelFileText(p, els)]));
|
|
87
|
+
const counts = new Map();
|
|
88
|
+
for (const els of model.values())
|
|
89
|
+
for (const [c, list] of Object.entries(els))
|
|
90
|
+
counts.set(c, (counts.get(c) ?? 0) + list.length);
|
|
91
|
+
const sortedCounts = new Map([...counts].sort(([a], [b]) => a.localeCompare(b)));
|
|
92
|
+
const sources = new Set();
|
|
93
|
+
for (const list of rows.values())
|
|
94
|
+
for (const r of list)
|
|
95
|
+
for (const col of SOURCE_PATH_COLUMNS)
|
|
96
|
+
if (r[col])
|
|
97
|
+
sources.add(String(r[col]));
|
|
98
|
+
const extraction = built.findings.filter((f) => f.category === "extraction");
|
|
99
|
+
const other = new Map();
|
|
100
|
+
for (const f of built.findings)
|
|
101
|
+
if (f.category !== "extraction")
|
|
102
|
+
other.set(String(f.category), (other.get(String(f.category)) ?? 0) + 1);
|
|
103
|
+
const x = { files, counts: sortedCounts, loss, extraction };
|
|
104
|
+
return { ...x, refused: null, report: report(built.sddId, head(sddPath), schema.schemaVersion, x, [...sources].sort(), new Map([...other].sort())) };
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
await rm(scratch, { recursive: true, force: true });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/** Write the partition files and the report under openspec/structure/. Returns the paths written. */
|
|
111
|
+
export async function writeExtraction(sddPath, x) {
|
|
112
|
+
const dir = join(sddPath, STRUCTURE_DIR);
|
|
113
|
+
await mkdir(dir, { recursive: true });
|
|
114
|
+
const written = [];
|
|
115
|
+
for (const [p, text] of x.files) {
|
|
116
|
+
await writeFile(join(dir, `${p}.yaml`), text, "utf8");
|
|
117
|
+
written.push(join(STRUCTURE_DIR, `${p}.yaml`));
|
|
118
|
+
}
|
|
119
|
+
await writeFile(join(dir, REPORT_FILE), x.report, "utf8");
|
|
120
|
+
written.push(join(STRUCTURE_DIR, REPORT_FILE));
|
|
121
|
+
return written;
|
|
122
|
+
}
|
|
123
|
+
//# sourceMappingURL=extract.js.map
|