@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.
Files changed (33) hide show
  1. package/README.md +1 -1
  2. package/dist/author-program.js +10 -2
  3. package/dist/lib/catalogue/build.js +58 -12
  4. package/dist/lib/catalogue/builders/adr-governs.js +1 -1
  5. package/dist/lib/catalogue/builders/adrs.js +2 -2
  6. package/dist/lib/catalogue/builders/contract-revisions.js +45 -7
  7. package/dist/lib/catalogue/builders/data-model.js +1 -1
  8. package/dist/lib/catalogue/builders/entity-archetype.js +121 -0
  9. package/dist/lib/catalogue/builders/quality-scores.js +1 -1
  10. package/dist/lib/catalogue/builders/requirements.js +1 -1
  11. package/dist/lib/catalogue/canonical-schema.yaml +41 -21
  12. package/dist/lib/catalogue/retired-catalogues.js +1 -1
  13. package/dist/lib/catalogue/rubric-coverage-map.yaml +7 -3
  14. package/dist/lib/catalogue/staleness-verdicts.js +3 -3
  15. package/dist/lib/structure/apply.js +71 -0
  16. package/dist/lib/structure/archive.js +330 -0
  17. package/dist/lib/structure/catalogue.js +31 -10
  18. package/dist/lib/structure/compose.js +1 -1
  19. package/dist/lib/structure/effects.js +1 -1
  20. package/dist/lib/structure/extract.js +123 -0
  21. package/dist/lib/structure/intended.js +199 -0
  22. package/dist/lib/structure/model.js +1 -1
  23. package/dist/lib/structure/render.js +1 -1
  24. package/dist/lib/structure/view.js +8 -3
  25. package/dist/lib/validate/agreement-evidence.js +2 -2
  26. package/dist/lib/validate/behaviour-store.js +49 -12
  27. package/dist/lib/validate/dispatch.js +1 -1
  28. package/dist/lib/validate/structural-model.js +48 -35
  29. package/dist/maintain-version.json +2 -2
  30. package/dist/verbs/change.js +15 -0
  31. package/dist/verbs/migrate-structure.js +36 -0
  32. package/dist/verify-version.json +2 -2
  33. package/package.json +2 -2
