@sdd-method/verify-cli 5.5.1 → 5.7.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/builders/feature-tree-walker.js +311 -0
- package/dist/lib/catalogue/builders/features.js +351 -0
- package/dist/lib/catalogue/canonical-schema.yaml +19 -1
- package/dist/lib/catalogue/coverage-predicates.js +19 -2
- package/dist/lib/catalogue/coverage-render.js +10 -1
- package/dist/lib/catalogue/impact-run.js +54 -2
- package/dist/lib/catalogue/rubric-coverage-map.yaml +4 -0
- package/dist/lib/derived-specs/index.js +501 -0
- package/dist/lib/derived-specs/source-path.js +275 -0
- package/dist/lib/structure/catalogue.js +13 -0
- package/dist/lib/validate/agreement-evidence.js +14 -3
- package/dist/lib/validate/behaviour-store.js +20 -2
- package/dist/lib/validate/carve-citations.js +64 -5
- package/dist/lib/validate/derived-specs.js +300 -0
- package/dist/lib/validate/dispatch.js +8 -1
- package/dist/maintain-version.json +2 -2
- package/dist/verbs/catalogue-verify.js +2 -0
- package/dist/verify-version.json +2 -2
- package/package.json +2 -2
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
// Derived living specs (M-ADR 0232): living specs generated, one way, from an executable source the SDD declares, never
|
|
2
|
+
// edited by hand. This module reads the declaration (.sdd/derived-specs.yaml) and the derivation record
|
|
3
|
+
// (.sdd/derived-specs.lock.yaml), runs a source's generator under the method's contract, resolves `Source:` lines
|
|
4
|
+
// against the source, and checks the living specs a source owns. The generator and the source's format are the
|
|
5
|
+
// adopter's; nothing here knows any schema.
|
|
6
|
+
import { execFile as execFileCb } from "node:child_process";
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import { existsSync } from "node:fs";
|
|
9
|
+
import { lstat, mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
10
|
+
import { homedir, tmpdir } from "node:os";
|
|
11
|
+
import { join, resolve } from "node:path";
|
|
12
|
+
import { promisify } from "node:util";
|
|
13
|
+
import { parse as parseYaml, parseDocument } from "yaml";
|
|
14
|
+
import { resolvePath, splitSourceLine } from "./source-path.js";
|
|
15
|
+
export { pathWithin, parsePath, renderPath, resolveNode, resolvePath, splitSourceLine } from "./source-path.js";
|
|
16
|
+
const execFile = promisify(execFileCb);
|
|
17
|
+
export const DERIVED_CONFIG = ".sdd/derived-specs.yaml";
|
|
18
|
+
export const DERIVED_LOCK = ".sdd/derived-specs.lock.yaml";
|
|
19
|
+
const SPECS = "openspec/specs";
|
|
20
|
+
export const sha256 = (text) => createHash("sha256").update(text).digest("hex");
|
|
21
|
+
/** The SDD's derived sources, or null when it declares none. Throws on a malformed declaration. */
|
|
22
|
+
export async function loadDerivedSources(repo) {
|
|
23
|
+
const text = await readFile(join(repo, DERIVED_CONFIG), "utf8").catch(() => null);
|
|
24
|
+
if (text === null)
|
|
25
|
+
return null;
|
|
26
|
+
const raw = parseYaml(text);
|
|
27
|
+
if (!Array.isArray(raw?.sources))
|
|
28
|
+
throw new Error(`${DERIVED_CONFIG}: expected a 'sources:' list`);
|
|
29
|
+
const out = [];
|
|
30
|
+
for (const [i, s] of raw.sources.entries()) {
|
|
31
|
+
const g = (s?.["generator"] ?? {});
|
|
32
|
+
const cmd = g["command"];
|
|
33
|
+
const id = String(s?.["id"] ?? "");
|
|
34
|
+
const source = String(s?.["source"] ?? "");
|
|
35
|
+
if (id === "" || source === "" || !Array.isArray(cmd) || cmd.length === 0 || !g["identity"] || !g["version"]) {
|
|
36
|
+
throw new Error(`${DERIVED_CONFIG}: sources[${i}] needs id, source, and generator.command (a list), generator.identity and generator.version`);
|
|
37
|
+
}
|
|
38
|
+
const fm = new Map();
|
|
39
|
+
const rawMap = s["features"];
|
|
40
|
+
if (rawMap !== undefined && rawMap !== null) {
|
|
41
|
+
if (typeof rawMap !== "object" || Array.isArray(rawMap))
|
|
42
|
+
throw new Error(`${DERIVED_CONFIG}: sources[${i}].features must map a feature id to a list of source paths`);
|
|
43
|
+
for (const [f, paths] of Object.entries(rawMap)) {
|
|
44
|
+
if (!Array.isArray(paths) || paths.length === 0)
|
|
45
|
+
throw new Error(`${DERIVED_CONFIG}: sources[${i}].features.${f} must be a non-empty list of source paths`);
|
|
46
|
+
fm.set(f, paths.map(String));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const keys = new Map();
|
|
50
|
+
const rawKeys = s["keys"];
|
|
51
|
+
if (rawKeys !== undefined && rawKeys !== null) {
|
|
52
|
+
if (typeof rawKeys !== "object" || Array.isArray(rawKeys))
|
|
53
|
+
throw new Error(`${DERIVED_CONFIG}: sources[${i}].keys must map a list's path to its key field (or a list of fields)`);
|
|
54
|
+
for (const [lp, k] of Object.entries(rawKeys)) {
|
|
55
|
+
const fields = (Array.isArray(k) ? k : [k]).map(String).filter((x) => x !== "");
|
|
56
|
+
if (fields.length === 0)
|
|
57
|
+
throw new Error(`${DERIVED_CONFIG}: sources[${i}].keys.${lp} names no key field`);
|
|
58
|
+
keys.set(lp, fields);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const inputs = [];
|
|
62
|
+
const rawInputs = s["inputs"];
|
|
63
|
+
if (rawInputs !== undefined && rawInputs !== null) {
|
|
64
|
+
if (!Array.isArray(rawInputs))
|
|
65
|
+
throw new Error(`${DERIVED_CONFIG}: sources[${i}].inputs must be a list of {id, path, sha256}`);
|
|
66
|
+
for (const [k, raw] of rawInputs.entries()) {
|
|
67
|
+
const inId = String(raw?.["id"] ?? "");
|
|
68
|
+
const inPath = String(raw?.["path"] ?? "");
|
|
69
|
+
const pin = String(raw?.["sha256"] ?? "");
|
|
70
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(inId) || inPath === "" || !/^[0-9a-f]{64}$/.test(pin)) {
|
|
71
|
+
throw new Error(`${DERIVED_CONFIG}: sources[${i}].inputs[${k}] needs an id (lower-case, digits, hyphens), a path, and sha256 (64 hex digits: the input's digest, M-ADR 0232 §4.2)`);
|
|
72
|
+
}
|
|
73
|
+
if (inputs.some((x) => x.id === inId))
|
|
74
|
+
throw new Error(`${DERIVED_CONFIG}: sources[${i}].inputs: '${inId}' appears twice`);
|
|
75
|
+
inputs.push({ id: inId, path: inPath, sha256: pin });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
for (const arg of cmd.map(String)) {
|
|
79
|
+
for (const m of arg.matchAll(/\{input:([^}]*)\}/g)) {
|
|
80
|
+
if (!inputs.some((x) => x.id === m[1]))
|
|
81
|
+
throw new Error(`${DERIVED_CONFIG}: sources[${i}].generator.command names {input:${m[1]}}, which sources[${i}].inputs does not declare`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const rawShared = s["shared"];
|
|
85
|
+
if (rawShared !== undefined && rawShared !== null && !Array.isArray(rawShared))
|
|
86
|
+
throw new Error(`${DERIVED_CONFIG}: sources[${i}].shared must be a list of source paths`);
|
|
87
|
+
out.push({
|
|
88
|
+
id,
|
|
89
|
+
source,
|
|
90
|
+
...(s["schema"] ? { schema: String(s["schema"]) } : {}),
|
|
91
|
+
generator: { command: cmd.map(String), identity: String(g["identity"]), version: String(g["version"]) },
|
|
92
|
+
features: fm,
|
|
93
|
+
keys,
|
|
94
|
+
shared: (rawShared ?? []).map(String),
|
|
95
|
+
inputs,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
const ids = out.map((s) => s.id);
|
|
99
|
+
const dup = ids.find((x, k) => ids.indexOf(x) !== k);
|
|
100
|
+
if (dup !== undefined)
|
|
101
|
+
throw new Error(`${DERIVED_CONFIG}: source id '${dup}' appears twice`);
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
export async function loadLock(repo) {
|
|
105
|
+
const text = await readFile(join(repo, DERIVED_LOCK), "utf8").catch(() => null);
|
|
106
|
+
if (text === null)
|
|
107
|
+
return [];
|
|
108
|
+
const raw = parseYaml(text);
|
|
109
|
+
return Array.isArray(raw?.sources) ? raw.sources : [];
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* The generator's `--version`: one line, `<identity> <version> <release commit>`. Null when the generator cannot be run
|
|
113
|
+
* on this machine (not installed), so the caller falls back to comparing digests and says so.
|
|
114
|
+
*/
|
|
115
|
+
export async function generatorIdentity(repo, g, inputs = []) {
|
|
116
|
+
try {
|
|
117
|
+
const cmd = commandFor(repo, g, inputs);
|
|
118
|
+
const { stdout } = await execFile(cmd[0], [...cmd.slice(1), "--version"], { cwd: repo, timeout: 60_000 });
|
|
119
|
+
const [identity, version, commit] = stdout.trim().split(/\s+/);
|
|
120
|
+
if (!identity || !version || !commit)
|
|
121
|
+
return null;
|
|
122
|
+
return { identity, version, commit };
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/** Where an input lives on this machine: relative to the SDD's root, absolute, or under the home directory. */
|
|
129
|
+
export function inputLocation(repo, path) {
|
|
130
|
+
if (path === "~" || path.startsWith("~/"))
|
|
131
|
+
return join(homedir(), path.slice(1));
|
|
132
|
+
return resolve(repo, path);
|
|
133
|
+
}
|
|
134
|
+
/** The generator's command with each `{input:<id>}` replaced by that input's location. */
|
|
135
|
+
export function commandFor(repo, g, inputs) {
|
|
136
|
+
return g.command.map((a) => a.replace(/\{input:([^}]*)\}/g, (_, id) => {
|
|
137
|
+
const x = inputs.find((i) => i.id === id);
|
|
138
|
+
return x === undefined ? `{input:${id}}` : inputLocation(repo, x.path);
|
|
139
|
+
}));
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* An input's digest, computed by the method. A file: the sha256 of its bytes. A directory: the sha256 of the
|
|
143
|
+
* `sha256sum`-format listing ("<sha256> <relative path>\n") of every regular file under it, sorted bytewise by path,
|
|
144
|
+
* which is `cd <dir> && find . -type f -printf '%P\n' | LC_ALL=C sort | xargs -d '\n' sha256sum | sha256sum`.
|
|
145
|
+
* A symbolic link inside it is refused, so the digest never depends on what a link points at. Null when absent.
|
|
146
|
+
*/
|
|
147
|
+
export async function inputDigest(location) {
|
|
148
|
+
const st = await lstat(location).catch(() => null);
|
|
149
|
+
if (st === null)
|
|
150
|
+
return null;
|
|
151
|
+
if (st.isFile())
|
|
152
|
+
return sha256(await readFile(location));
|
|
153
|
+
if (!st.isDirectory())
|
|
154
|
+
throw new Error(`${location} is neither a file nor a directory`);
|
|
155
|
+
const files = [];
|
|
156
|
+
const walk = async (rel) => {
|
|
157
|
+
for (const e of await readdir(join(location, rel), { withFileTypes: true })) {
|
|
158
|
+
const r = rel === "" ? e.name : `${rel}/${e.name}`;
|
|
159
|
+
if (e.isSymbolicLink())
|
|
160
|
+
throw new Error(`${join(location, r)} is a symbolic link; an input's digest never follows one`);
|
|
161
|
+
if (e.isDirectory())
|
|
162
|
+
await walk(r);
|
|
163
|
+
else if (e.isFile())
|
|
164
|
+
files.push(r);
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
await walk("");
|
|
168
|
+
files.sort((a, b) => Buffer.compare(Buffer.from(a, "utf8"), Buffer.from(b, "utf8")));
|
|
169
|
+
const listing = [];
|
|
170
|
+
for (const f of files)
|
|
171
|
+
listing.push(`${sha256(await readFile(join(location, f)))} ${f}\n`);
|
|
172
|
+
return sha256(listing.join(""));
|
|
173
|
+
}
|
|
174
|
+
/** Each declared input's state here: its location, and its digest (null: not available on this machine). */
|
|
175
|
+
export async function inputStates(repo, inputs) {
|
|
176
|
+
const out = [];
|
|
177
|
+
for (const input of inputs) {
|
|
178
|
+
const location = inputLocation(repo, input.path);
|
|
179
|
+
out.push({ input, location, digest: await inputDigest(location) });
|
|
180
|
+
}
|
|
181
|
+
return out;
|
|
182
|
+
}
|
|
183
|
+
/** Run the generator into a fresh directory; returns feature id -> spec text. Throws if it fails or writes stray files. */
|
|
184
|
+
export async function generate(repo, s, sourcePath) {
|
|
185
|
+
const out = await mkdtemp(join(tmpdir(), "sdd-derived-"));
|
|
186
|
+
try {
|
|
187
|
+
const cmd = commandFor(repo, s.generator, s.inputs);
|
|
188
|
+
await execFile(cmd[0], [...cmd.slice(1), "--source", resolve(repo, sourcePath), "--out", out], {
|
|
189
|
+
cwd: repo,
|
|
190
|
+
timeout: 600_000,
|
|
191
|
+
});
|
|
192
|
+
const specs = new Map();
|
|
193
|
+
for (const e of await readdir(out, { withFileTypes: true })) {
|
|
194
|
+
const file = join(out, e.name, "spec.md");
|
|
195
|
+
if (!e.isDirectory() || !existsSync(file))
|
|
196
|
+
throw new Error(`generator ${s.generator.identity} wrote '${e.name}', which is not <feature>/spec.md`);
|
|
197
|
+
const extra = (await readdir(join(out, e.name))).filter((f) => f !== "spec.md");
|
|
198
|
+
if (extra.length > 0)
|
|
199
|
+
throw new Error(`generator ${s.generator.identity} wrote ${extra.join(", ")} beside ${e.name}/spec.md; it writes nothing else`);
|
|
200
|
+
specs.set(e.name, await readFile(file, "utf8"));
|
|
201
|
+
}
|
|
202
|
+
return specs;
|
|
203
|
+
}
|
|
204
|
+
finally {
|
|
205
|
+
await rm(out, { recursive: true, force: true });
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
export function specBlocks(text) {
|
|
209
|
+
const out = [];
|
|
210
|
+
for (const line of text.split("\n")) {
|
|
211
|
+
const r = /^###\s+Requirement:\s*(.+?)\s*$/.exec(line);
|
|
212
|
+
const sc = /^####\s+Scenario:\s*(.+?)\s*$/.exec(line);
|
|
213
|
+
if (r)
|
|
214
|
+
out.push({ kind: "requirement", name: r[1], sources: [], dataItems: [], via: [] });
|
|
215
|
+
else if (sc)
|
|
216
|
+
out.push({ kind: "scenario", name: sc[1], sources: [], dataItems: [], via: [] });
|
|
217
|
+
else {
|
|
218
|
+
const ov = /^\s*(?:[-*]\s*)?Observed via:\s*(.+?)\s*$/.exec(line);
|
|
219
|
+
if (ov && out.length > 0) {
|
|
220
|
+
out[out.length - 1].via.push(...splitSourceLine(ov[1]));
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
const di = /^\s*(?:[-*]\s*)?Data item:\s*(.+?)\s*$/.exec(line);
|
|
224
|
+
if (di && out.length > 0) {
|
|
225
|
+
out[out.length - 1].dataItems.push(...di[1].split(",").map((x) => x.trim().replace(/^`|`$/g, "")).filter(Boolean));
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
const src = /^\s*(?:[-*]\s*)?Source:\s*(.+?)\s*$/.exec(line);
|
|
229
|
+
if (src && out.length > 0)
|
|
230
|
+
out[out.length - 1].sources.push(...splitSourceLine(src[1]));
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return out;
|
|
234
|
+
}
|
|
235
|
+
/** Resolve a `Source:` path in a source parsed by `parseSource` (the grammar is source-path.ts's). Null when it resolves. */
|
|
236
|
+
export function resolveSourcePath(doc, path, keys = new Map()) {
|
|
237
|
+
return resolvePath(doc, path, { keys });
|
|
238
|
+
}
|
|
239
|
+
/** A derived source parsed for path resolution: typed map keys, so `[200]` and `["200"]` stay distinct. */
|
|
240
|
+
export function parseSource(text) {
|
|
241
|
+
const d = parseDocument(text);
|
|
242
|
+
if (d.errors.length > 0)
|
|
243
|
+
throw new Error(d.errors[0].message);
|
|
244
|
+
return d.toJS({ mapAsMap: true });
|
|
245
|
+
}
|
|
246
|
+
/** The living specs on disk, feature id -> text. */
|
|
247
|
+
export async function livingSpecs(repo) {
|
|
248
|
+
const out = new Map();
|
|
249
|
+
const root = join(repo, SPECS);
|
|
250
|
+
if (!existsSync(root))
|
|
251
|
+
return out;
|
|
252
|
+
for (const e of await readdir(root, { withFileTypes: true })) {
|
|
253
|
+
if (!e.isDirectory())
|
|
254
|
+
continue;
|
|
255
|
+
const t = await readFile(join(root, e.name, "spec.md"), "utf8").catch(() => null);
|
|
256
|
+
if (t !== null)
|
|
257
|
+
out.set(e.name, t);
|
|
258
|
+
}
|
|
259
|
+
return out;
|
|
260
|
+
}
|
|
261
|
+
/** A living spec's or a delta's requirement blocks: name -> the block's text (heading to the next requirement or section). */
|
|
262
|
+
export function requirementBlocks(text) {
|
|
263
|
+
const out = new Map();
|
|
264
|
+
let name = null;
|
|
265
|
+
let buf = [];
|
|
266
|
+
const flush = () => {
|
|
267
|
+
if (name !== null)
|
|
268
|
+
out.set(name, buf.join("\n").replace(/\s+$/, ""));
|
|
269
|
+
};
|
|
270
|
+
for (const line of text.split("\n")) {
|
|
271
|
+
const r = /^###\s+Requirement:\s*(.+?)\s*$/.exec(line);
|
|
272
|
+
if (r || /^##\s/.test(line)) {
|
|
273
|
+
flush();
|
|
274
|
+
name = r ? r[1] : null;
|
|
275
|
+
buf = r ? [line] : [];
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
if (name !== null)
|
|
279
|
+
buf.push(line);
|
|
280
|
+
}
|
|
281
|
+
flush();
|
|
282
|
+
return out;
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* The OpenSpec delta that turns `living` (absent: a new feature) into `derived`, requirement by requirement: ADDED for
|
|
286
|
+
* a new requirement, MODIFIED (stated whole) for a changed one, REMOVED for one the source no longer declares. Null when
|
|
287
|
+
* nothing changes.
|
|
288
|
+
*/
|
|
289
|
+
export function deltaFor(living, derived) {
|
|
290
|
+
const before = living === undefined ? new Map() : requirementBlocks(living);
|
|
291
|
+
const after = derived === undefined ? new Map() : requirementBlocks(derived);
|
|
292
|
+
const added = [];
|
|
293
|
+
const modified = [];
|
|
294
|
+
const removed = [];
|
|
295
|
+
for (const [n, t] of after) {
|
|
296
|
+
const b = before.get(n);
|
|
297
|
+
if (b === undefined)
|
|
298
|
+
added.push(t);
|
|
299
|
+
else if (b !== t)
|
|
300
|
+
modified.push(t);
|
|
301
|
+
}
|
|
302
|
+
for (const n of before.keys()) {
|
|
303
|
+
if (!after.has(n))
|
|
304
|
+
removed.push(`### Requirement: ${n}\n**Reason**: the source no longer declares it\n**Migration**: none`);
|
|
305
|
+
}
|
|
306
|
+
if (added.length + modified.length + removed.length === 0)
|
|
307
|
+
return null;
|
|
308
|
+
const parts = [];
|
|
309
|
+
if (added.length)
|
|
310
|
+
parts.push(`## ADDED Requirements\n\n${added.join("\n\n")}`);
|
|
311
|
+
if (modified.length)
|
|
312
|
+
parts.push(`## MODIFIED Requirements\n\n${modified.join("\n\n")}`);
|
|
313
|
+
if (removed.length)
|
|
314
|
+
parts.push(`## REMOVED Requirements\n\n${removed.join("\n\n")}`);
|
|
315
|
+
return parts.join("\n\n") + "\n";
|
|
316
|
+
}
|
|
317
|
+
/** The derived/ files a change carries: source id -> its path in the change. */
|
|
318
|
+
export async function changeDerivedSources(changeDir) {
|
|
319
|
+
const dir = join(changeDir, "derived");
|
|
320
|
+
const out = new Map();
|
|
321
|
+
if (!existsSync(dir))
|
|
322
|
+
return out;
|
|
323
|
+
for (const e of await readdir(dir, { withFileTypes: true })) {
|
|
324
|
+
if (e.isFile())
|
|
325
|
+
out.set(e.name.replace(/\.[^.]+$/, ""), join(dir, e.name));
|
|
326
|
+
}
|
|
327
|
+
return out;
|
|
328
|
+
}
|
|
329
|
+
/** The lock's text for these entries. */
|
|
330
|
+
export function renderLock(entries) {
|
|
331
|
+
const lines = ["# Derivation record (M-ADR 0232 §4.3), written by sdd-cli; never edit by hand.", 'schema_version: "1.0.0"', "sources:"];
|
|
332
|
+
for (const e of [...entries].sort((a, b) => a.id.localeCompare(b.id))) {
|
|
333
|
+
lines.push(` - id: ${e.id}`, ` source: ${e.source}`, ` source_sha256: ${e.source_sha256}`);
|
|
334
|
+
lines.push(` generator: { identity: ${JSON.stringify(e.generator.identity)}, version: ${JSON.stringify(e.generator.version)}, commit: ${JSON.stringify(e.generator.commit)} }`);
|
|
335
|
+
if (e.inputs && e.inputs.length > 0) {
|
|
336
|
+
lines.push(" inputs:");
|
|
337
|
+
for (const x of [...e.inputs].sort((a, b) => a.id.localeCompare(b.id)))
|
|
338
|
+
lines.push(` - { id: ${x.id}, sha256: ${x.sha256} }`);
|
|
339
|
+
}
|
|
340
|
+
lines.push(" specs:");
|
|
341
|
+
for (const sp of [...e.specs].sort((a, b) => a.feature.localeCompare(b.feature)))
|
|
342
|
+
lines.push(` - { feature: ${sp.feature}, sha256: ${sp.sha256} }`);
|
|
343
|
+
}
|
|
344
|
+
return lines.join("\n") + "\n";
|
|
345
|
+
}
|
|
346
|
+
/** Which derived source owns each feature: the derivation record's specs and the features map both declare it. */
|
|
347
|
+
export function ownership(sources, lock) {
|
|
348
|
+
const out = new Map();
|
|
349
|
+
for (const l of lock)
|
|
350
|
+
for (const sp of l.specs)
|
|
351
|
+
out.set(sp.feature, l.id);
|
|
352
|
+
for (const s of sources)
|
|
353
|
+
for (const f of s.features.keys())
|
|
354
|
+
out.set(f, s.id);
|
|
355
|
+
return out;
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Regenerate each derived source a change carries (M-ADR 0232 §4.5) from its proposed copy and diff it against the
|
|
359
|
+
* living specs. Throws when a source id is undeclared or its pinned generator cannot run here: a derived change's deltas
|
|
360
|
+
* are only ever the regenerated difference, so without the generator there is nothing to write or compare.
|
|
361
|
+
*/
|
|
362
|
+
export async function planDerivedChange(repo, changeDir) {
|
|
363
|
+
const carried = await changeDerivedSources(changeDir);
|
|
364
|
+
if (carried.size === 0)
|
|
365
|
+
return [];
|
|
366
|
+
const sources = (await loadDerivedSources(repo)) ?? [];
|
|
367
|
+
const lock = await loadLock(repo);
|
|
368
|
+
const owners = ownership(sources, lock);
|
|
369
|
+
const living = await livingSpecs(repo);
|
|
370
|
+
const out = [];
|
|
371
|
+
for (const [id, proposed] of carried) {
|
|
372
|
+
const s = sources.find((x) => x.id === id);
|
|
373
|
+
if (s === undefined)
|
|
374
|
+
throw new Error(`derived/${id}: no derived source '${id}' is declared in ${DERIVED_CONFIG}`);
|
|
375
|
+
for (const st of await inputStates(repo, s.inputs)) {
|
|
376
|
+
if (st.digest === null)
|
|
377
|
+
throw new Error(`derived/${id}: input '${st.input.id}' is not available here (${st.location}), so the change cannot be regenerated`);
|
|
378
|
+
if (st.digest !== st.input.sha256)
|
|
379
|
+
throw new Error(`derived/${id}: input '${st.input.id}' at ${st.location} is ${st.digest.slice(0, 12)}, not the pinned ${st.input.sha256.slice(0, 12)}, so the change cannot be regenerated`);
|
|
380
|
+
}
|
|
381
|
+
const ident = await generatorIdentity(repo, s.generator, s.inputs);
|
|
382
|
+
if (ident === null || ident.identity !== s.generator.identity || ident.version !== s.generator.version) {
|
|
383
|
+
throw new Error(`derived/${id}: the pinned generator ${s.generator.identity} ${s.generator.version} is not available here (${ident === null ? "not installed" : `found ${ident.identity} ${ident.version}`}), so the change cannot be regenerated`);
|
|
384
|
+
}
|
|
385
|
+
const specs = await generate(repo, s, proposed);
|
|
386
|
+
for (const [f, t] of specs) {
|
|
387
|
+
const bare = requirementsWithoutScenario(t);
|
|
388
|
+
if (bare.length > 0)
|
|
389
|
+
throw new Error(`derived/${id}: generator ${s.generator.identity} ${s.generator.version} emitted ${f} requirement(s) with no scenario (${bare.map((x) => `'${x}'`).join(", ")}); OpenSpec's strict validation refuses them, so fix the generator`);
|
|
390
|
+
}
|
|
391
|
+
const deltas = new Map();
|
|
392
|
+
const features = new Set([...specs.keys(), ...[...owners].filter(([, o]) => o === id).map(([f]) => f)]);
|
|
393
|
+
for (const f of [...features].sort())
|
|
394
|
+
deltas.set(f, deltaFor(living.get(f), specs.get(f)));
|
|
395
|
+
out.push({ source: s, proposed, specs, deltas, generator: ident });
|
|
396
|
+
}
|
|
397
|
+
return out;
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* The behaviour-store rule for derived features (M-ADR 0232 §4.1, §4.5): a change never authors a delta to a feature
|
|
401
|
+
* a derived source owns; a change carrying a proposed source has exactly the regenerated deltas for that source's
|
|
402
|
+
* features. Returns the problems (empty: none).
|
|
403
|
+
*/
|
|
404
|
+
export async function derivedChangeProblems(repo, change) {
|
|
405
|
+
const problems = [];
|
|
406
|
+
const sources = await loadDerivedSources(repo).catch(() => null);
|
|
407
|
+
if (sources === null || sources.length === 0)
|
|
408
|
+
return problems;
|
|
409
|
+
const changeDir = join(repo, SPECS, "..", "changes", change);
|
|
410
|
+
const owners = ownership(sources, await loadLock(repo));
|
|
411
|
+
const carried = await changeDerivedSources(changeDir);
|
|
412
|
+
const deltaDir = join(changeDir, "specs");
|
|
413
|
+
const authored = existsSync(deltaDir) ? (await readdir(deltaDir, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name) : [];
|
|
414
|
+
for (const f of authored) {
|
|
415
|
+
const o = owners.get(f);
|
|
416
|
+
if (o !== undefined && !carried.has(o)) {
|
|
417
|
+
problems.push(`${change}: authors a delta to '${f}', which derived source '${o}' owns; change the source instead (derived/${o}.<ext>) and let \`sdd-cli change derive\` write the deltas (M-ADR 0232 §4.1)`);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
if (carried.size === 0)
|
|
421
|
+
return problems;
|
|
422
|
+
let plans;
|
|
423
|
+
try {
|
|
424
|
+
plans = await planDerivedChange(repo, changeDir);
|
|
425
|
+
}
|
|
426
|
+
catch (e) {
|
|
427
|
+
problems.push(`${change}: ${e.message}`);
|
|
428
|
+
return problems;
|
|
429
|
+
}
|
|
430
|
+
for (const p of plans) {
|
|
431
|
+
for (const [f, want] of p.deltas) {
|
|
432
|
+
const have = await readFile(join(deltaDir, f, "spec.md"), "utf8").catch(() => null);
|
|
433
|
+
if ((have?.trim() ?? null) !== (want?.trim() ?? null)) {
|
|
434
|
+
problems.push(`${change}: the delta for '${f}' is not the difference regenerated from derived/${p.source.id} (${want === null ? "it should have none" : have === null ? "it is missing" : "it differs"}); run \`sdd-cli change derive ${change}\` (M-ADR 0232 §4.5)`);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
return problems;
|
|
439
|
+
}
|
|
440
|
+
/** Write a derived change's deltas: the regenerated difference, and nothing authored. Returns what it wrote and removed. */
|
|
441
|
+
export async function writeDerivedDeltas(repo, change) {
|
|
442
|
+
const changeDir = join(repo, SPECS, "..", "changes", change);
|
|
443
|
+
if (!existsSync(changeDir))
|
|
444
|
+
throw new Error(`no open change '${change}'`);
|
|
445
|
+
const plans = await planDerivedChange(repo, changeDir);
|
|
446
|
+
if (plans.length === 0)
|
|
447
|
+
throw new Error(`change '${change}' carries no proposed source (openspec/changes/${change}/derived/<source-id>.<ext>)`);
|
|
448
|
+
const written = [];
|
|
449
|
+
const removed = [];
|
|
450
|
+
for (const p of plans) {
|
|
451
|
+
for (const [f, delta] of p.deltas) {
|
|
452
|
+
const dir = join(changeDir, "specs", f);
|
|
453
|
+
if (delta === null) {
|
|
454
|
+
if (existsSync(dir)) {
|
|
455
|
+
await rm(dir, { recursive: true, force: true });
|
|
456
|
+
removed.push(f);
|
|
457
|
+
}
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
await mkdir(dir, { recursive: true });
|
|
461
|
+
await writeFile(join(dir, "spec.md"), delta, "utf8");
|
|
462
|
+
written.push(f);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
return { written, removed };
|
|
466
|
+
}
|
|
467
|
+
/** Two specs state the same requirements, whatever their order and trailing whitespace. */
|
|
468
|
+
export function sameRequirements(a, b) {
|
|
469
|
+
const norm = (t) => {
|
|
470
|
+
const m = requirementBlocks(t);
|
|
471
|
+
return new Map([...m].map(([k, v]) => [k, v.split("\n").map((l) => l.trimEnd()).join("\n").replace(/\n{2,}/g, "\n\n")]));
|
|
472
|
+
};
|
|
473
|
+
const x = norm(a);
|
|
474
|
+
const y = norm(b);
|
|
475
|
+
return x.size === y.size && [...x].every(([k, v]) => y.get(k) === v);
|
|
476
|
+
}
|
|
477
|
+
/** Requirements with no scenario: OpenSpec's strict validation refuses them, so a derivation that emits one is refused early. */
|
|
478
|
+
export function requirementsWithoutScenario(text) {
|
|
479
|
+
const out = [];
|
|
480
|
+
const blocks = specBlocks(text);
|
|
481
|
+
blocks.forEach((b, k) => {
|
|
482
|
+
if (b.kind === "requirement" && blocks[k + 1]?.kind !== "scenario")
|
|
483
|
+
out.push(b.name);
|
|
484
|
+
});
|
|
485
|
+
return out;
|
|
486
|
+
}
|
|
487
|
+
/** Open changes carrying a proposed copy of this source (derived/<id>.<ext>): a first derivation under review. */
|
|
488
|
+
export async function openDerivations(repo, sourceId) {
|
|
489
|
+
const dir = join(repo, SPECS, "..", "changes");
|
|
490
|
+
if (!existsSync(dir))
|
|
491
|
+
return [];
|
|
492
|
+
const out = [];
|
|
493
|
+
for (const e of await readdir(dir, { withFileTypes: true })) {
|
|
494
|
+
if (!e.isDirectory() || e.name === "archive")
|
|
495
|
+
continue;
|
|
496
|
+
if ((await changeDerivedSources(join(dir, e.name))).has(sourceId))
|
|
497
|
+
out.push(e.name);
|
|
498
|
+
}
|
|
499
|
+
return out.sort();
|
|
500
|
+
}
|
|
501
|
+
//# sourceMappingURL=index.js.map
|