@sdd-method/author-cli 0.12.1 → 0.14.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 (46) hide show
  1. package/README.md +1 -1
  2. package/dist/author-program.js +10 -2
  3. package/dist/lib/catalogue/build.js +72 -23
  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/c4-containers.js +6 -2
  7. package/dist/lib/catalogue/builders/contract-revisions.js +45 -7
  8. package/dist/lib/catalogue/builders/contracts.js +4 -11
  9. package/dist/lib/catalogue/builders/data-model.js +1 -1
  10. package/dist/lib/catalogue/builders/entities-manifest.js +10 -4
  11. package/dist/lib/catalogue/builders/entity-archetype.js +121 -0
  12. package/dist/lib/catalogue/builders/entity-merge.js +90 -0
  13. package/dist/lib/catalogue/builders/features.js +1 -1
  14. package/dist/lib/catalogue/builders/glossary-xref.js +11 -5
  15. package/dist/lib/catalogue/builders/journey-requirement.js +5 -5
  16. package/dist/lib/catalogue/builders/quality-scores.js +1 -1
  17. package/dist/lib/catalogue/builders/requirements.js +1 -1
  18. package/dist/lib/catalogue/builders/service-contract-consumes.js +2 -2
  19. package/dist/lib/catalogue/builders/service-entity-tables.js +1 -1
  20. package/dist/lib/catalogue/canonical-schema.yaml +50 -30
  21. package/dist/lib/catalogue/contract-id.js +49 -12
  22. package/dist/lib/catalogue/manifest-loader.js +1 -1
  23. package/dist/lib/catalogue/retired-catalogues.js +1 -1
  24. package/dist/lib/catalogue/rubric-coverage-map.yaml +7 -3
  25. package/dist/lib/catalogue/schema-compat.js +1 -1
  26. package/dist/lib/catalogue/staleness-verdicts.js +3 -3
  27. package/dist/lib/structure/apply.js +71 -0
  28. package/dist/lib/structure/archive.js +330 -0
  29. package/dist/lib/structure/catalogue.js +31 -10
  30. package/dist/lib/structure/compose.js +1 -1
  31. package/dist/lib/structure/effects.js +1 -1
  32. package/dist/lib/structure/extract.js +123 -0
  33. package/dist/lib/structure/intended.js +194 -0
  34. package/dist/lib/structure/model.js +1 -1
  35. package/dist/lib/structure/render.js +1 -1
  36. package/dist/lib/structure/view.js +8 -3
  37. package/dist/lib/validate/agreement-evidence.js +2 -2
  38. package/dist/lib/validate/behaviour-store.js +49 -12
  39. package/dist/lib/validate/dispatch.js +2 -2
  40. package/dist/lib/validate/journey-impact.js +7 -7
  41. package/dist/lib/validate/structural-model.js +48 -35
  42. package/dist/maintain-version.json +2 -2
  43. package/dist/verbs/change.js +15 -0
  44. package/dist/verbs/migrate-structure.js +36 -0
  45. package/dist/verify-version.json +2 -2
  46. package/package.json +2 -2
@@ -7,7 +7,7 @@ import { rm, stat } from "node:fs/promises";
7
7
  import { join } from "node:path";
8
8
  /** name -> why it went, and what replaced it. */
9
9
  export const RETIRED_CATALOGUES = new Map([
10
- ["requirement_status", "retired in catalogue schema 6.0.0: evidence about one revision of a requirement is requirement_assertions (M-ADR 0230 §4.7, Proposed)"],
10
+ ["requirement_status", "retired in catalogue schema 6.0.0: evidence about one revision of a requirement is requirement_assertions (M-ADR 0230 §4.7)"],
11
11
  ]);
12
12
  /** Remove each retired catalogue's file from `dir`. Returns the file names removed. */