@@ -0,0 +1,199 @@
1
+ // What an open change adds, for the references its own content makes (M-ADR 0230 §4.7, §4.11 proof 3).
2
+ // A reference made by an open change resolves against the current view plus that change's additions: the elements its
3
+ // structural deltas ADD, those of the changes in its depends_on closure, and the contracts its agreement record agrees
4
+ // as new files. A reference made by anything else resolves against the current view only, so a current reference that
5
+ // resolves only against an addition is still refused.
6
+ // What ties content to a change is never the reference being resolved:
7
+ // a model element the change's own delta (validateDeltas, viewAt)
8
+ // a source-built row with a change_id that change
9
+ // a contract row the change whose record agrees its file as new
10
+ // a row naming such a contract that change
11
+ // a row built from the change's files that change (its spec_path lies in openspec/changes/<change>/, as a
12
+ // requirement the change ADDS does)
13
+ // Pure but for reading agreement records.
14
+ import { readdir, readFile } from "node:fs/promises";
15
+ import { join } from "node:path";
16
+ import { parseDocument } from "yaml";
17
+ import { closure } from "./compose.js";
18
+ import { elementKey, loadChangeStructure } from "./model.js";
19
+ export const CONTRACTS = "contracts";
20
+ const LIFECYCLES = ["proposed", "agreed", "withdrawn", "superseded"];
21
+ /** The change's agreement record (docs/agreements/<change>.yaml): its status, or `unrecorded`, and its new contract files. */
22
+ export async function readAgreement(repo, change) {
23
+ let text;
24
+ try {
25
+ text = await readFile(join(repo, "docs", "agreements", `${change}.yaml`), "utf8");
26
+ }
27
+ catch {
28
+ return { status: "unrecorded", newContracts: [] };
29
+ }
30
+ let record;
31
+ try {
32
+ record = parseDocument(text).toJS()?.agreement_record;
33
+ }
34
+ catch {
35
+ return { status: "unrecorded", newContracts: [] };
36
+ }
37
+ const status = LIFECYCLES.find((s) => s === String(record?.["status"] ?? "")) ?? "unrecorded";
38
+ const newContracts = [];
39
+ const entries = Array.isArray(record?.["contracts"]) ? record["contracts"] : [];
40
+ for (const raw of entries) {
41
+ if (raw === null || typeof raw !== "object")
42
+ continue;
43
+ const e = raw;
44
+ if (e["amended_by_this_change"] !== true)
45
+ continue;
46
+ const files = Array.isArray(e["files"]) ? e["files"].map(String) : e["file"] !== undefined ? [String(e["file"])] : [];
47
+ // agreement-record schema 1.2.0 and later: a file the change relies on carries relies_on, so an agreed file
48
+ // without one is new (1.3.0 states it). An entry with no agrees predates the digests and says nothing either way.
49
+ const digest = (field, f) => {
50
+ const v = e[field];
51
+ return v !== null && typeof v === "object" ? String(v[f] ?? "") : String(v ?? "");
52
+ };
53
+ for (const f of files)
54
+ if (digest("agrees", f) !== "" && digest("relies_on", f) === "")
55
+ newContracts.push(`${String(e["repo"] ?? "")}/${f}`);
56
+ }
57
+ return { status, newContracts };
58
+ }
59
+ /** The open changes (openspec/changes/<change>, not archive/), with their agreement records. */
60
+ export async function loadOpenChanges(repo, problems) {
61
+ let names;
62
+ try {
63
+ names = (await readdir(join(repo, "openspec", "changes"), { withFileTypes: true }))
64
+ .filter((e) => e.isDirectory() && e.name !== "archive")
65
+ .map((e) => e.name)
66
+ .sort();
67
+ }
68
+ catch {
69
+ names = [];
70
+ }
71
+ const open = new Map();
72
+ const newContracts = new Map();
73
+ for (const name of names) {
74
+ const agreement = await readAgreement(repo, name);
75
+ open.set(name, { structure: await loadChangeStructure(repo, name, problems), status: agreement.status });
76
+ newContracts.set(name, agreement.newContracts);
77
+ }
78
+ return { open, newContracts };
79
+ }
80
+ const cell = (v) => (v === null || v === undefined ? "" : String(v));
81
+ /**
82
+ * Each open change's additions, and the change that adds each. A contract is identified by its built row: the row
83
+ * whose spec_path is `<repo>/<file>` as the record names it. A new contract with no built row adds nothing, and a
84
+ * reference to it stays unresolved.
85
+ */
86
+ export class Additions {
87
+ open;
88
+ own = new Map();
89
+ adder = new Map();
90
+ constructor(open, newContracts, contractRows) {
91
+ this.open = open;
92
+ const idByPath = new Map();
93
+ for (const r of contractRows)
94
+ if (cell(r["spec_path"]) !== "")
95
+ idByPath.set(cell(r["spec_path"]), cell(r["id"]));
96
+ for (const [name, oc] of open) {
97
+ const keys = new Set();
98
+ for (const d of oc.structure.deltas)
99
+ for (const e of d.added)
100
+ keys.add(elementKey(e.catalogue, e.id));
101
+ for (const p of newContracts.get(name) ?? []) {
102
+ const id = idByPath.get(p);
103
+ if (id !== undefined && id !== "")
104
+ keys.add(elementKey(CONTRACTS, id));
105
+ }
106
+ this.own.set(name, keys);
107
+ for (const k of keys)
108
+ if (!this.adder.has(k))
109
+ this.adder.set(k, name);
110
+ }
111
+ }
112
+ /** The open change that adds the element or contract, if one does. */
113
+ addedBy(catalogue, id) {
114
+ return this.adder.get(elementKey(catalogue, id));
115
+ }
116
+ /** Whether the element or contract is the change's own addition or one of its dependency closure's. */
117
+ within(change, catalogue, id) {
118
+ const key = elementKey(catalogue, id);
119
+ return closure(change, this.open).some((c) => this.own.get(c)?.has(key) ?? false);
120
+ }
121
+ /** The contracts the given changes add. */
122
+ contracts(changes) {
123
+ const out = new Set();
124
+ for (const c of changes)
125
+ for (const k of this.own.get(c) ?? [])
126
+ if (k.startsWith(`${CONTRACTS}/`))
127
+ out.add(k.slice(CONTRACTS.length + 1));
128
+ return out;
129
+ }
130
+ /** The open changes a source-built row belongs to: its change_id, the change whose files it is built from, or a new contract it is or names. */
131
+ ownersOf(schema, catalogue, row, sddId) {
132
+ const out = new Set();
133
+ const changeId = cell(row["change_id"]);
134
+ if (changeId !== "") {
135
+ const name = changeId.startsWith(`${sddId}::`) ? changeId.slice(sddId.length + 2) : changeId;
136
+ if (this.open.has(name))
137
+ out.add(name);
138
+ }
139
+ const from = /^openspec\/changes\/([^/]+)\//.exec(cell(row["spec_path"]))?.[1];
140
+ if (from !== undefined && this.open.has(from))
141
+ out.add(from);
142
+ const contractOwner = (id) => {
143
+ const by = this.adder.get(elementKey(CONTRACTS, id));
144
+ if (by !== undefined)
145
+ out.add(by);
146
+ };
147
+ const cat = schema.catalogues.find((c) => c.name === catalogue);
148
+ if (catalogue === CONTRACTS && cat)
149
+ contractOwner(cell(row[cat.primaryKey]));
150
+ for (const fk of cat?.foreignKeys ?? [])
151
+ if (fk.references === `${CONTRACTS}.id` && cell(row[fk.column]) !== "")
152
+ contractOwner(cell(row[fk.column]));
153
+ return [...out].sort();
154
+ }
155
+ }
156
+ /**
157
+ * Sort a build's dangling references: a row an open change owns, naming that change's addition, resolves in that
158
+ * change's view. Every other stays dangling, including a row nothing ties to the change that adds its target.
159
+ */
160
+ export function splitDangling(schema, rowsByCatalogue, failures, additions, sddId) {
161
+ const intended = [];
162
+ const dangling = [];
163
+ const index = new Map();
164
+ const rowOf = (catalogue, pk) => {
165
+ let byPk = index.get(catalogue);
166
+ if (!byPk) {
167
+ const key = schema.catalogues.find((c) => c.name === catalogue)?.primaryKey ?? "id";
168
+ byPk = new Map((rowsByCatalogue.get(catalogue) ?? []).map((r) => [cell(r[key]), r]));
169
+ index.set(catalogue, byPk);
170
+ }
171
+ return byPk.get(pk);
172
+ };
173
+ for (const f of failures) {
174
+ const [target, column] = f.target.split(".");
175
+ const targetPk = schema.catalogues.find((c) => c.name === target)?.primaryKey;
176
+ const row = rowOf(f.catalogue, f.referencingRow);
177
+ const owner = f.value === "" || column !== targetPk || row === undefined
178
+ ? undefined
179
+ : additions.ownersOf(schema, f.catalogue, row, sddId).find((c) => additions.within(c, target, f.value));
180
+ if (owner !== undefined)
181
+ intended.push({ failure: f, change: owner });
182
+ else {
183
+ const by = f.value === "" ? undefined : additions.addedBy(target, f.value);
184
+ dangling.push(by === undefined ? { failure: f } : { failure: f, addedBy: by });
185
+ }
186
+ }
187
+ return { intended, dangling };
188
+ }
189
+ /** Element key -> the agreed change that adds or modifies it, and the element as that change states it. */
190
+ export function intendedWrites(open, agreed) {
191
+ const out = new Map();
192
+ for (const c of agreed) {
193
+ for (const d of open.get(c)?.structure.deltas ?? [])
194
+ for (const e of [...d.added, ...d.modified])
195
+ out.set(elementKey(e.catalogue, e.id), { change: c, element: e });
196
+ }
197
+ return out;
198
+ }
199
+ //# sourceMappingURL=intended.js.map
@@ -1,4 +1,4 @@
1
- // The structural model and a change's structural deltas (M-ADR 0230 §4.3, §4.4, Proposed). Pure reads, so the
1
+ // The structural model and a change's structural deltas (M-ADR 0230 §4.3, §4.4). Pure reads, so the
2
2
  // verify-only CLI can carry them. The format is docs/method/methods/guides/structural-model-format.md:
