@sdd-method/author-cli 0.10.0 → 0.12.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.
Files changed (38) hide show
  1. package/dist/lib/behaviour-store/archive.js +13 -0
  2. package/dist/lib/catalogue/build.js +45 -2
  3. package/dist/lib/catalogue/builders/adr-governs.js +39 -0
  4. package/dist/lib/catalogue/builders/adrs.js +11 -0
  5. package/dist/lib/catalogue/builders/capability-spec.js +1 -0
  6. package/dist/lib/catalogue/builders/contract-revisions.js +126 -0
  7. package/dist/lib/catalogue/builders/data-model.js +316 -0
  8. package/dist/lib/catalogue/builders/entities-mermaid.js +18 -85
  9. package/dist/lib/catalogue/builders/feature-grounding.js +1 -0
  10. package/dist/lib/catalogue/builders/features.js +23 -2
  11. package/dist/lib/catalogue/builders/integration-components.js +49 -5
  12. package/dist/lib/catalogue/builders/journey-requirement.js +144 -0
  13. package/dist/lib/catalogue/builders/quality-scores.js +17 -0
  14. package/dist/lib/catalogue/builders/requirements.js +168 -32
  15. package/dist/lib/catalogue/canonical-schema.yaml +845 -28
  16. package/dist/lib/catalogue/manifest-loader.js +13 -0
  17. package/dist/lib/catalogue/retired-catalogues.js +25 -0
  18. package/dist/lib/catalogue/rubric-coverage-map.yaml +108 -2
  19. package/dist/lib/catalogue/schema-compat.js +1 -1
  20. package/dist/lib/catalogue/schema-loader.js +3 -0
  21. package/dist/lib/catalogue/staleness-verdicts.js +75 -6
  22. package/dist/lib/intents/migrate.js +35 -9
  23. package/dist/lib/metamodel/tql-subset-parser.js +35 -2
  24. package/dist/lib/structure/catalogue.js +183 -0
  25. package/dist/lib/structure/compose.js +174 -0
  26. package/dist/lib/structure/effects.js +36 -0
  27. package/dist/lib/structure/model.js +212 -0
  28. package/dist/lib/structure/render.js +242 -0
  29. package/dist/lib/structure/view.js +301 -0
  30. package/dist/lib/validate/agreement-evidence.js +216 -0
  31. package/dist/lib/validate/behaviour-store.js +516 -28
  32. package/dist/lib/validate/dispatch.js +21 -0
  33. package/dist/lib/validate/feature-spec-pointers.js +175 -0
  34. package/dist/lib/validate/journey-impact.js +193 -0
  35. package/dist/lib/validate/structural-model.js +265 -0
  36. package/dist/maintain-version.json +2 -2
  37. package/dist/verify-version.json +2 -2
  38. package/package.json +2 -2
@@ -7,18 +7,28 @@
7
7
  // requirements one row per `### Requirement:` in a living spec (stage living), keyed by the spec's
8
8
  // directory name, which is the catalogue feature id; plus one per requirement an open,
9
9
  // agreed change ADDS (stage agreed).
10
- // requirement_lineage each archived change, in archive order, whose delta ADDED, MODIFIED or RENAMED a
11
- // requirement that is current now, under any name a later RENAMED gave it. A change carrying
10
+ // requirement_lineage each archived change, in archive order, whose delta ADDED, MODIFIED, RENAMED or REMOVED a
11
+ // requirement, under any name a later RENAMED gave it. A change carrying
12
12
  // baseline-evidence.md is a baseline. Statuses follow renames the same way.
13
+ // A requirement REMOVED by an archived change and not in a living spec now is kept in
14
+ // requirements as stage retired (schema 4.3.0), so its lineage ends in the REMOVED row;
15
+ // it carries no status.
13
16
  // changes each change in the store, open or archived (M-ADR 0223 §4.2): its directory name, its
14
17
  // undated name, kind, status (archived, or its agreement record's) and verdict.
15
18
  // change_work_order per (change, repository): the record's work order, agreed or completed.
16
19
  // change_work_item per (change, work item): the ids its proposal's `## Work Items` section lists, keyed by
17
20
  // the undated name so the row survives archive.