13
13
  export async function removeRetiredCatalogues(dir, findings, label) {
@@ -954,7 +954,7 @@ coverage:
954
954
  entity_attributes:
955
955
  kind: entity
956
956
  scoring: scored-at-source
957
- instrument: an element of the structural model (M-ADR 0230, Proposed); scored with its domain model by rubrics/domain-architecture-rubric.md
957
+ instrument: an element of the structural model (M-ADR 0230); scored with its domain model by rubrics/domain-architecture-rubric.md
958
958
  relationships:
959
959
  entity_id:
960
960
  class: membership
@@ -962,7 +962,7 @@ coverage:
962
962
  entity_relationships:
963
963
  kind: entity
964
964
  scoring: scored-at-source
965
- instrument: an element of the structural model (M-ADR 0230, Proposed); scored with its domain model by rubrics/domain-architecture-rubric.md
965
+ instrument: an element of the structural model (M-ADR 0230); scored with its domain model by rubrics/domain-architecture-rubric.md
966
966
  relationships:
967
967
  from_entity_id:
968
968
  class: membership
@@ -973,7 +973,7 @@ coverage:
973
973
  domain_invariants:
974
974
  kind: entity
975
975
  scoring: scored-at-source
976
- instrument: an element of the structural model (M-ADR 0230, Proposed); scored with its domain model by rubrics/domain-architecture-rubric.md
976
+ instrument: an element of the structural model (M-ADR 0230); scored with its domain model by rubrics/domain-architecture-rubric.md
977
977
  relationships:
978
978
  entity_id:
979
979
  class: membership
@@ -1039,3 +1039,7 @@ coverage:
1039
1039
  kind: enum
1040
1040
  scoring: not-applicable
1041
1041
  reason: closed vocabulary, not an artefact
1042
+ ppt_archetype:
1043
+ kind: enum
1044
+ scoring: not-applicable
1045
+ reason: closed vocabulary, not an artefact
@@ -27,7 +27,7 @@
27
27
  * M-ADR 0134 §4.6 (canonical builder + aggregator).
28
28
  */
29
29
  export const SCHEMA_COMPAT = {
30
- catalogue_schema: "^6.0.0",
30
+ catalogue_schema: "^7.0.0",
31
31
  sdd_manifest: "^1.0.0",
32
32
  aggregation_manifest: "^1.0.0",
33
33
  };
@@ -40,7 +40,7 @@
40
40
  // none — already reported by scores-coverage as unknown currency; not
41
41
  // re-reported here.
42
42
  //
43
- // ASSESSED INPUTS (M-ADR 0230 §4.9, Proposed): a review that names its other inputs (`input_paths`) is read with
43
+ // ASSESSED INPUTS (M-ADR 0230 §4.9): a review that names its other inputs (`input_paths`) is read with
44
44
  // each of them, once its target is current. An input that moved stales the score, with the input named. So does an
45
45
  // input gone from HEAD, and one the corpus did not hold at the anchor: unlike a missing target, the score can still
46
46
  // be re-reviewed against its target, and a review cannot have read an input that was not there, so both prescribe a
@@ -171,7 +171,7 @@ export async function runStalenessVerdicts(session, options) {
171
171
  };
172
172
  }
173
173
  const asStr = (v) => v === null || v === undefined ? "" : String(v).trim();
174
- // input_paths (schema 6.0.0, M-ADR 0230 §4.9, Proposed): a catalogue built before it has no such column, and its
174
+ // input_paths (schema 6.0.0, M-ADR 0230 §4.9): a catalogue built before it has no such column, and its
175
175
  // scores are read as naming no other input.