3
3
  // openspec/structure/<partition>.yaml the model, one file per partition
4
4
  // openspec/changes/<change>/structure/<partition>.yaml a change's delta for that partition
@@ -1,4 +1,4 @@
1
- // The generated views of the structural model (M-ADR 0230 §4.3, Proposed): projections, never edited. Each file is
1
+ // The generated views of the structural model (M-ADR 0230 §4.3): projections, never edited. Each file is
2
2
  // rendered from the current view alone, in id order, so the same model renders the same bytes. Its first line names
3
3
  // the generator and the digest of the body below it, which tells three states apart:
4
4
  // missing the file is absent
@@ -121,7 +121,12 @@ function naturalKey(cat, e) {
121
121
  * catalogue of another class, against its built rows), no cycle through a self-reference, natural keys unique, and
122
122
  * domain invariants naming their entity's own attributes and relationships.
123
123
  */
124
- export function validateView(schema, view, built, problems, unresolved) {
124
+ export function validateView(schema, view, built, problems, unresolved, addedBy = () => undefined) {
125
+ // A reference to an open change's addition, from outside that change and its dependants (§4.11 proof 3).
126
+ const outside = (catalogue, id) => {
127
+ const by = addedBy(catalogue, id);
128
+ return by === undefined ? "" : `; open change ${by} adds it, and only that change and those depending on it can name it`;
129
+ };
125
130
  for (const [catalogue, byId] of view) {
126
131
  const cat = catalogueOf(schema, catalogue);
127
132
  for (const e of byId.values()) {
@@ -133,14 +138,14 @@ export function validateView(schema, view, built, problems, unresolved) {
133
138
  for (const r of referencesOf(schema, e)) {
134
139
  if (r.structural) {
135
140
  if (!find(view, r.catalogue, r.targetColumn, r.value))
136
- problems.push(`${e.file}: ${elementKey(catalogue, e.id)}: ${r.column} ${JSON.stringify(r.value)} names no ${r.catalogue} element in this view (${elementKey(r.catalogue, r.value)})`);
141
+ problems.push(`${e.file}: ${elementKey(catalogue, e.id)}: ${r.column} ${JSON.stringify(r.value)} names no ${r.catalogue} element in this view (${elementKey(r.catalogue, r.value)})${r.targetColumn === "id" ? outside(r.catalogue, r.value) : ""}`);
137
142
  }
138
143
  else {
139
144
  const rows = built(r.catalogue, r.targetColumn);
140
145
  if (rows === null)
141
146
  unresolved.add(r.catalogue);
142
147
  else if (!rows.has(r.value))
143
- problems.push(`${e.file}: ${elementKey(catalogue, e.id)}: ${r.column} ${JSON.stringify(r.value)} names no row of ${r.catalogue} as built`);
148
+ problems.push(`${e.file}: ${elementKey(catalogue, e.id)}: ${r.column} ${JSON.stringify(r.value)} names no row of ${r.catalogue} as built${outside(r.catalogue, r.value)}`);
144
149
  }
145
150
  }
146
151
  }
@@ -3,7 +3,7 @@
3
3
  //
4
4
  // agreed content the change's proposal, deltas, Gate 0, design, tasks (checkboxes ignored), the test plan's
5
5
  // scenario-to-test rows (its Before and After cells ignored) and its structural deltas with their
6
- // base revisions (M-ADR 0230 §4.4, Proposed; report only until it is accepted) are what was approved. Each is compared
6
+ // base revisions (M-ADR 0230 §4.4; report only until it is accepted) are what was approved. Each is compared
7
7
  // with its state at the record's pinned commit: the latest amendment's commit, else sdd.commit.
8
8
  // requirement a requirement's block in a spec: its heading to the next requirement or section heading. The
9
9
  // stale check compares the blocks a change targets, not the whole file.
@@ -16,7 +16,7 @@ export function boundFile(rel) {
16
16
  return /^(proposal|design|gate-0|tasks|test-plan)\.md$/.test(rel) || /^specs\/.+\/spec\.md$/.test(rel) || structuralFile(rel);
17
17
  }
18
18
  /**
19
- * A change's structural artifact (M-ADR 0230 §4.4, Proposed): a partition delta or the no-op claim, both agreed
19
+ * A change's structural artifact (M-ADR 0230 §4.4): a partition delta or the no-op claim, both agreed
20
20
  * content, so a delta's base revisions are pinned with it. The effect record (structure/applied.yaml) is written by
21
21
  * the archive, after agreement, and is not.
22
22
  */
@@ -16,10 +16,12 @@
16
16
  // check is vacuous, and says so.
17
17
  // agreed content: the change's proposal, deltas, Gate 0, design, tasks (checkboxes ignored), test
18
18
  // plan (its execution results ignored) and structural deltas equal their state at that commit
19
- // (M-ADR 0222 §4.6; a structural delta's change is a [WARN] while M-ADR 0230 is Proposed).
19
+ // (M-ADR 0222 §4.6; a structural delta's change fails like the rest, a [WARN] only with ENFORCED false).
20
20
  // kinds: on a record held to 1.1.0, each test-plan row's Kind (changed or carried) is the one computed
21
- // from the deltas and the living spec, a row naming no scenario of the deltas fails, and a
22
- // filled Before is a fail for changed and a pass for carried (M-ADR 0223 §4.4).
21
+ // from the deltas and the living spec, or repaired (declared, on a carried scenario); a row
22
+ // naming no scenario of the deltas fails; and a filled Before, read as whole-word results, is a
23
+ // fail for changed, a pass for carried, and a fail citing the test double's correcting commit
24
+ // for repaired (M-ADR 0223 §4.4).
23
25
  // 4. verdicts: every archived change except a baseline has a verdict.md whose verdict line agrees with
24
26
  // its record's PASS (M-ADR 0223 §4.6). A record held to 1.1.0 (by its own history, or by the
25
27
  // schema the SDD shipped when the change archived) also needs: an agreed record; archived
@@ -186,6 +188,16 @@ export function parseAgreementRecord(file, text) {
186
188
  noLongerImplements.push({ repo: e.repo, requirement: e.requirement });
187
189
  }
188
190
  }
191
+ // Record 1.3.0 (M-ADR 0230 §4.2): a contracts entry may deprecate its version, stating the sunset. A
192
+ // deprecation that names no date states no runway, so it is refused rather than read as a deprecation without one.
193
+ for (const [i, x] of (Array.isArray(r.contracts) ? r.contracts : []).entries()) {
194
+ const d = x?.deprecates;
195
+ if (d === undefined)
196
+ continue;
197
+ const sunset = d?.sunset;
198
+ if (typeof sunset !== "string" || !isCalendarDate(sunset))
199
+ problems.push(`${file}: contracts[${i}].deprecates must be {sunset: YYYY-MM-DD}, a calendar date as a string; found ${JSON.stringify(d)}`);
200
+ }
189
201
  if (problems.length > 0)
190
202
  return { problems };
191
203
  return {
@@ -205,6 +217,13 @@ export function parseAgreementRecord(file, text) {
205
217
  problems,
206
218
  };
207
219
  }
220
+ /** A YYYY-MM-DD string naming a day that exists. */
221
+ function isCalendarDate(s) {
222
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(s))
223
+ return false;
224
+ const t = new Date(`${s}T00:00:00Z`);
225
+ return !Number.isNaN(t.getTime()) && t.toISOString().slice(0, 10) === s;
226
+ }
208
227
  /** Every pinned commit in a record: any key named `commit` or ending `_commit`, and each value under verdict.merged and current. */
209
228
  function commitFields(r) {
210
229
  const out = [];
@@ -489,7 +508,7 @@ export async function checkBehaviourStore(repo, options = {}) {
489
508
  if (a === null || b === null || normaliseBound(f, a) !== normaliseBound(f, b)) {
490
509
  const what = `${dir}: AGREED CONTENT CHANGED: ${f} ${a === null ? "was added" : b === null ? "was removed" : "differs"} since ${base}, with no amendment recording a commit and an approver; amend the agreement or renew it (M-ADR 0222 §4.6)`;
491
510
  if (structuralFile(f) && !STRUCTURE_ENFORCED)
492
- warnings.push(`${what}; a structural delta, report only while M-ADR 0230 is Proposed`);
511
+ warnings.push(`${what}; a structural delta, reported only because ENFORCED is off`);
493
512
  else
494
513
  failures.push(what);
495
514
  }
@@ -497,14 +516,14 @@ export async function checkBehaviourStore(repo, options = {}) {
497
516
  }
498
517
  }
499
518
  }
500
- // A test plan's kinds (M-ADR 0223 §4.4 as amended): changed scenarios go red then green, carried ones stay green.
519
+ // A test plan's kinds (M-ADR 0223 §4.4 as amended): changed and repaired scenarios go red then green, carried ones stay green.
501
520
  if (versionAtLeast(record?.schemaVersion, "1.1.0") && record?.status === "agreed") {
502
521
  const plan = await readText(join(repo, dir, "test-plan.md"));
503
522
  if (plan !== null) {
504
523
  const header = testPlanHeader(plan);
505
524
  const col = (name) => header.indexOf(name);
506
525
  if (col("kind") < 0)
507
- failures.push(`${dir}/test-plan.md: the scenario table has no Kind column (changed | carried)`);
526
+ failures.push(`${dir}/test-plan.md: the scenario table has no Kind column (changed | carried | repaired)`);
508
527
  else {
509
528
  for (const row of testPlanMapping(plan)) {
510
529
  const cell = row[col("requirement")] ?? "";
@@ -533,13 +552,31 @@ export async function checkBehaviourStore(repo, options = {}) {
533
552
  continue;
534
553
  }
535
554
  kindsChecked++;
536
- if (kind !== expected)
555
+ // `repaired` is declared, never computed: a carried scenario whose earlier pass was false-green.
556
+ const repaired = kind === "repaired";
557
+ if (repaired && expected === "changed")
558
+ failures.push(`${dir}/test-plan.md: "${req}" / "${scen}" is changed against the living spec, so it cannot be repaired: repaired is a scenario carried as the living spec states it, whose code did not meet it`);
559
+ else if (!repaired && kind !== expected)
537
560
  failures.push(`${dir}/test-plan.md: "${req}" / "${scen}" is ${expected} against the living spec, and the plan says ${JSON.stringify(kind)}`);
538
561
  const before = col("before") >= 0 ? (row[col("before")] ?? "") : "";
539
- if (before !== "" && expected === "changed" && !/fail|red/i.test(before))
540
- failures.push(`${dir}/test-plan.md: "${req}" / "${scen}" is changed, so its test fails before the code; the scenario table's Before cell says ${JSON.stringify(before)}`);
541
- if (before !== "" && expected === "carried" && !/pass|green/i.test(before))
542
- failures.push(`${dir}/test-plan.md: "${req}" / "${scen}" is carried, so its test passes before the code as regression; the scenario table's Before cell says ${JSON.stringify(before)}`);
562
+ if (before !== "") {
563
+ // The cell's results, as whole words: "PASS at x; FAIL at y" holds both, and "setup required" or "bypass" holds neither.
564
+ const pass = /\b(?:pass(?:es|ed)?|green)\b/i.test(before);
565
+ const fail = /\b(?:fail(?:s|ed)?|red)\b/i.test(before);
566
+ const said = `the scenario table's Before cell says ${JSON.stringify(before)}`;
567
+ if (repaired) {
568
+ if (!fail)
569
+ failures.push(`${dir}/test-plan.md: "${req}" / "${scen}" is repaired, so its test fails before the code; ${said}`);
570
+ else if (!/\b[0-9a-f]{7,40}\b/i.test(before))
571
+ failures.push(`${dir}/test-plan.md: "${req}" / "${scen}" is repaired, so its Before cell cites the commit that corrects the test double, with its own red run; ${said}`);
572
+ }
573
+ else if (expected === "changed" && (!fail || pass)) {
574
+ failures.push(`${dir}/test-plan.md: "${req}" / "${scen}" is changed, so its test fails before the code${pass && fail ? ", and a cell holding both results is not a result" : ""}; ${said}`);
575
+ }
576
+ else if (expected === "carried" && (!pass || fail)) {
577
+ failures.push(`${dir}/test-plan.md: "${req}" / "${scen}" is carried, so its test passes before the code as regression${fail ? "; if its earlier pass was false-green, declare it repaired and cite the commit that corrects the test double" : ""}; ${said}`);
578
+ }
579
+ }
543
580
  }
544
581
  }
545
582
  }
@@ -640,7 +677,7 @@ export async function checkBehaviourStore(repo, options = {}) {
640
677
  if (a === null || b === null || normaliseBound(f, a) !== normaliseBound(f, b)) {
641
678
  const what = `${dir}: AGREED CONTENT CHANGED: ${f} ${a === null ? "was added" : b === null ? "was removed" : "differs"} from what was agreed at ${base}, with no amendment recording a commit and an approver (M-ADR 0222 §4.6)`;
642
679
  if (structuralFile(f) && !STRUCTURE_ENFORCED)
643
- warnings.push(`${what}; a structural delta, report only while M-ADR 0230 is Proposed`);
680
+ warnings.push(`${what}; a structural delta, reported only because ENFORCED is off`);
644
681
  else
645
682
  failures.push(what);
646
683
  }
@@ -43,7 +43,7 @@ const BEHAVIOUR_STORE_VALIDATORS = [
43
43
  source: { kind: "native", run: async (ctx) => checkJourneyImpact(ctx.targetRepo) },
44
44
  build: () => ({ status: "run", args: [] }),
45
45
  },
46
- // The structural model and each open change's structural artifact (M-ADR 0230, Proposed). Report only; [N/A] without a model.
46
+ // The structural model and each open change's structural artifact (M-ADR 0230). Report only; [N/A] without a model.
47
47
  {
48
48
  name: "structural-model",
49
49
  source: { kind: "native", run: async (ctx) => checkStructuralModel(ctx.targetRepo) },
@@ -1,4 +1,4 @@
1
- // structural-model: the structural model and each open change's structural artifact (M-ADR 0230 §4.3, §4.4, Proposed;
1
+ // structural-model: the structural model and each open change's structural artifact (M-ADR 0230 §4.3, §4.4;
2
2
  // docs/method/methods/guides/structural-model-format.md).
3
3
  //
4
4
  // The model (openspec/structure/<partition>.yaml), as the current view:
@@ -6,11 +6,13 @@
6
6
  // (a platform SDD: its domains and `platform`; an application or integration SDD: its sdd_id)
7
7
  // elements a `structure` catalogue, its `structure` columns only, an id held once, required columns,
8
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
9
+ // references resolved in the view; into a catalogue of another class, against that catalogue as built, less the
10
+ // contracts open changes' records agree as new (lib/structure/intended.ts): those are in their change's
11
+ // view, its dependants' and the intended one, so a current reference to one is refused (§4.11 proof 3)
10
12
  // hierarchies no cycle through a self-reference; natural keys unique; domain invariants naming their entity's own
11
13
  // 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
+ // views docs/structure/ absent where the model is admitted, and each generated view under it missing, stale
15
+ // (rendered from an earlier model) or edited by hand (its body no longer matches the digest in its header)
14
16
  // Each open change (openspec/changes/<change>/structure/):
15
17
  // artifact where the model exists, a change has partition deltas or none.yaml, never both
16
18
  // deltas well formed; ADDED ids new; MODIFIED and REMOVED ids held by the delta's partition; a base
@@ -29,20 +31,22 @@
29
31
  // archived a change archived with structural deltas and no effect record (a raw `openspec archive`): its
30
32
  // structure is not applied, so its transition is not shipped
31
33
  //
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
+ // Enforced (the release that accepts M-ADR 0230): every finding is a [FAIL] and the check
35
+ // exits 1; with ENFORCED false it reports them as [WARN] and exits 0. Without openspec/structure/ and with no change
36
+ // carrying a structure/ directory it reports [N/A], so an SDD without a model is unaffected.
34
37
  import { readdir, readFile } from "node:fs/promises";
35
38
  import { join } from "node:path";
36
39
  import { parseDocument } from "yaml";
37
40
  import { parseCatalogueCsv } from "../catalogue/csv-reader.js";
38
41
  import { loadCanonicalSchema } from "../catalogue/schema-loader.js";
39
42
  import { APPLIED_FILE, elementKey, loadChangeStructure, loadModel, STRUCTURE_DIR } from "../structure/model.js";
40
- import { agreedOrder, attribute, checkDependencies, checkOverlap, intended, viewAt } from "../structure/compose.js";
43
+ import { agreedOrder, attribute, checkDependencies, checkOverlap, closure, intended, viewAt } from "../structure/compose.js";
41
44
  import { describeState, renderViews, VIEW_PATHS, viewState } from "../structure/render.js";
45
+ import { Additions, CONTRACTS, readAgreement } from "../structure/intended.js";
42
46
  import { validateDeltas, validateView, viewOf } from "../structure/view.js";
43
47
  const VIEW_COUNT = Object.keys(VIEW_PATHS).length;
44
- /** Flipped when M-ADR 0230 is accepted: findings then FAIL. */
45
- export const ENFORCED = false;
48
+ /** Findings FAIL (from M-ADR 0230's acceptance); false reports them only, as while M-ADR 0230 was Proposed. Also binds a change's structural deltas as agreed content in the behaviour-store check. */
49
+ export const ENFORCED = true;
46
50
  const CHANGES_DIR = join("openspec", "changes");
47
51
  async function readText(p) {
48
52
  try {
@@ -60,19 +64,6 @@ async function subdirs(p) {
60
64
  return [];
61
65
  }
62
66
  }
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
67
  /** Names of archived changes: openspec/changes/archive/<YYYY-MM-DD>-<name>. */
77
68
  async function archivedNames(repo) {
78
69
  return new Set((await subdirs(join(repo, CHANGES_DIR, "archive"))).map((d) => d.replace(/^\d{4}-\d{2}-\d{2}-/, "")));
@@ -91,8 +82,22 @@ function builtLookup(repo) {
91
82
  const text = await readText(join(repo, "catalogues", `${catalogue}.csv`));
92
83
  cache.set(catalogue, text === null ? null : parseCatalogueCsv(text).rows);
93
84
  };
85
+ fn.rows = (catalogue) => cache.get(catalogue) ?? [];
94
86
  return fn;
95
87
  }
88
+ /**
89
+ * The built catalogues as one view sees them. A contract an open change's record agrees as new is built from the
90
+ * change's own sources, so it is in no view but that change's, its dependants' and, once agreed, the intended one.
91
+ */
92
+ function builtIn(built, all, mine) {
93
+ return (catalogue, column) => {
94
+ const rows = built(catalogue, column);
95
+ if (rows === null || catalogue !== CONTRACTS || column !== "id")
96
+ return rows;
97
+ const out = new Set([...rows].filter((id) => !all.has(id) || mine.has(id)));
98
+ return out;
99
+ };
100
+ }
96
101
  export async function checkStructuralModel(repo, options = {}) {
97
102
  const enforced = options.enforced ?? ENFORCED;
98
103
  const problems = [];
@@ -123,6 +128,16 @@ export async function checkStructuralModel(repo, options = {}) {
123
128
  for (const c of schema.catalogues)
124
129
  if (c.sourceOfTruth !== "structure")
125
130
  await built.load(c.name);
131
+ const open = new Map();
132
+ const newContracts = new Map();
133
+ for (const s of structures) {
134
+ const agreement = await readAgreement(repo, s.change);
135
+ open.set(s.change, { structure: s, status: agreement.status });
136
+ newContracts.set(s.change, agreement.newContracts);
137
+ }
138
+ const additions = new Additions(open, newContracts, built.rows(CONTRACTS));
139
+ const allNew = additions.contracts(open.keys());
140
+ const addedBy = (catalogue, id) => additions.addedBy(catalogue, id);
126
141
  let allowed = null;
127
142
  if (profile === "platform-profile") {
128
143
  allowed = new Set(["platform", ...(view.get("domains")?.keys() ?? []), ...(built("domains", "id") ?? [])]);
@@ -143,14 +158,14 @@ export async function checkStructuralModel(repo, options = {}) {
143
158
  // The current view.
144
159
  const unresolved = new Set();
145
160
  const currentProblems = [];
146
- validateView(schema, view, built, currentProblems, unresolved);
161
+ validateView(schema, view, builtIn(built, allNew, new Set()), currentProblems, unresolved, addedBy);
147
162
  problems.push(...currentProblems);
148
163
  const elementCount = [...view.values()].reduce((n, m) => n + m.size, 0);
149
164
  lines.push(files === null ? `model: none (${STRUCTURE_DIR}/ absent)` : `model: ${files.length} partition file(s), ${elementCount} element(s) in ${view.size} catalogue(s)`);
150
165
  if (unresolved.size > 0)
151
166
  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).
167
+ // The generated views, against a render of the current view. Where a model is admitted they are required (M-ADR
168
+ // 0230 §4.3): an SDD reviews an extraction before writing it (§6 step 1), so none is mid-adoption with a model.
154
169
  if (files !== null && (await subdirs(join(repo, "docs"))).includes("structure")) {
155
170
  let current = 0;
156
171
  for (const [path, rendered] of renderViews(view)) {
@@ -163,14 +178,15 @@ export async function checkStructuralModel(repo, options = {}) {
163
178
  lines.push(`generated views: ${current} of ${VIEW_COUNT} current`);
164
179
  }
165
180
  else if (files !== null) {
166
- lines.push("generated views: not rendered (no docs/structure/); `sdd-cli structure render` writes them");
181
+ problems.push("docs/structure/: the generated views are not rendered; where a structural model is admitted they are required. `structure render` writes them (sdd-maintain or sdd-cli)");
182
+ lines.push(`generated views: 0 of ${VIEW_COUNT} (no docs/structure/)`);
167
183
  }
168
184
  // Open changes.
169
- const addedBy = new Map();
185
+ const addedByChange = new Map();
170
186
  for (const s of structures)
171
187
  for (const d of s.deltas)
172
188
  for (const e of d.added)
173
- addedBy.set(elementKey(e.catalogue, e.id), s.change);
189
+ addedByChange.set(elementKey(e.catalogue, e.id), s.change);
174
190
  const features = new Set(view.get("features")?.keys() ?? []);
175
191
  let withDeltas = 0;
176
192
  let noOps = 0;
@@ -190,7 +206,7 @@ export async function checkStructuralModel(repo, options = {}) {
190
206
  }
191
207
  if (s.noOp && s.deltas.length > 0)
192
208
  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));
209
+ const elsewhere = new Map([...addedByChange].filter(([, c]) => c !== s.change));
194
210
  validateDeltas(schema, view, s.deltas, elsewhere, problems);
195
211
  // A behaviour delta for a feature the model does not hold needs that feature added here.
196
212
  const adds = new Set(s.deltas.flatMap((d) => d.added.filter((e) => e.catalogue === "features").map((e) => e.id)));
@@ -207,9 +223,6 @@ export async function checkStructuralModel(repo, options = {}) {
207
223
  }
208
224
  lines.push(`changes: ${changes.length} open, ${withDeltas} with structural deltas, ${noOps} claiming none, ${without} without the artifact`);
209
225
  // 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
226
  const archived = await archivedNames(repo);
214
227
  checkOverlap(open, problems);
215
228
  checkDependencies(open, archived, problems);
@@ -219,7 +232,7 @@ export async function checkStructuralModel(repo, options = {}) {
219
232
  if (oc.structure.deltas.length === 0)
220
233
  continue;
221
234
  const at = [];
222
- validateView(schema, viewAt(view, name, open), built, at, new Set());
235
+ validateView(schema, viewAt(view, name, open), builtIn(built, allNew, additions.contracts(closure(name, open))), at, new Set(), addedBy);
223
236
  for (const p of at) {
224
237
  if (known.has(p))
225
238
  continue;
@@ -231,7 +244,7 @@ export async function checkStructuralModel(repo, options = {}) {
231
244
  const agreed = agreedOrder(open);
232
245
  if (agreed.length > 0) {
233
246
  const inIntended = [];
234
- validateView(schema, intended(view, open), built, inIntended, new Set());
247
+ validateView(schema, intended(view, open), builtIn(built, allNew, additions.contracts(agreed)), inIntended, new Set(), addedBy);
235
248
  for (const p of inIntended) {
236
249
  if (known.has(p) || atAgreed.has(p))
237
250
  continue;
@@ -259,7 +272,7 @@ export async function checkStructuralModel(repo, options = {}) {
259
272
  if (problems.length === 0)
260
273
  return { exitCode: 0, stdout: `structural-model:\n${body}[PASS] structural-model: ok\n` };
261
274
  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`;
275
+ const tail = enforced ? `[FAIL] structural-model: ${problems.length} finding(s)\n` : `[WARN] structural-model: ${problems.length} finding(s), report only (M-ADR 0230)\n`;
263
276
  return { exitCode: enforced ? 1 : 0, stdout: `structural-model:\n${body}${problems.map((p) => `[${tag}] ${p}\n`).join("")}${tail}` };
264
277
  }
265
278
  //# sourceMappingURL=structural-model.js.map
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "0.14.0",
3
- "built_against_sdd_cli": "5.0.0"
2
+ "version": "0.15.1",
3
+ "built_against_sdd_cli": "5.1.1"
4
4
  }
@@ -0,0 +1,15 @@
1
+ import { archiveChange } from "../lib/structure/archive.js";
2
+ // `change archive <change>`: the method's archive operation (M-ADR 0230 §4.6). Behaviour and structure are
3
+ // prepared in a scratch worktree, validated as a whole, and published as one commit only if the store has not moved.
4
+ // Run it again after an interruption: it verifies an archive that already landed, and recovers a raw `openspec archive`.
5
+ export function registerChange(program) {
6
+ const change = program.command("change").description("Operate on a change in the behaviour store (M-ADR 0223, M-ADR 0230)");
7
+ change
8
+ .command("archive <change>")
9
+ .description("Archive a judged change: its behaviour and structure published as one guarded commit (M-ADR 0230 §4.6)")
10
+ .option("--sdd-path <path>", "the SDD whose openspec/ is the store (default: the current directory)", ".")
11
+ .action(async (name, opts) => {
12
+ process.exitCode = await archiveChange({ sddPath: opts.sddPath, change: name, write: (s) => process.stdout.write(s) });
13
+ });
14
+ }
15
+ //# sourceMappingURL=change.js.map