18
- // requirement_status per (requirement, repository): observed from a baseline's `**Observed in:**` line;
21
+ // requirement_revisions one row per distinct text of a requirement (schema 6.0.0, M-ADR 0230 §4.7, Proposed): the
22
+ // living spec's is current, an agreed open change's ADDED or MODIFIED block intended, an
23
+ // archived change's block the living spec no longer holds history. Identified by its digest.
24
+ // requirement_assertions per (revision, repository): observed from a baseline's `**Observed in:**` line;
19
25
  // declared-shipped from an archived change whose agreement record's verdict is PASS, through
20
- // its work orders' `implements` lists; agreed from an open change whose record is agreed.
21
- // The latest event wins, archived changes in archive order, then open changes.
26
+ // its work orders' `implements` lists; each about the revision that change's delta states, so
27
+ // evidence about one revision is never read as evidence about another. The latest wins, in
28
+ // archive order. An agreed change is not an assertion: it is an intended revision.
29
+ // change_effects per (change, element): each requirement a change's delta writes, from its base revision to
30
+ // its target, open and archived changes alike; and each structural element its structural
31
+ // deltas write (an archived change's from its effect record, structure/applied.yaml).
22
32
  //
23
33
  // Archive order is ../../behaviour-store/archive.ts's; same-day archives the history cannot order are a finding.
24
34
  //
@@ -26,8 +36,25 @@
26
36
  import { readdir, readFile, stat } from "node:fs/promises";
27
37
  import { join } from "node:path";
28
38
  import { parse as parseYaml } from "yaml";
29
- import { observedIn, orderArchives, parseRequirements, renamePairs } from "../../behaviour-store/archive.js";
30
- export { observedIn, parseRequirements };
39
+ import { createHash } from "node:crypto";
40
+ import { observedIn, orderArchives, parseRequirements, renamePairs, resolveImplements } from "../../behaviour-store/archive.js";
41
+ import { effectsOf, parseAppliedRecord } from "../../structure/effects.js";
42
+ import { APPLIED_FILE, loadChangeStructure, loadModel } from "../../structure/model.js";
43
+ import { viewOf } from "../../structure/view.js";
44
+ import { requirementBlock } from "../../validate/agreement-evidence.js";
45
+ export { observedIn, parseRequirements, resolveImplements };
46
+ /** `sha256:` of a requirement block, its lines trimmed and blank lines dropped (M-ADR 0230 §4.7). */
47
+ export function requirementDigest(spec, name) {
48
+ const block = requirementBlock(spec, name);
49
+ if (block === null)
50
+ return undefined;
51
+ const normal = block.split("\n").map((l) => l.trim()).filter((l) => l !== "").join("\n");
52
+ return `sha256:${createHash("sha256").update(normal, "utf8").digest("hex")}`;
53
+ }
54
+ /** A revision's id: <requirement_id>@<the digest's first 12 hex digits>. */
55
+ export function revisionId(requirementId, digest) {
56
+ return `${requirementId}@${digest.slice("sha256:".length, "sha256:".length + 12)}`;
57
+ }
31
58
  const STORE = "openspec";
32
59
  const AGREEMENTS = join("docs", "agreements");