176
176
  const query = (inputs) => `SELECT rubric_id, target_ref, target_path, corpus_ref, scored_at, staleness_basis, ${inputs}
177
177
  FROM "quality_scores"
@@ -191,7 +191,7 @@ export async function runStalenessVerdicts(session, options) {
191
191
  const rubricId = asStr(rubricIdRaw);
192
192
  const targetRef = asStr(targetRefRaw);
193
193
  const basis = asStr(basisRaw);
194
- // The review's other assessed inputs (M-ADR 0230 §4.9, Proposed): read with the target, so an edit to any of
194
+ // The review's other assessed inputs (M-ADR 0230 §4.9): read with the target, so an edit to any of
195
195
  // them makes the score stale as an edit to the target does.
196
196
  const inputs = asStr(inputPathsRaw).split(";").map((x) => x.trim()).filter((x) => x !== "");
197
197
  // Counted for BOTH bases, before the branch. The first cut of this counter
@@ -0,0 +1,71 @@
1
+ // Writing a change's structural deltas into the model files, and the record of what they did (M-ADR 0230 §4.6).
2
+ // The files are edited as YAML documents, so their comments and element order survive: a modified element
3
+ // is replaced where it stands, a removed one is deleted, an added one is appended to its catalogue's list, and a
4
+ // partition the model does not have yet gets a file. Writes files; the archive operation calls it in a scratch copy.
5
+ import { readFile, writeFile } from "node:fs/promises";
6
+ import { join } from "node:path";
7
+ import { isMap, isSeq, parseDocument, stringify } from "yaml";
8
+ import { elementKey, FORMAT_VERSION, STRUCTURE_DIR } from "./model.js";
9
+ export { APPLIED_FILE } from "./model.js";
10
+ export { effectsOf, parseAppliedRecord } from "./effects.js";
11
+ export function appliedRecord(change, baseCommit, effects) {
12
+ return stringify({ schema_version: FORMAT_VERSION, change, base_commit: baseCommit, effects: effects.map((e) => ({ ...e })) });
13
+ }
14
+ function itemIndex(seq, id) {
15
+ return seq.items.findIndex((it) => isMap(it) && String(it.get("id")) === id);
16
+ }
17
+ function catalogueSeq(doc, catalogue) {
18
+ let elements = doc.get("elements");
19
+ if (!isMap(elements)) {
20
+ doc.set("elements", doc.createNode({}));
21
+ elements = doc.get("elements");
22
+ }
23
+ const map = elements;
24
+ let seq = map.get(catalogue);
25
+ if (!isSeq(seq)) {
26
+ map.set(catalogue, doc.createNode([]));
27
+ seq = map.get(catalogue);
28
+ }
29
+ return seq;
30
+ }
31
+ /** Apply the change's deltas to the model files under `sddPath`, partition by partition. Returns the files written. */
32
+ export async function applyToModel(sddPath, s) {
33
+ const written = [];
34
+ for (const d of s.deltas) {
35
+ const rel = join(STRUCTURE_DIR, `${d.partition}.yaml`);
36
+ const path = join(sddPath, rel);
37
+ let text;
38
+ try {
39
+ text = await readFile(path, "utf8");
40
+ }
41
+ catch {
42
+ text = stringify({ schema_version: FORMAT_VERSION, partition: d.partition, elements: {} });
43
+ }
44
+ const doc = parseDocument(text);
45
+ const node = (e) => doc.createNode({ id: e.id, ...e.columns });
46
+ for (const e of d.modified) {
47
+ const seq = catalogueSeq(doc, e.catalogue);
48
+ const i = itemIndex(seq, e.id);
49
+ if (i < 0)
50
+ throw new Error(`${rel}: MODIFIED ${elementKey(e.catalogue, e.id)} is not in this partition`);
51
+ seq.items[i] = node(e);
52
+ }
53
+ for (const r of d.removed) {
54
+ const seq = catalogueSeq(doc, r.catalogue);
55
+ const i = itemIndex(seq, r.id);
56
+ if (i < 0)
57
+ throw new Error(`${rel}: REMOVED ${elementKey(r.catalogue, r.id)} is not in this partition`);
58
+ seq.items.splice(i, 1);
59
+ }
60
+ for (const e of d.added) {
61
+ const seq = catalogueSeq(doc, e.catalogue);
62
+ if (itemIndex(seq, e.id) >= 0)
63
+ throw new Error(`${rel}: ADDED ${elementKey(e.catalogue, e.id)} is already in this partition`);
64
+ seq.add(node(e));
65
+ }
66
+ await writeFile(path, doc.toString(), "utf8");
67
+ written.push(rel);
68
+ }
69
+ return written;
70
+ }
71
+ //# sourceMappingURL=apply.js.map
@@ -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
- // Proposed). Where an SDD has a model, it is the record of structure, so every structure catalogue is built from it whole:
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. Pure: no file is read or written.
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 = structural.filter((c) => cell(from[c]) !== "" && cell(from[c]) !== cell(e.columns[c]));
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, Proposed)`, "warn", `catalogue:${cat.name}`);
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, Proposed)`, "warn", `catalogue:${cat.name}`);
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, Proposed).
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, Proposed). Pure reads,
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";