33
60
  async function isDir(p) {
@@ -62,8 +89,10 @@ async function deltaOf(changeDir) {
62
89
  if (text === null)
63
90
  continue;
64
91
  for (const r of parseRequirements(text)) {
65
- if (r.operation)
66
- out.push({ feature, name: r.name, operation: r.operation, file, scenarios: r.scenarios });
92
+ if (!r.operation)
93
+ continue;
94
+ const digest = r.operation === "ADDED" || r.operation === "MODIFIED" ? requirementDigest(text, r.name) : undefined;
95
+ out.push({ feature, name: r.name, operation: r.operation, file, scenarios: r.scenarios, ...(digest ? { digest } : {}) });
67
96
  }
68
97
  }
69
98
  return out;
@@ -123,13 +152,24 @@ export function parseWorkItems(proposal) {
123
152
  return ids;
124
153
  }
125
154
  const rel = (sddPath, p) => p.slice(sddPath.length + 1).split("\\").join("/");
155
+ /** One effect per (change, element): a change that renames and modifies a requirement records MODIFIED, as its lineage does. */
156
+ function dedupeEffects(rows) {
157
+ const byId = new Map();
158
+ for (const r of rows) {
159
+ const prior = byId.get(String(r["id"]));
160
+ byId.set(String(r["id"]), prior && r["operation"] === "RENAMED" ? prior : r);
161
+ }
162
+ return [...byId.values()];
163
+ }
126
164
  export async function buildRequirements(inputs) {
127
165
  const { sddPath, findings } = inputs;
128
- const empty = { requirements: [], lineage: [], status: [], changes: [], workOrders: [], workItems: [] };
166
+ const scoped = (change) => `${inputs.sddId}::${change}`;
167
+ const empty = { requirements: [], lineage: [], revisions: [], assertions: [], effects: [], changes: [], workOrders: [], workItems: [] };
129
168
  const store = join(sddPath, STORE);
130
169
  if (!(await isDir(store)))
131
170
  return empty;
132
171
  const requirements = new Map();
172
+ const livingDigest = new Map();
133
173
  const unknownFeatures = new Set();
134
174
  const known = (feature) => {
135
175
  if (inputs.knownFeatureIds.has(feature))
@@ -150,17 +190,35 @@ export async function buildRequirements(inputs) {
150
190
  continue;
151
191
  }
152
192
  requirements.set(id, { id, feature_id: feature, name: r.name, stage: "living", spec_path: rel(sddPath, file), scenario_count: r.scenarios, source_sdd_id: "", platform_sdd_id: "" });
193
+ const digest = requirementDigest(text, r.name);
194
+ if (digest)
195
+ livingDigest.set(id, digest);
153
196
  }
154
197
  }
155
198
  const events = [];
199
+ const revisions = new Map();
200
+ const requirementEffects = [];
201
+ const structureEffects = [];
202
+ const effectRow = (changeId, kind, element, operation, base, target) => ({
203
+ id: `${changeId}__${kind}__${element}`,
204
+ change_id: changeId,
205
+ element_kind: kind,
206
+ element_id: element,
207
+ operation,
208
+ base_revision: base,
209
+ target_revision: target,
210
+ source_sdd_id: "",
211
+ platform_sdd_id: "",
212
+ });
156
213
  const lineage = [];
157
214
  const unknownRepos = new Set();
158
215
  const changes = [];
159
216
  const workOrders = [];
160
217
  const workItems = [];
161
218
  // The change itself, its work orders and its work items (M-ADR 0223 §4.2).
162
- const recordChange = async (id, name, kind, status, archivedOn, changeDir, record) => {
219
+ const recordChange = async (localId, name, kind, status, archivedOn, changeDir, record) => {
163
220
  const verdict = record?.verdict?.result === "PASS" || record?.verdict?.result === "FAIL" ? record.verdict.result : "";
221
+ const id = scoped(localId);
164
222
  changes.push({ id, name, change_kind: kind, status, archived_on: archivedOn, verdict, source_sdd_id: "", platform_sdd_id: "" });
165
223
  for (const wo of record?.work_orders ?? []) {
166
224
  if (!wo.repo)
@@ -174,7 +232,7 @@ export async function buildRequirements(inputs) {
174
232
  }
175
233
  const proposal = await readText(join(changeDir, "proposal.md"));
176
234
  for (const item of proposal === null ? [] : parseWorkItems(proposal)) {
177
- workItems.push({ id: `${name}__${item}`, change_id: id, change_name: name, work_item_id: item, source_sdd_id: "", platform_sdd_id: "" });
235
+ workItems.push({ id: `${scoped(name)}__${item}`, change_id: id, change_name: name, work_item_id: item, source_sdd_id: "", platform_sdd_id: "" });
178
236
  }
179
237
  };
180
238
  const pushEvent = (e) => {
@@ -182,7 +240,8 @@ export async function buildRequirements(inputs) {
182
240
  unknownRepos.add(e.repo);
183
241
  return;
184
242
  }
185
- if (requirements.has(e.requirementId))
243
+ // A retired requirement has no status: it is not where any repository's behaviour is.
244
+ if (requirements.has(e.requirementId) && requirements.get(e.requirementId)["stage"] !== "retired")
186
245
  events.push(e);
187
246
  };
188
247
  // 2. Archived changes, in archive order. A requirement is named as it was when each change archived, so its
@@ -201,10 +260,20 @@ export async function buildRequirements(inputs) {
201
260
  await recordChange(dir, change, kind, "archived", archivedOn, changeDir, await agreementRecord(sddPath, change));
202
261
  const delta = await deltaOf(changeDir);
203
262
  for (const d of delta) {
204
- if (d.operation === "REMOVED")
205
- continue;
206
- archived.push({ index, feature: d.feature, name: d.name, lineage: { change_id: dir, change_kind: kind, operation: d.operation, archived_on: archivedOn, source_sdd_id: "", platform_sdd_id: "" } });
263
+ archived.push({
264
+ index,
265
+ feature: d.feature,
266
+ name: d.name,
267
+ lineage: { change_id: scoped(dir), change_kind: kind, operation: d.operation, archived_on: archivedOn, source_sdd_id: "", platform_sdd_id: "" },
268
+ ...(d.operation === "REMOVED" ? { removedIn: rel(sddPath, d.file) } : {}),
269
+ });
270
+ archived.push({ index, feature: d.feature, name: d.name, effect: { change: scoped(dir), operation: d.operation, ...(d.digest ? { digest: d.digest } : {}) } });
207
271
  }
272
+ // Structural effects, from the effect record the method's archive wrote (M-ADR 0230 §4.6).
273
+ const applied = await readText(join(changeDir, "structure", APPLIED_FILE));
274
+ const rec = applied === null ? null : parseAppliedRecord(applied);
275
+ for (const e of rec?.effects ?? [])
276
+ structureEffects.push(effectRow(scoped(dir), "structure", e.element, e.operation, e.base ?? "", e.target ?? ""));
208
277
  if (evidence !== null) {
209
278
  const repos = observedIn(evidence);
210
279
  if (repos.length === 0) {
@@ -212,8 +281,8 @@ export async function buildRequirements(inputs) {
212
281
  }
213
282
  for (const repo of repos)
214
283
  for (const d of delta)
215
- if (d.operation !== "REMOVED")
216
- archived.push({ index, feature: d.feature, name: d.name, event: { repo, status: "observed", basis: dir } });
284
+ if (d.digest)
285
+ archived.push({ index, feature: d.feature, name: d.name, event: { repo, status: "observed", basis: scoped(dir), digest: d.digest } });
217
286
  continue;
218
287
  }
219
288
  const record = await agreementRecord(sddPath, change);
@@ -223,12 +292,17 @@ export async function buildRequirements(inputs) {
223
292
  if (!wo.repo)
224
293
  continue;
225
294
  for (const name of wo.implements ?? []) {
226
- const match = delta.filter((d) => d.name === name && d.operation !== "REMOVED");
227
- if (match.length === 0) {
295
+ const { matches, ambiguous } = resolveImplements(delta.filter((d) => d.operation !== "REMOVED"), name);
296
+ const match = matches;
297
+ if (ambiguous.length > 0) {
298
+ findings.add("divergence", `${AGREEMENTS}/${change}.yaml: work order for ${wo.repo} implements "${name}", a name ${ambiguous.length} features in the change share (${ambiguous.join(", ")}); qualify it as <feature>::<name>. No status is derived from it`);
299
+ }
300
+ else if (match.length === 0) {
228
301
  findings.add("convention", `${AGREEMENTS}/${change}.yaml: work order for ${wo.repo} implements "${name}", which the change's delta does not name`);
229
302
  }
230
303
  for (const d of match)
231
- archived.push({ index, feature: d.feature, name: d.name, event: { repo: wo.repo, status: "declared-shipped", basis: dir } });
304
+ if (d.digest)
305
+ archived.push({ index, feature: d.feature, name: d.name, event: { repo: wo.repo, status: "declared-shipped", basis: scoped(dir), digest: d.digest } });
232
306
  }
233
307
  }
234
308
  }
@@ -240,11 +314,37 @@ export async function buildRequirements(inputs) {
240
314
  n = r.to;
241
315
  return `${feature}::${n}`;
242
316
  };
317
+ // A requirement removed and not living now is retired: kept, so its lineage can end in the removal.
318
+ for (const a of archived) {
319
+ if (!a.removedIn)
320
+ continue;
321
+ const id = current(a.feature, a.name, a.index);
322
+ if (requirements.has(id) || !known(a.feature))
323
+ continue;
324
+ requirements.set(id, { id, feature_id: a.feature, name: id.slice(a.feature.length + 2), stage: "retired", spec_path: a.removedIn, scenario_count: 0, source_sdd_id: "", platform_sdd_id: "" });
325
+ }
243
326
  // One lineage row per (requirement, change): a change that renames and modifies a requirement records MODIFIED,
244
327
  // the behaviour change, and its delta still shows the rename.
245
328
  const lineageById = new Map();
329
+ // Revisions in archive order: each ADDED or MODIFIED block is a revision its change introduced; the last one of a
330
+ // requirement is the base of the next change's effect on it.
331
+ const latest = new Map();
246
332
  for (const a of archived) {
247
333
  const requirementId = current(a.feature, a.name, a.index);
334
+ if (a.effect) {
335
+ if (!requirements.has(requirementId))
336
+ continue;
337
+ const base = latest.get(requirementId);
338
+ const target = a.effect.digest ? revisionId(requirementId, a.effect.digest) : undefined;
339
+ if (a.effect.digest && target) {
340
+ revisions.set(target, { id: target, requirement_id: requirementId, digest: a.effect.digest, state: "history", change_id: a.effect.change, source_sdd_id: "", platform_sdd_id: "" });
341
+ latest.set(requirementId, target);
342
+ }
343
+ if (a.effect.operation === "REMOVED")
344
+ latest.delete(requirementId);
345
+ requirementEffects.push(effectRow(a.effect.change, "requirement", requirementId, a.effect.operation, base ?? "", a.effect.operation === "RENAMED" ? base ?? "" : target ?? ""));
346
+ continue;
347
+ }
248
348
  if (a.event)
249
349
  pushEvent({ requirementId, ...a.event });
250
350
  else if (a.lineage && requirements.has(requirementId)) {
@@ -255,6 +355,12 @@ export async function buildRequirements(inputs) {
255
355
  }
256
356
  }
257
357
  lineage.push(...lineageById.values());
358
+ // The living text is the current revision, whichever change introduced it (none, for an edit no change made).
359
+ for (const [id, digest] of livingDigest) {
360
+ const rid = revisionId(id, digest);
361
+ const prior = revisions.get(rid);
362
+ revisions.set(rid, { id: rid, requirement_id: id, digest, state: "current", change_id: prior?.["change_id"] ?? "", source_sdd_id: "", platform_sdd_id: "" });
363
+ }
258
364
  // 3. Open changes whose agreement record is agreed.
259
365
  for (const dir of (await dirs(join(store, "changes"))).filter((d) => d !== "archive")) {
260
366
  const record = await agreementRecord(sddPath, dir);
@@ -265,40 +371,70 @@ export async function buildRequirements(inputs) {
265
371
  openStatus = "proposed";
266
372
  }
267
373
  await recordChange(dir, dir, openKind, openStatus, "", join(store, "changes", dir), record);
268
- if (record?.status !== "agreed")
269
- continue;
270
374
  const delta = await deltaOf(join(store, "changes", dir));
375
+ const agreed = record?.status === "agreed";
271
376
  for (const d of delta) {
272
377
  const id = `${d.feature}::${d.name}`;
273
- if (d.operation === "ADDED" && !requirements.has(id) && known(d.feature)) {
378
+ if (agreed && d.operation === "ADDED" && !requirements.has(id) && known(d.feature)) {
274
379
  requirements.set(id, { id, feature_id: d.feature, name: d.name, stage: "agreed", spec_path: rel(sddPath, d.file), scenario_count: d.scenarios, source_sdd_id: "", platform_sdd_id: "" });
275
380
  }
381
+ // An effect for every element the change writes: a requirement a change not yet agreed ADDs has no
382
+ // requirements row yet, and still has its effect (change_effects.element_id names no foreign key).
383
+ if (!requirements.has(id) && !(d.operation === "ADDED" && known(d.feature)))
384
+ continue;
385
+ const living = livingDigest.get(id);
386
+ const base = living ? revisionId(id, living) : "";
387
+ const target = d.digest ? revisionId(id, d.digest) : "";
388
+ // An agreed change's text is an intended revision; one not yet agreed has an effect and no revision row.
389
+ if (agreed && d.digest && !revisions.has(target)) {
390
+ revisions.set(target, { id: target, requirement_id: id, digest: d.digest, state: "intended", change_id: scoped(dir), source_sdd_id: "", platform_sdd_id: "" });
391
+ }
392
+ requirementEffects.push(effectRow(scoped(dir), "requirement", id, d.operation, d.operation === "ADDED" ? "" : base, d.operation === "REMOVED" ? "" : d.operation === "RENAMED" ? base : target));
276
393
  }
394
+ if (!agreed)
395
+ continue;
277
396
  for (const wo of record.work_orders ?? []) {
278
397
  if (!wo.repo)
279
398
  continue;
280
399
  for (const name of wo.implements ?? []) {
281
- for (const d of delta.filter((x) => x.name === name && x.operation !== "REMOVED")) {
282
- pushEvent({ requirementId: `${d.feature}::${d.name}`, repo: wo.repo, status: "agreed", basis: dir });
400
+ const { ambiguous } = resolveImplements(delta.filter((x) => x.operation !== "REMOVED"), name);
401
+ if (ambiguous.length > 0) {
402
+ findings.add("divergence", `${AGREEMENTS}/${dir}.yaml: work order for ${wo.repo} implements "${name}", a name ${ambiguous.length} features in the change share (${ambiguous.join(", ")}); qualify it as <feature>::<name>`);
283
403
  }
284
404
  }
285
405
  }
286
406
  }
407
+ // Open changes' structural effects, against the model as it stands (M-ADR 0230 §4.4, §4.7).
408
+ {
409
+ const problems = [];
410
+ const files = await loadModel(sddPath, problems);
411
+ const view = viewOf(files ?? [], problems);
412
+ for (const dir of (await dirs(join(store, "changes"))).filter((d) => d !== "archive")) {
413
+ const s = await loadChangeStructure(sddPath, dir, problems);
414
+ for (const e of effectsOf(s, view))
415
+ structureEffects.push(effectRow(scoped(dir), "structure", e.element, e.operation, e.base ?? "", e.target ?? ""));
416
+ }
417
+ }
287
418
  for (const f of unknownFeatures)
288
419
  findings.add("convention", `${STORE}/: a spec keyed "${f}" names no catalogue feature, so its requirements are not catalogued (M-ADR 0221 §4.7)`);
289
420
  for (const r of unknownRepos)
290
- findings.add("convention", `${STORE}/: a status names repository "${r}", which the catalogue does not hold; no status row`);
291
- // Latest event per (requirement, repository) wins.
292
- const status = new Map();
421
+ findings.add("convention", `${STORE}/: an assertion names repository "${r}", which the catalogue does not hold; no assertion row`);
422
+ // Latest assertion per (revision, repository) wins.
423
+ const assertions = new Map();
293
424
  for (const e of events) {
294
- const id = `${e.requirementId}__${e.repo}`;
295
- status.set(id, { id, requirement_id: e.requirementId, repo_id: e.repo, status: e.status, basis: e.basis, source_sdd_id: "", platform_sdd_id: "" });
425
+ const rid = revisionId(e.requirementId, e.digest);
426
+ if (!revisions.has(rid))
427
+ continue;
428
+ const id = `${rid}__${e.repo}`;
429
+ assertions.set(id, { id, revision_id: rid, requirement_id: e.requirementId, repo_id: e.repo, kind: e.status, basis: e.basis, source_sdd_id: "", platform_sdd_id: "" });
296
430
  }
297
431
  const byId = (a, b) => String(a["id"]).localeCompare(String(b["id"]));
298
432
  return {
299
433
  requirements: [...requirements.values()].sort(byId),
300
434
  lineage,
301
- status: [...status.values()].sort(byId),
435
+ revisions: [...revisions.values()].sort(byId),
436
+ assertions: [...assertions.values()].sort(byId),
437
+ effects: [...dedupeEffects(requirementEffects), ...structureEffects].sort(byId),
302
438
  changes: changes.sort(byId),
303
439
  workOrders: workOrders.sort(byId),
304
440
  workItems: workItems.sort(byId),