@sdd-method/author-cli 0.15.0 → 0.16.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.
@@ -196,6 +196,34 @@ export function parseSurfaces(evidence) {
196
196
  problems.push("the **Surfaces:** line lists no contract");
197
197
  return { present: true, entries, problems };
198
198
  }
199
+ /**
200
+ * A structural delta's `moved: {contracts: [{from, to, from_path?}]}` block. A contract's id is derived from its path,
201
+ * so moving the file changes the id; the change that moves it records the move once, and what named the old
202
+ * identity (an archived baseline's Surfaces list) follows it rather than being edited.
203
+ */
204
+ export function contractMoves(raw) {
205
+ const moves = [];
206
+ const problems = [];
207
+ if (raw === undefined || raw === null)
208
+ return { moves, problems };
209
+ const contracts = typeof raw === "object" && !Array.isArray(raw) ? raw["contracts"] : undefined;
210
+ const extra = typeof raw === "object" && raw !== null && !Array.isArray(raw) ? Object.keys(raw).filter((k) => k !== "contracts") : [];
211
+ if (!Array.isArray(contracts) || extra.length > 0) {
212
+ problems.push(`moved must be {contracts: [{from: <old contract id>, to: <new contract id>, from_path?: <old spec path>}]}`);
213
+ return { moves, problems };
214
+ }
215
+ for (const [i, m] of contracts.entries()) {
216
+ const e = (m ?? {});
217
+ const from = typeof e["from"] === "string" ? e["from"].trim() : "";
218
+ const to = typeof e["to"] === "string" ? e["to"].trim() : "";
219
+ const fromPath = typeof e["from_path"] === "string" ? e["from_path"].trim() : undefined;
220
+ if (from === "" || to === "" || from === to)
221
+ problems.push(`moved.contracts[${i}] needs a from and a to that differ`);
222
+ else
223
+ moves.push({ from, to, ...(fromPath ? { fromPath } : {}) });
224
+ }
225
+ return { moves, problems };
226
+ }
199
227
  /** Whether a Surfaces entry names a catalogue contract: its id, or a path suffix of its spec_path on a segment boundary. */
200
228
  export function surfaceMatches(entry, contractId, specPath) {
201
229
  const e = entry.replace(/^\.\//, "");
@@ -14,6 +14,16 @@ export const SCHEMA_NAME = "sdd-change";
14
14
  export const SHIPPED_SCHEMA_DIR = "docs/method/foundations/openspec/sdd-change";
15
15
  export const SHIPPED_CONFIG_TEMPLATE = "docs/method/foundations/openspec/config-template.yaml";
16
16
  export const STORE_SCHEMA_DIR = `openspec/schemas/${SCHEMA_NAME}`;
17
+ /**
18
+ * The baseline schema (M-ADR 0222 §4.3-4.4): a baseline change selects it in its own .openspec.yaml, the store's
19
+ * config.yaml keeping sdd-change as the default. Copied and checked as sdd-change is, where the SDD's bundle
20
+ * ships it; a bundle from before it shipped has none, and nothing is asked of the store.
21
+ */
22
+ export const BASELINE_SCHEMA_NAME = "sdd-baseline";
23
+ /** Every schema the method ships for a store, the default first. */
24
+ export const STORE_SCHEMAS = [SCHEMA_NAME, BASELINE_SCHEMA_NAME];
25
+ export const shippedSchemaDir = (name) => `docs/method/foundations/openspec/${name}`;
26
+ export const storeSchemaDir = (name) => `openspec/schemas/${name}`;
17
27
  export const STORE_CONFIG = "openspec/config.yaml";
18
28
  async function files(dir) {
19
29
  const out = [];
@@ -50,36 +60,38 @@ export async function storeSchemaName(repo) {
50
60
  const v = parseDocument(text.toString("utf8")).toJS()?.schema;
51
61
  return typeof v === "string" ? v : "";
52
62
  }
53
- /** Compare the store's copy of the schema with the one the SDD's method bundle shipped, file by file. */
54
- export async function compareStoreSchema(repo) {
55
- const shipped = await files(join(repo, SHIPPED_SCHEMA_DIR));
63
+ /** Compare the store's copy of a schema with the one the SDD's method bundle shipped, file by file. */
64
+ export async function compareStoreSchema(repo, name = SCHEMA_NAME) {
65
+ const shippedDir = shippedSchemaDir(name);
66
+ const storeDir = storeSchemaDir(name);
67
+ const shipped = await files(join(repo, shippedDir));
56
68
  if (shipped === null || shipped.length === 0)
57
69
  return { shipped: false, missing: [], differing: [], extra: [] };
58
- const store = (await files(join(repo, STORE_SCHEMA_DIR))) ?? [];
70
+ const store = (await files(join(repo, storeDir))) ?? [];
59
71
  const missing = shipped.filter((f) => !store.includes(f));
60
72
  const extra = store.filter((f) => !shipped.includes(f));
61
73
  const differing = [];
62
74
  for (const f of shipped.filter((x) => store.includes(x))) {
63
- const a = await read(join(repo, SHIPPED_SCHEMA_DIR, f));
64
- const b = await read(join(repo, STORE_SCHEMA_DIR, f));
75
+ const a = await read(join(repo, shippedDir, f));
76
+ const b = await read(join(repo, storeDir, f));
65
77
  if (a === null || b === null || !a.equals(b))
66
78
  differing.push(f);
67
79
  }
68
80
  return { shipped: true, missing, differing, extra };
69
81
  }
70
82
  /**
71
- * Replace the store's copy of the schema with the shipped one. Returns what changed, as a
83
+ * Replace the store's copy of a schema with the shipped one. Returns what changed, as a
72
84
  * comparison taken before the write; a store already current returns empty lists.
73
85
  */
74
- export async function writeStoreSchema(repo) {
75
- const before = await compareStoreSchema(repo);
86
+ export async function writeStoreSchema(repo, name = SCHEMA_NAME) {
87
+ const before = await compareStoreSchema(repo, name);
76
88
  if (!before.shipped)
77
89
  return before;
78
90
  if (before.missing.length + before.differing.length + before.extra.length === 0)
79
91
  return before;
80
- await rm(join(repo, STORE_SCHEMA_DIR), { recursive: true, force: true });
81
- await mkdir(join(repo, STORE_SCHEMA_DIR), { recursive: true });
82
- await cp(join(repo, SHIPPED_SCHEMA_DIR), join(repo, STORE_SCHEMA_DIR), { recursive: true });
92
+ await rm(join(repo, storeSchemaDir(name)), { recursive: true, force: true });
93
+ await mkdir(join(repo, storeSchemaDir(name)), { recursive: true });
94
+ await cp(join(repo, shippedSchemaDir(name)), join(repo, storeSchemaDir(name)), { recursive: true });
83
95
  return before;
84
96
  }
85
97
  //# sourceMappingURL=schema.js.map
@@ -0,0 +1,173 @@
1
+ // Aggregation-manifest loader per
2
+ // docs/method/methods/aggregation-manifest-schema.yaml.
3
+ //
4
+ // v1 supports `pull.kind: filesystem` only; the other kinds (git-ref,
5
+ // release-asset) are schema-reserved and reject with a
6
+ // clear "deferred" error so adopters get unambiguous feedback.
7
+ import { readFile } from "node:fs/promises";
8
+ import { isAbsolute, relative, resolve } from "node:path";
9
+ import { parse as parseYaml } from "yaml";
10
+ import { z } from "zod";
11
+ import { SCHEMA_COMPAT, formatSchemaVersionMismatch } from "./schema-compat.js";
12
+ import { isInRange } from "./manifest-loader.js";
13
+ export const DEFAULT_AGGREGATION_MANIFEST_REL = "orchestration/aggregation-manifest.yaml";
14
+ const PullSchema = z
15
+ .object({
16
+ kind: z.string(),
17
+ path: z.string().optional(),
18
+ })
19
+ .passthrough();
20
+ const SiblingSchema = z.object({
21
+ sdd_id: z.string().regex(/^[a-z][a-z0-9-]*$/, {
22
+ message: "sdd_id must match /^[a-z][a-z0-9-]*$/",
23
+ }),
24
+ pull: PullSchema,
25
+ description: z.string().optional(),
26
+ level: z.string().optional(),
27
+ });
28
+ /**
29
+ * An ecosystem member that is real and intended but has no SDD yet
30
+ * (aggregation-manifest-schema 1.1.0, per M-ADR 0195 §4.3).
31
+ *
32
+ * A SEPARATE list rather than a flag on `siblings[]`: `pull` is required on a sibling
33
+ * and a pending member has nothing to pull, and `siblings[]` must keep meaning exactly
34
+ * "members whose catalogues merge in" — adding skippable entries would give one list
35
+ * two meanings, which every existing reader would keep miscounting.
36
+ *
37
+ * `reason` is required and non-empty. A bare marker suppresses the build failure while
38
+ * saying nothing about why, losing the signal that a pending member is a WORK ITEM —
39
+ * the state this replaces.
40
+ */
41
+ const PendingMemberSchema = z.object({
42
+ sdd_id: z.string().regex(/^[a-z][a-z0-9-]*$/, {
43
+ message: "pending_members[].sdd_id must match /^[a-z][a-z0-9-]*$/",
44
+ }),
45
+ reason: z.string().min(1, {
46
+ message: "pending_members[].reason must be non-empty — a marker without a reason suppresses a failure without explaining it",
47
+ }),
48
+ blocked_on: z.string().optional(),
49
+ });
50
+ const SLUG = /^[a-z][a-z0-9-]*$/;
51
+ // M-ADR 0208 §4.3: `root_sdd_id` is canonical; `ecosystem_root` is its DEPRECATED alias
52
+ // (at organisation tier the old name called an organisation-root an "ecosystem root").
53
+ // Exactly one of the two must be present. Removed at 2.0.0.
54
+ const AggregationManifestSchema = z
55
+ .object({
56
+ schema_version: z.string(),
57
+ root_sdd_id: z.string().regex(SLUG).optional(),
58
+ ecosystem_root: z.string().regex(SLUG).optional(),
59
+ level: z.enum(["platform", "organisation"]).optional(),
60
+ siblings: z.array(SiblingSchema),
61
+ pending_members: z.array(PendingMemberSchema).optional(),
62
+ })
63
+ .refine((m) => (m.root_sdd_id !== undefined) !== (m.ecosystem_root !== undefined), {
64
+ message: "exactly one of `root_sdd_id` (canonical, M-ADR 0208 §4.3) or `ecosystem_root` (deprecated alias) must be present",
65
+ });
66
+ export async function loadAggregationManifest(manifestPath, options) {
67
+ let text;
68
+ try {
69
+ text = await readFile(manifestPath, "utf-8");
70
+ }
71
+ catch (err) {
72
+ if (err.code === "ENOENT") {
73
+ throw new Error(`Aggregation manifest not found at ${manifestPath}\n\n` +
74
+ `Ecosystem-root SDDs declare an aggregation manifest at the path\n` +
75
+ `recorded in .sdd/sdd-manifest.yaml#aggregation_manifest_path, or\n` +
76
+ `at ${DEFAULT_AGGREGATION_MANIFEST_REL} by default.\n` +
77
+ `Schema: docs/method/methods/aggregation-manifest-schema.yaml.`);
78
+ }
79
+ throw err;
80
+ }
81
+ let raw;
82
+ try {
83
+ raw = parseYaml(text);
84
+ }
85
+ catch (err) {
86
+ throw new Error(`failed to parse YAML at ${manifestPath}: ${err.message}`);
87
+ }
88
+ const parsed = AggregationManifestSchema.safeParse(raw);
89
+ if (!parsed.success) {
90
+ const issues = parsed.error.issues
91
+ .map((i) => ` - ${i.path.join(".") || "<root>"}: ${i.message}`)
92
+ .join("\n");
93
+ throw new Error(`invalid aggregation manifest at ${manifestPath}:\n${issues}`);
94
+ }
95
+ if (!isInRange(parsed.data.schema_version, SCHEMA_COMPAT.aggregation_manifest)) {
96
+ throw new Error(formatSchemaVersionMismatch({
97
+ schema: "aggregation_manifest",
98
+ readVersion: parsed.data.schema_version,
99
+ cliVersion: options.cliVersion,
100
+ sourcePath: manifestPath,
101
+ }));
102
+ }
103
+ // Duplicate sibling-id check.
104
+ const seen = new Set();
105
+ for (const s of parsed.data.siblings) {
106
+ if (seen.has(s.sdd_id)) {
107
+ throw new Error(`aggregation manifest has duplicate sibling sdd_id "${s.sdd_id}"\n` +
108
+ ` (${manifestPath})`);
109
+ }
110
+ seen.add(s.sdd_id);
111
+ }
112
+ // Resolve each sibling's pull. v1 supports `kind: filesystem` only.
113
+ const resolved = [];
114
+ for (const s of parsed.data.siblings) {
115
+ const kind = s.pull.kind;
116
+ if (kind !== "filesystem") {
117
+ throw new Error(`aggregation manifest: sibling "${s.sdd_id}" uses pull.kind="${kind}"\n` +
118
+ ` v1 supports filesystem only. The other kinds (git-ref, release-asset)\n` +
119
+ ` are schema-reserved and not yet implemented.\n` +
120
+ ` See: docs/method/methods/aggregation-manifest-schema.yaml#pull_kinds`);
121
+ }
122
+ const path = s.pull.path;
123
+ if (!path || typeof path !== "string") {
124
+ throw new Error(`aggregation manifest: sibling "${s.sdd_id}" pull.kind=filesystem requires \`path\``);
125
+ }
126
+ const cataloguesPath = isAbsolute(path)
127
+ ? path
128
+ : resolve(options.ecosystemRootPath, path);
129
+ // The sibling's repo root is the parent of `catalogues/`.
130
+ const sddRoot = resolve(cataloguesPath, "..");
131
+ // Portable form for the committed JSON snapshot: keep a relative source
132
+ // verbatim; anchor an absolute source to the ecosystem root so the emitted
133
+ // value never carries a machine-specific $HOME/checkout prefix.
134
+ const relativeCataloguesPath = isAbsolute(path)
135
+ ? relative(options.ecosystemRootPath, cataloguesPath)
136
+ : path;
137
+ resolved.push({
138
+ sddId: s.sdd_id,
139
+ description: s.description ?? "",
140
+ cataloguesPath,
141
+ relativeCataloguesPath,
142
+ sddRoot,
143
+ level: s.level,
144
+ });
145
+ }
146
+ // A member is pending authoring OR contributing catalogues, never both. Declaring
147
+ // both is a contradiction rather than something to merge, and silently preferring
148
+ // one reading would make the work queue untrustworthy — so it fails with the id.
149
+ const pendingMembers = [];
150
+ const siblingIds = new Set(resolved.map((r) => r.sddId));
151
+ for (const p of parsed.data.pending_members ?? []) {
152
+ if (siblingIds.has(p.sdd_id)) {
153
+ throw new Error(`aggregation manifest: "${p.sdd_id}" appears in BOTH siblings and pending_members.\n` +
154
+ ` A member is either pending authoring or contributing catalogues, not both.\n` +
155
+ ` Remove whichever declaration is stale (${parsed.data.root_sdd_id ?? parsed.data.ecosystem_root}).`);
156
+ }
157
+ pendingMembers.push({
158
+ sddId: p.sdd_id,
159
+ reason: p.reason,
160
+ ...(p.blocked_on !== undefined ? { blockedOn: p.blocked_on } : {}),
161
+ });
162
+ }
163
+ return {
164
+ schemaVersion: parsed.data.schema_version,
165
+ ecosystemRoot: (parsed.data.root_sdd_id ?? parsed.data.ecosystem_root),
166
+ level: parsed.data.level,
167
+ rootFieldDeprecated: parsed.data.root_sdd_id === undefined,
168
+ siblings: resolved,
169
+ pendingMembers,
170
+ sourcePath: manifestPath,
171
+ };
172
+ }
173
+ //# sourceMappingURL=aggregation-manifest-loader.js.map
@@ -90,7 +90,7 @@ export async function runCatalogueBuild(inputs, write) {
90
90
  const intendedRows = [];
91
91
  const rowsByCatalogue = modelFiles === null
92
92
  ? collected
93
- : overlayModel(schema, viewOf(modelFiles, modelProblems), collected, findings, intendedWrites(open, agreedOrder(open)), intendedRows);
93
+ : withC4Rollups(overlayModel(schema, viewOf(modelFiles, modelProblems), collected, findings, intendedWrites(open, agreedOrder(open)), intendedRows));
94
94
  for (const p of modelProblems)
95
95
  findings.add("convention", `${p} (M-ADR 0230)`);
96
96
  // 3a. Normalise every keyed catalogue to PK-unique rows. Multi-source edge
@@ -394,6 +394,9 @@ async function collectRows(schema, manifest, findings, sddPath, snapshotStore) {
394
394
  knownDomainIds,
395
395
  profile: manifest.profile,
396
396
  category: manifest.category,
397
+ domainExternal: schema.catalogues
398
+ .find((c) => c.name === "adrs")
399
+ ?.foreignKeys.some((fk) => fk.column === "domain_id" && (fk.external || (fk.externalForProfiles ?? []).includes(manifest.profile))) ?? false,
397
400
  });
398
401
  const supersedes = buildAdrSupersedes({ findings, adrs: adrResult.parsed });
399
402
  out.set("adr_supersedes", supersedes.rows);
@@ -1517,6 +1520,18 @@ async function collectRows(schema, manifest, findings, sddPath, snapshotStore) {
1517
1520
  });
1518
1521
  return out;
1519
1522
  }
1523
+ /**
1524
+ * The C4 roll-ups joined again over the catalogues the structural model settles (M-ADR 0159 §4.8, M-ADR 0230 §4.3).
1525
+ * collectRows joins them over the sources, which can state an element an agreed open change adds; the overlay holds
1526
+ * that element back until the change archives, and a roll-up row reaching it would be a current row referencing it.
1527
+ */
1528
+ function withC4Rollups(rows) {
1529
+ const c4Containers = rows.get("c4_containers") ?? [];
1530
+ const containerEntityRows = buildC4ContainerEntity({ serviceEntityRows: rows.get("service_entity") ?? [], c4Containers });
1531
+ rows.set("c4_container_entity", containerEntityRows);
1532
+ rows.set("c4_system_entity", buildC4SystemEntity({ containerEntityRows, c4Containers }));
1533
+ return rows;
1534
+ }
1520
1535
  const CONTEXT_CROSS_CUTTING = "cross-cutting";
1521
1536
  /**
1522
1537
  * Convention check (schema 2.21.0): where a screen carries `context` and the
@@ -88,7 +88,7 @@ export async function buildAdrs(inputs) {
88
88
  inputs.findings.add("convention", `${relPath}: **Effective:** is "${effectiveRaw}", neither "on acceptance" nor "on archive of <change>"; read as on acceptance (M-ADR 0230 §4.2)`);
89
89
  }
90
90
  const effectiveChange = onArchive?.[1] ?? "";
91
- if (scope === "domain" && !inputs.knownDomainIds.has(domainId)) {
91
+ if (scope === "domain" && !inputs.knownDomainIds.has(domainId) && !inputs.domainExternal) {
92
92
  inputs.findings.add("orphan", `${relPath}: ADR is in domain "${domainId}" but no docs/domains/${domainId}/ exists`);
93
93
  }
94
94
  // Method-scoped ADRs are cross-cutting governance and belong to no
@@ -58,6 +58,10 @@ const KIND_SPECS = [
58
58
  // name as their surface (M-ADR 0222 §4.4). Reachable from every path at once, unlike graphql above: discovered here, mapped from a
59
59
  // traceability manifest's `cli:` block (DECLARED_KINDS), loaded (CONTRACT_LIST_KEYS) and keyed by repo-config.
60
60
  { subdir: "cli", extensions: [".yaml", ".yml"], kind: "cli" },
61
+ // `ui` — an embeddable UI surface's manifest (schema 7.2.0): its custom elements and the messages it sends and
62
+ // receives. A UI-only repository that other applications load serves no other contract, so its behaviour
63
+ // baselines had nothing to name as their surface (M-ADR 0222 §4.4). Reachable from every path, as `cli` is.
64
+ { subdir: "ui", extensions: [".yaml", ".yml"], kind: "ui" },
61
65
  ];
62
66
  export async function buildContracts(inputs) {
63
67
  const rows = [];
@@ -318,6 +322,7 @@ const DECLARED_KINDS = {
318
322
  spicedb: "authz",
319
323
  graphql: "graphql",
320
324
  cli: "cli",
325
+ ui: "ui",
321
326
  };
322
327
  export function buildDeclaredExternalContracts(inputs) {
323
328
  const rows = [];
@@ -33,10 +33,11 @@ const LOC_REPORT_REL = join("docs", "platform", "reference", "loc-report.csv");
33
33
  const REPOS_DIR_REL = "repos";
34
34
  const REVERSE_METADATA_BASENAME = "reverse-metadata.yaml";
35
35
  // The contract kinds a repo-config `contracts:` block keys, under `contracts/<kind>/`. The sdd-index reader
36
- // accepts the same five; `spicedb` (an `authz` contract) was once missing here, and zod strips an unknown key,
36
+ // accepts the same six; `spicedb` (an `authz` contract) was once missing here, and zod strips an unknown key,
37
37
  // so a declared `.zed` schema vanished without a finding. `cli` (schema 4.1.0) declares the command manifest a
38
- // command-line repository serves. GraphQL is not keyed in repo-config: that is its own question, on this schema.
39
- export const REPO_CONFIG_CONTRACT_KINDS = ["openapi", "proto", "avro", "spicedb", "cli"];
38
+ // command-line repository serves, and `ui` (schema 7.2.0) the manifest of an embeddable UI surface a UI-only
39
+ // repository serves. GraphQL is not keyed in repo-config: that is its own question, on this schema.
40
+ export const REPO_CONFIG_CONTRACT_KINDS = ["openapi", "proto", "avro", "spicedb", "cli", "ui"];
40
41
  const ContractsBlockSchema = z
41
42
  .object({
42
43
  openapi: z.array(z.string()).optional(),
@@ -44,6 +45,7 @@ const ContractsBlockSchema = z
44
45
  avro: z.array(z.string()).optional(),
45
46
  spicedb: z.array(z.string()).optional(),
46
47
  cli: z.array(z.string()).optional(),
48
+ ui: z.array(z.string()).optional(),
47
49
  })
48
50
  .optional();
49
51
  const EventEntrySchema = z.object({
@@ -7,7 +7,7 @@
7
7
  # Emitted by sdd-cli's YAML emitter with no line folding; the three header scalars are quoted for the
8
8
  # textual readers downstream. The one deliberate difference from the authored form is schema_status:
9
9
  # the authored form says canonical; a projection never does.
10
- schema_version: "7.1.0"
10
+ schema_version: "7.2.0"
11
11
  schema_owner: "sdd-method"
12
12
  schema_status: "generated"
13
13
  enums:
@@ -364,6 +364,7 @@ enums:
364
364
  - value: rpc
365
365
  - value: authz
366
366
  - value: cli
367
+ - value: ui
367
368
  description: Contract kind. Bound at 3.13.0 from the column descriptions; measured against both reference estates before binding (M-ADR 0205 §4.4).
368
369
  contract_status:
369
370
  values:
@@ -1791,7 +1792,7 @@ catalogues:
1791
1792
  - name: kind
1792
1793
  type: string
1793
1794
  nullable: false
1794
- description: rest | graphql | rpc | event | authz | cli. rpc covers Proto/gRPC; event covers Avro schemas; authz covers authorization/policy schemas (SpiceDB .zed and equivalents), added because an adopter contract agreement gate certified .zed schemas as contracts while no kind could represent them; cli covers a command-line interface's command manifest (its commands, options and exit codes), added at 4.1.0 because a command-line tool often serves no REST, RPC, event or authorization contract, so its behaviour baselines had nothing to name as their surface.
1795
+ description: rest | graphql | rpc | event | authz | cli | ui. rpc covers Proto/gRPC; event covers Avro schemas; authz covers authorization/policy schemas (SpiceDB .zed and equivalents), added because an adopter contract agreement gate certified .zed schemas as contracts while no kind could represent them; cli covers a command-line interface's command manifest (its commands, options and exit codes), added at 4.1.0 because a command-line tool often serves no REST, RPC, event or authorization contract, so its behaviour baselines had nothing to name as their surface; ui covers an embeddable UI surface's manifest (its custom elements with their attributes, properties and emitted events, and the messages it sends and receives), added at 7.2.0 because a UI-only repository that other applications load serves no other contract, so its behaviour baselines had nothing to name as their surface.
1795
1796
  - name: version
1796
1797
  type: string
1797
1798
  nullable: true
@@ -2,7 +2,7 @@
2
2
  // M-ADR 0203 §4.4, the cut's acid test.
3
3
  //
4
4
  // From 3.0.0 a contract id carries its KIND component: `{domain}-{stem}-{kind}`
5
- // (kind ∈ rest | graphql | rpc | event | authz | cli). The kindless 2.x form made one
5
+ // (kind ∈ rest | graphql | rpc | event | authz | cli | ui). The kindless 2.x form made one
6
6
  // measured collision class unrepresentable: a service published as two contract
7
7
  // families (`calendar-service.yaml` + `calendar_service.proto`) minted one id,
8
8
  // costing a live estate one row and two edges permanently, and mislabelling
@@ -103,7 +103,12 @@ export function buildImpactPropagation(edges, semantics) {
103
103
  for (const [key, rec] of pairs) {
104
104
  const abOnly = [...rec.ab].filter((t) => !rec.ba.has(t));
105
105
  const baOnly = [...rec.ba].filter((t) => !rec.ab.has(t));
106
- if (abOnly.length > 0 && baOnly.length > 0) {
106
+ // Every opposing type a foreign key of ONE catalogue: an entity-FK step runs between a row and what it
107
+ // references, so such a pair is one row naming the same node in two of its columns, a self-reference (an
108
+ // entity related to itself), and the opposition is that row's own two ends meeting, not drift.
109
+ const catalogues = new Set([...abOnly, ...baOnly].map((t) => (t.includes(".") ? t.slice(0, t.indexOf(".")) : `\u0000${t}`)));
110
+ const selfReference = catalogues.size === 1 && !([...catalogues][0].startsWith("\u0000"));
111
+ if (abOnly.length > 0 && baOnly.length > 0 && !selfReference) {
107
112
  const [a, b] = key.split("␟");
108
113
  throw new Error(`impact: counter-directional reciprocal pair (M-ADR 0206 §4.4): ` +
109
114
  `${abOnly[0]} propagates ${a} -> ${b} while ${baOnly[0]} propagates ` +
@@ -48,6 +48,7 @@ export const CONTRACT_LIST_KEYS = [
48
48
  "spicedb",
49
49
  "graphql",
50
50
  "cli",
51
+ "ui",
51
52
  ];
52
53
  /**
53
54
  * Every contract path a contracts block declares, across all kinds and both
@@ -283,6 +284,7 @@ function extractContracts(raw) {
283
284
  const spicedb = arr("spicedb");
284
285
  const graphql = arr("graphql");
285
286
  const cli = arr("cli");
287
+ const ui = arr("ui");
286
288
  // `provenance` (3.5.0) MUST be extracted here or the builder that reads it
287
289
  // sees nothing — this function reconstructs the block key-by-key rather than
288
290
  // passing the parsed object through, which is precisely the shape the
@@ -300,6 +302,7 @@ function extractContracts(raw) {
300
302
  ...(spicedb ? { spicedb } : {}),
301
303
  ...(graphql ? { graphql } : {}),
302
304
  ...(cli ? { cli } : {}),
305
+ ...(ui ? { ui } : {}),
303
306
  ...(provenance ? { provenance } : {}),
304
307
  };
305
308
  }
@@ -116,7 +116,7 @@ async function collectReferences(repo) {
116
116
  const block = m.traceability.contracts;
117
117
  if (!block)
118
118
  continue;
119
- for (const list of [block.openapi, block.proto, block.avro, block.spicedb, block.graphql, block.cli]) {
119
+ for (const list of [block.openapi, block.proto, block.avro, block.spicedb, block.graphql, block.cli, block.ui]) {
120
120
  if (!Array.isArray(list))
121
121
  continue;
122
122
  for (const entry of list) {
@@ -201,6 +201,9 @@ export function inferKind(ref) {
201
201
  // repository whose own name ends in -cli stays rest.
202
202
  if (/(^|\/)cli\/.*\.ya?ml$/.test(ref))
203
203
  return "cli";
204
+ // A UI surface manifest lives under a `ui/` contracts folder, tested after rest for the same reason.
205
+ if (/(^|\/)ui\/.*\.ya?ml$/.test(ref))
206
+ return "ui";
204
207
  return undefined;
205
208
  }
206
209
  /** The SDD's attested external artefacts (`.sdd/contract-availability.yaml`); none when it has no manifest. */
@@ -44,7 +44,7 @@ async function readRepoConfigFor(sddRoot, manifestAbsPath) {
44
44
  path: manifestAbsPath,
45
45
  aggregates: [],
46
46
  internalDependencies: [],
47
- contracts: { openapi: [], proto: [], avro: [], spicedb: [], graphql: [], cli: [] },
47
+ contracts: { openapi: [], proto: [], avro: [], spicedb: [], graphql: [], cli: [], ui: [] },
48
48
  };
49
49
  if (!(await fileExists(manifestAbsPath)))
50
50
  return empty;
@@ -63,6 +63,7 @@ function legacyShape(node, sourcePath) {
63
63
  avro: [],
64
64
  spicedb: [],
65
65
  cli: [],
66
+ ui: [],
66
67
  };
67
68
  for (const ref of node.contracts)
68
69
  byKind[ref.kind].push(ref.path);
@@ -5,7 +5,7 @@
5
5
  import { access, mkdir, readFile, writeFile } from "node:fs/promises";
6
6
  import { dirname, join } from "node:path";
7
7
  import { parseDocument } from "yaml";
8
- import { SHIPPED_CONFIG_TEMPLATE, SHIPPED_SCHEMA_DIR, STORE_CONFIG, STORE_SCHEMA_DIR, writeStoreSchema } from "../behaviour-store/schema.js";
8
+ import { SHIPPED_CONFIG_TEMPLATE, SHIPPED_SCHEMA_DIR, STORE_CONFIG, writeStoreSchema, STORE_SCHEMAS, storeSchemaDir } from "../behaviour-store/schema.js";
9
9
  import { MANIFEST_REL_PATH } from "../catalogue/manifest-loader.js";
10
10
  export const STORE_KEEP_FILES = ["openspec/specs/.gitkeep", "openspec/changes/archive/.gitkeep", "docs/agreements/.gitkeep"];
11
11
  async function exists(p) {
@@ -53,8 +53,10 @@ export async function scaffoldBehaviourStore(repo) {
53
53
  await writeFile(join(repo, rel), "");
54
54
  filesWritten.push(rel);
55
55
  }
56
- const copied = await writeStoreSchema(repo);
57
- filesWritten.push(...copied.missing.map((f) => `${STORE_SCHEMA_DIR}/${f}`));
56
+ for (const name of STORE_SCHEMAS) {
57
+ const copied = await writeStoreSchema(repo, name);
58
+ filesWritten.push(...copied.missing.map((f) => `${storeSchemaDir(name)}/${f}`));
59
+ }
58
60
  return { filesWritten, sddId };
59
61
  }
60
62
  //# sourceMappingURL=behaviour-store.js.map
@@ -17,6 +17,7 @@ const CONTRACT_KINDS = [
17
17
  "avro",
18
18
  "spicedb",
19
19
  "cli",
20
+ "ui",
20
21
  ];
21
22
  export function readAllRepoConfigs(reader) {
22
23
  const services = [];
@@ -29,7 +29,7 @@ import { parse, parseDocument, stringify } from "yaml";
29
29
  // unrelated churn in lines the tool had no business touching.
30
30
  import { deltaPath } from "./diff.js";
31
31
  import { loadCanonicalSchema } from "../catalogue/schema-loader.js";
32
- import { SnapshotError, credentialNotice, exists, isIsoDate, readManifest, splitFolder, } from "./manifest.js";
32
+ import { SnapshotError, credentialNotice, exists, isIsoDate, parseSnapshotYaml, readManifest, splitFolder, } from "./manifest.js";
33
33
  export const REVERSE_STATUSES = ["classified", "reversed"];
34
34
  export const IMPACTS = ["none", "repo-minor", "repo-major", "product-major"];
35
35
  /**
@@ -179,7 +179,7 @@ export async function advance(inputs, write) {
179
179
  if (!(await exists(dp))) {
180
180
  throw new SnapshotError(`no ${dp}: the pin advances after the delta is recorded (run snapshot diff, then classify)`);
181
181
  }
182
- const delta = parse(await readFile(dp, "utf8"));
182
+ const delta = parseSnapshotYaml(await readFile(dp, "utf8"));
183
183
  const provenance = delta.t0_provenance?.result;
184
184
  const provenanceNote = provenance ? ` T0 provenance: ${provenance}${delta.t0_provenance?.waiver?.who ? ` (waiver: ${delta.t0_provenance.waiver.who})` : ""}.` : "";
185
185
  if (provenance && !/^pass$/.test(provenance)) {
@@ -215,8 +215,8 @@ export async function advance(inputs, write) {
215
215
  const hFile = join(dir, "reverse-history.yaml");
216
216
  const mFile = join(dir, "reverse-metadata.yaml");
217
217
  const metadataText = await readFile(mFile, "utf8");
218
- const metadata = parse(metadataText);
219
- let history = (await exists(hFile)) ? parse(await readFile(hFile, "utf8")) : { canonical_repo_key: key, history: [] };
218
+ const metadata = parseSnapshotYaml(metadataText);
219
+ let history = (await exists(hFile)) ? parseSnapshotYaml(await readFile(hFile, "utf8")) : { canonical_repo_key: key, history: [] };
220
220
  let migrated = false;
221
221
  if (Array.isArray(history.entries)) {
222
222
  if (!inputs.migrateHistory) {
@@ -315,6 +315,14 @@ export async function advance(inputs, write) {
315
315
  }
316
316
  // A classification is one of the bound vocabulary (M-ADR 0214 §4.5), read from the
317
317
  // schema rather than restated here: a typo is not a classification.
318
+ // Both effects are stated, `none` or the baseline change that carries it (delta-reverse.md §7): a refresh that
319
+ // changed nothing must read differently from one nobody checked.
320
+ const silent = mine
321
+ .filter((f) => f.classification !== null && f.classification !== undefined)
322
+ .flatMap((f) => ["structural_effect", "behavioural_effect"].filter((k) => typeof f[k] !== "string" || f[k].trim() === "").map((k) => `${f.id} ${k}`));
323
+ if (silent.length > 0) {
324
+ problems.push(`${key}: ${silent.join(", ")} not stated — each classified finding says none, or names the baseline change that carries it (delta-reverse.md §7)`);
325
+ }
318
326
  const outOfVocabulary = mine.filter((f) => !dispositions.has(String(f.classification)));
319
327
  if (outOfVocabulary.length > 0) {
320
328
  problems.push(`${key}: ${outOfVocabulary.map((f) => `${f.id} '${String(f.classification)}'`).join(", ")} — not a delta_disposition (${[...dispositions].join(", ")}; M-ADR 0214 §4.5)`);
@@ -371,7 +379,7 @@ export async function advance(inputs, write) {
371
379
  else {
372
380
  await writeFile(hFile, appendHistoryEntry(await readFile(hFile, "utf8"), p.entry));
373
381
  }
374
- const after = parse(await readFile(hFile, "utf8")).history;
382
+ const after = parseSnapshotYaml(await readFile(hFile, "utf8")).history;
375
383
  if (after.length !== before.length + 1 || !isDeepStrictEqual(after.slice(0, before.length), before)) {
376
384
  throw new Error(`${hFile}: post-write check failed — prior entries changed or not exactly one appended`);
377
385
  }
@@ -15,9 +15,9 @@
15
15
  // (`support-repo` → `ancillary`, delta-reverse.md §1) and left unset otherwise.
16
16
  import { mkdir, readdir, readFile, rename, stat, writeFile } from "node:fs/promises";
17
17
  import { join, relative, sep } from "node:path";
18
- import { parse, stringify } from "yaml";
18
+ import { stringify } from "yaml";
19
19
  import { setTopLevelScalars } from "./advance.js";
20
- import { SnapshotError, baselineIds, deriveKey, knownRepositoryIds, exists, isIsoDate, manifestPath, memberFolders, splitFolder, } from "./manifest.js";
20
+ import { SnapshotError, baselineIds, deriveKey, knownRepositoryIds, exists, isIsoDate, manifestPath, memberFolders, parseSnapshotYaml, splitFolder, } from "./manifest.js";
21
21
  const ROLE_BY_PROFILE = { "support-repo": "ancillary" };
22
22
  /** The same mapping by M-ADR 0213's scope, which replaces the per-repo profile: a support repo is ancillary. */
23
23
  const ROLE_BY_SCOPE = { support: "ancillary" };
@@ -32,7 +32,7 @@ async function pinsByFolder(sddPath) {
32
32
  if (!e.isDirectory() || !(await exists(file)))
33
33
  continue;
34
34
  const text = await readFile(file, "utf8");
35
- const meta = (parse(text) ?? {});
35
+ const meta = (parseSnapshotYaml(text) ?? {});
36
36
  const folder = String(meta.source_path ?? "").split("/").pop() || String(meta.exported_folder_name ?? "");
37
37
  if (folder)
38
38
  out.set(folder, { file, text, meta });
@@ -9,7 +9,7 @@
9
9
  import { createHash } from "node:crypto";
10
10
  import { readdir, readFile, stat } from "node:fs/promises";
11
11
  import { join, relative, sep } from "node:path";
12
- import { parse } from "yaml";
12
+ import { isScalar, parseDocument, visit } from "yaml";
13
13
  /** The line every later verb prints while a baseline carries unresolved credential findings. */
14
14
  export function credentialNotice(m) {
15
15
  const s = m.credential_scan;
@@ -60,6 +60,22 @@ export async function exists(p) {
60
60
  return false;
61
61
  }
62
62
  }
63
+ /**
64
+ * Parse snapshot YAML with every `*revision` value read as the text written. A revision is an identifier, and YAML
65
+ * 1.2 reads an unquoted all-digit one as an integer: `08985180587` became 8985180587, losing the leading zero,
66
+ * so a pin no longer matched its folder's `@<rev>`. Writers quote such a value; this covers files written by hand.
67
+ */
68
+ export function parseSnapshotYaml(text) {
69
+ const doc = parseDocument(text);
70
+ visit(doc, {
71
+ Pair(_, pair) {
72
+ if (isScalar(pair.key) && /(^|_)revision$/.test(String(pair.key.value)) && isScalar(pair.value) && typeof pair.value.value !== "string" && pair.value.value !== null) {
73
+ pair.value.value = pair.value.source ?? String(pair.value.value);
74
+ }
75
+ },
76
+ });
77
+ return doc.toJS();
78
+ }
63
79
  export function manifestPath(areaRoot, id) {
64
80
  return join(areaRoot, id, "snapshot.yaml");
65
81
  }
@@ -67,7 +83,7 @@ export async function readManifest(areaRoot, id) {
67
83
  const p = manifestPath(areaRoot, id);
68
84
  if (!(await exists(p)))
69
85
  throw new SnapshotError(`no manifest at ${p}`);
70
- const raw = parse(await readFile(p, "utf8"));
86
+ const raw = parseSnapshotYaml(await readFile(p, "utf8"));
71
87
  if (raw === null || typeof raw !== "object")
72
88
  throw new SnapshotError(`${p}: not a mapping`);
73
89
  const problems = [];
@@ -5,6 +5,7 @@
5
5
  // openspec/changes/<change>/structure/none.yaml a change's typed claim of no structural effect
6
6
  // An element is a catalogue row of a `structure` catalogue, keyed by its immutable id. Its revision is the
7
7
  // SHA-256 of its canonical JSON, so two readers agree on it without agreeing on anything else.
8
+ import { contractMoves } from "../behaviour-store/archive.js";
8
9
  import { createHash } from "node:crypto";
9
10
  import { readdir, readFile } from "node:fs/promises";
10
11
  import { join } from "node:path";
@@ -193,9 +194,12 @@ export async function loadChangeStructure(repo, change, problems) {
193
194
  removed.push({ catalogue: r.catalogue, id: r.id, reason: typeof reason === "string" ? reason : "" });
194
195
  }
195
196
  for (const k of Object.keys(m)) {
196
- if (!["schema_version", "partition", "depends_on", "ADDED", "MODIFIED", "REMOVED", "base"].includes(k))
197
+ if (!["schema_version", "partition", "depends_on", "ADDED", "MODIFIED", "REMOVED", "base", "moved"].includes(k))
197
198
  problems.push(`${file}: unknown key ${k}`);
198
199
  }
200
+ const moved = contractMoves(m["moved"]);
201
+ for (const p of moved.problems)
202
+ problems.push(`${file}: ${p}`);
199
203
  deltas.push({
200
204
  change,
201
205
  partition,
@@ -205,6 +209,7 @@ export async function loadChangeStructure(repo, change, problems) {
205
209
  modified: readElements(m["MODIFIED"], `${file}: MODIFIED`, partition, file, problems),
206
210
  removed,
207
211
  base,
212
+ ...(moved.moves.length > 0 ? { moved: moved.moves } : {}),
208
213
  });
209
214
  }
210
215
  return { change, deltas, noOp, present: true };
@@ -0,0 +1,121 @@
1
+ // A removed id against what references it outside the view (M-ADR 0230 §4.3, §4.11 negative proof 1: "a removal
2
+ // against a reference to it"). The view's own references are checked by validateView; these are the rest:
3
+ // this SDD its catalogues built from its records, not its model: an archived change's work orders and declared
4
+ // assertions name repositories, and a row whose target is removed drops from the next build
5
+ // members the catalogues of the SDDs its aggregation manifest names, where they are checked out: a member's row
6
+ // naming the removed id, and no row of its own holding it, dangles in the aggregate
7
+ // A rename is not a removal: ids never change, so a rename keeps the id and modifies the element's name.
8
+ import { access, readFile } from "node:fs/promises";
9
+ import { join } from "node:path";
10
+ import { parseDocument } from "yaml";
11
+ import { loadAggregationManifest, DEFAULT_AGGREGATION_MANIFEST_REL } from "../catalogue/aggregation-manifest-loader.js";
12
+ import { CLI_VERSION } from "../catalogue/cli-version.js";
13
+ import { parseCatalogueCsv } from "../catalogue/csv-reader.js";
14
+ async function exists(p) {
15
+ try {
16
+ await access(p);
17
+ return true;
18
+ }
19
+ catch {
20
+ return false;
21
+ }
22
+ }
23
+ /** A catalogue directory's rows, read once each. */
24
+ function reader(dir) {
25
+ const cache = new Map();
26
+ return async (catalogue) => {
27
+ if (!cache.has(catalogue)) {
28
+ let rows = null;
29
+ try {
30
+ rows = parseCatalogueCsv(await readFile(join(dir, `${catalogue}.csv`), "utf8")).rows;
31
+ }
32
+ catch {
33
+ rows = null;
34
+ }
35
+ cache.set(catalogue, rows);
36
+ }
37
+ return cache.get(catalogue) ?? null;
38
+ };
39
+ }
40
+ /** `<catalogue>.<column> (<n>)` for each foreign key into `<target>.id` whose rows name `id`. */
41
+ async function referencesTo(schema, rows, target, id, include) {
42
+ const out = [];
43
+ for (const c of schema.catalogues) {
44
+ if (!include(c.name))
45
+ continue;
46
+ for (const fk of c.foreignKeys) {
47
+ if (fk.references !== `${target}.id`)
48
+ continue;
49
+ const rs = await rows(c.name);
50
+ const n = rs?.filter((r) => r[fk.column] === id).length ?? 0;
51
+ if (n > 0)
52
+ out.push(`${c.name}.${fk.column} (${n})`);
53
+ }
54
+ }
55
+ return out;
56
+ }
57
+ /** The aggregation manifest's path: .sdd/sdd-manifest.yaml#aggregation_manifest_path, or the default. */
58
+ async function manifestPath(repo) {
59
+ try {
60
+ const m = parseDocument(await readFile(join(repo, ".sdd", "sdd-manifest.yaml"), "utf8")).toJS();
61
+ if (typeof m?.aggregation_manifest_path === "string" && m.aggregation_manifest_path !== "")
62
+ return join(repo, m.aggregation_manifest_path);
63
+ }
64
+ catch {
65
+ // the manifest check reports an unparseable manifest
66
+ }
67
+ return join(repo, DEFAULT_AGGREGATION_MANIFEST_REL);
68
+ }
69
+ /**
70
+ * Each open change's removals against their references outside the view. Returns the findings, one per removal with
71
+ * references, and the lines saying what was not looked at.
72
+ */
73
+ export async function checkRemovals(repo, schema, structures) {
74
+ const findings = [];
75
+ const lines = [];
76
+ const removals = structures.flatMap((s) => s.deltas.flatMap((d) => d.removed.map((r) => ({ change: s.change, file: d.file, ...r }))));
77
+ if (removals.length === 0)
78
+ return { findings, lines };
79
+ const fromModel = new Set(schema.catalogues.filter((c) => c.sourceOfTruth === "structure").map((c) => c.name));
80
+ const own = reader(join(repo, "catalogues"));
81
+ const members = [];
82
+ const path = await manifestPath(repo);
83
+ if (await exists(path)) {
84
+ try {
85
+ const manifest = await loadAggregationManifest(path, { cliVersion: CLI_VERSION, ecosystemRootPath: repo });
86
+ const absent = [];
87
+ for (const s of manifest.siblings) {
88
+ if (await exists(s.cataloguesPath))
89
+ members.push({ sddId: s.sddId, rows: reader(s.cataloguesPath) });
90
+ else
91
+ absent.push(s.sddId);
92
+ }
93
+ if (absent.length > 0)
94
+ lines.push(`removed ids: ${absent.length} of ${manifest.siblings.length} member SDD(s) not checked, no checkout of their catalogues (${absent.join(", ")})`);
95
+ }
96
+ catch (e) {
97
+ lines.push(`removed ids: member SDDs not checked (${String(e.message).split("\n")[0]})`);
98
+ }
99
+ }
100
+ for (const r of removals) {
101
+ const where = [];
102
+ const mine = await referencesTo(schema, own, r.catalogue, r.id, (c) => !fromModel.has(c));
103
+ if (mine.length > 0)
104
+ where.push(`this SDD's ${mine.join(", ")}`);
105
+ for (const m of members) {
106
+ // A member that holds the id itself resolves its references to its own row.
107
+ if ((await m.rows(r.catalogue))?.some((row) => row.id === r.id))
108
+ continue;
109
+ const theirs = await referencesTo(schema, m.rows, r.catalogue, r.id, () => true);
110
+ if (theirs.length > 0)
111
+ where.push(`${m.sddId}'s ${theirs.join(", ")}`);
112
+ }
113
+ if (where.length === 0)
114
+ continue;
115
+ findings.push(`${r.file}: removes ${r.catalogue}/${r.id}, which rows outside the model still reference: ${where.join("; ")}. ` +
116
+ `A rename keeps the id and modifies the element's name (ids never change, M-ADR 0230 §4.3); a removal needs ` +
117
+ `those references gone, or each row naming it drops from the next build and dangles in the aggregate`);
118
+ }
119
+ return { findings, lines };
120
+ }
121
+ //# sourceMappingURL=removals.js.map
@@ -34,13 +34,17 @@
34
34
  // change was withdrawn or replaced (M-ADR 0222 §4.6).
35
35
  // 5. surfaces: every archived baseline's evidence lists, under **Surfaces:**, each contract the
36
36
  // repositories it was observed in serve, as covered at transport or excluded with a
37
- // reason, and the list is checked against the catalogue (catalogues/repo_contract.csv),
38
- // never against itself (M-ADR 0222 §4.3, §4.4). A list the catalogue gives nothing to
37
+ // reason, and the list is checked against the catalogue (catalogues/repo_contract.csv, less
38
+ // a contract service_contract records the repository's services consuming and none exposing:
39
+ // repo_contract has no direction), never against itself (M-ADR 0222 §4.3, §4.4). A list the catalogue gives nothing to
39
40
  // check against (no Observed in line, or a repository with no catalogue contracts) fails:
40
41
  // a cross-check of zero contracts is unresolved, not a pass.
41
42
  // A baseline whose every requirement has since left the living specs (removed, or its
42
43
  // spec retired) is history: its list is not checked, so a retired surface can drop its
43
- // contract (M-ADR 0222 §4.4).
44
+ // contract (M-ADR 0222 §4.4). A baseline archived where the SDD ships record schema 1.4.0
45
+ // or later records its gate's PASS (**Baseline gate:**), so `change archive` refuses one
46
+ // that has not passed (M-ADR 0222 §4.3). An open baseline is asked these questions, and
47
+ // what it may change and which features key it, as warnings while it can still be fixed.
44
48
  // 7. features: every living spec is keyed by a catalogue feature (M-ADR 0221 §4.7). A spec keyed by no
45
49
  // feature is dropped from the catalogue by the requirements builder, which reports it only as a
46
50
  // build finding; this check makes it a failure, so a shipped requirement cannot vanish from the
@@ -69,11 +73,11 @@ import { tmpdir } from "node:os";
69
73
  import { basename, join } from "node:path";
70
74
  import { promisify } from "node:util";
71
75
  import { parseDocument } from "yaml";
72
- import { observedIn, orderArchives, parseRequirements, parseSurfaces, renamedFrom, renamePairs, resolveImplements, surfaceMatches } from "../behaviour-store/archive.js";
76
+ import { contractMoves, observedIn, orderArchives, parseRequirements, parseSurfaces, renamedFrom, renamePairs, resolveImplements, surfaceMatches } from "../behaviour-store/archive.js";
73
77
  import { parseCatalogueCsv } from "../catalogue/csv-reader.js";
74
78
  import { boundFile, conditionProblems, structuralFile, isCommit, normaliseBound, requirementBlock, sameCommit, scenarioKind, taskGroups, testPlanHeader, testPlanMapping, verdictLine, versionAtLeast } from "./agreement-evidence.js";
75
79
  import { ENFORCED as STRUCTURE_ENFORCED } from "./structural-model.js";
76
- import { compareStoreSchema, SCHEMA_NAME, SHIPPED_SCHEMA_DIR, STORE_CONFIG, STORE_SCHEMA_DIR, storeSchemaName } from "../behaviour-store/schema.js";
80
+ import { BASELINE_SCHEMA_NAME, compareStoreSchema, SCHEMA_NAME, SHIPPED_SCHEMA_DIR, shippedSchemaDir, STORE_CONFIG, STORE_SCHEMA_DIR, storeSchemaDir, storeSchemaName } from "../behaviour-store/schema.js";
77
81
  const execFileAsync = promisify(execFile);
78
82
  /** The OpenSpec release the method depends on (M-ADR 0223 ruling 2). The module installs exactly this. */
79
83
  export const OPENSPEC_VERSION = "1.14.3";
@@ -86,6 +90,8 @@ const COMMIT_RE = /^[0-9a-f]{7,40}$/;
86
90
  // OpenSpec counts a task done when its box holds only `x`, either case, any spacing.
87
91
  const TASK_DONE_RE = /^\s*-\s*\[\s*[xX]\s*\]/m;
88
92
  const TARGET_HEADING_RE = /^##\s+(MODIFIED|REMOVED|RENAMED)\s+Requirements\b/m;
93
+ // A tasks.md group headed by one word, `## <n>. <word>`, is that repository's work order (taskGroups).
94
+ const GROUP_REPO = (repo) => `tasks.md has a work order for ${repo} and the record's work_orders do not; its evidence cannot be checked (a group heading of one word, \`## <n>. ${repo}\`, names the group's repository)`;
89
95
  async function exists(p) {
90
96
  try {
91
97
  await access(p);
@@ -516,6 +522,14 @@ export async function checkBehaviourStore(repo, options = {}) {
516
522
  }
517
523
  }
518
524
  }
525
+ // The archive check reads a task group's repository from its heading; once the record declares work orders, a
526
+ // heading it would read as a repository the record lacks fails here, while the change can still be amended.
527
+ if (versionAtLeast(record?.schemaVersion, "1.1.0") && record?.status === "agreed" && record.workOrders.length > 0 && tasks !== null) {
528
+ const recorded = new Set(record.workOrders.map((w) => w.repo));
529
+ for (const g of taskGroups(tasks))
530
+ if (!recorded.has(g.repo))
531
+ failures.push(`${record.file}: ${GROUP_REPO(g.repo)}`);
532
+ }
519
533
  // A test plan's kinds (M-ADR 0223 §4.4 as amended): changed and repaired scenarios go red then green, carried ones stay green.
520
534
  if (versionAtLeast(record?.schemaVersion, "1.1.0") && record?.status === "agreed") {
521
535
  const plan = await readText(join(repo, dir, "test-plan.md"));
@@ -717,7 +731,7 @@ export async function checkBehaviourStore(repo, options = {}) {
717
731
  const recorded = new Set(record.workOrders.map((w) => w.repo));
718
732
  for (const g of groups)
719
733
  if (!recorded.has(g.repo))
720
- failures.push(`${record.file}: tasks.md has a work order for ${g.repo} and the record's work_orders do not; its evidence cannot be checked`);
734
+ failures.push(`${record.file}: ${GROUP_REPO(g.repo)}`);
721
735
  // Requirement names are unique within a feature, not across a change: a work order names `<feature>::<name>`,
722
736
  // or a bare name only one feature in the change has (schema 5.0.0). An ambiguous bare name implements nothing.
723
737
  const built = [];
@@ -818,8 +832,107 @@ export async function checkBehaviourStore(repo, options = {}) {
818
832
  }
819
833
  const orderDecides = (name, key) => [...(peers.get(name) ?? [])].some((p) => p !== name && touches.get(p)?.has(key));
820
834
  let scopeUnresolved = 0;
821
- const servedBy = baselines > 0 ? await catalogueContracts(repo) : null;
835
+ const openBaselines = [];
836
+ for (const change of open)
837
+ if (await exists(join(repo, STORE_DIR, "changes", change, "baseline-evidence.md")))
838
+ openBaselines.push(change);
839
+ const servedBy = baselines + openBaselines.length > 0 ? await catalogueContracts(repo) : null;
822
840
  let surfacesChecked = 0;
841
+ // The contract moves each archived change records, in archive order. A contract's id derives from its path, so a
842
+ // move changes it; an archived baseline's list names the contract as it was, and is resolved through the moves
843
+ // archived after it rather than edited (M-ADR 0222 §4.4). A surface that vanished with no move still fails.
844
+ const movesAt = [];
845
+ for (const name of order) {
846
+ const found = [];
847
+ let names = [];
848
+ try {
849
+ names = (await readdir(join(repo, archiveRel, name, "structure"))).filter((f) => f.endsWith(".yaml")).sort();
850
+ }
851
+ catch {
852
+ names = [];
853
+ }
854
+ for (const f of names) {
855
+ try {
856
+ found.push(...contractMoves(parseDocument((await readText(join(repo, archiveRel, name, "structure", f))) ?? "").toJS()?.["moved"]).moves);
857
+ }
858
+ catch {
859
+ // the structural-model check reports an unreadable delta
860
+ }
861
+ }
862
+ movesAt.push(found);
863
+ }
864
+ // An open change's moves count too, after every archive: the catalogue it is built with already holds the new
865
+ // identity, so the lists that named the old one follow the move while it is open, not only once it archives.
866
+ const openMoves = [];
867
+ for (const change of open) {
868
+ let names = [];
869
+ try {
870
+ names = (await readdir(join(repo, STORE_DIR, "changes", change, "structure"))).filter((f) => f.endsWith(".yaml")).sort();
871
+ }
872
+ catch {
873
+ names = [];
874
+ }
875
+ for (const f of names) {
876
+ try {
877
+ openMoves.push(...contractMoves(parseDocument((await readText(join(repo, STORE_DIR, "changes", change, "structure", f))) ?? "").toJS()?.["moved"]).moves);
878
+ }
879
+ catch {
880
+ // the structural-model check reports an unreadable delta
881
+ }
882
+ }
883
+ }
884
+ /** The identities a contract had before the moves archived after position `after` (-1: every archived move). */
885
+ const formerIdentities = (id, after) => {
886
+ const back = new Map();
887
+ for (const m of [...movesAt.slice(after + 1).flat(), ...openMoves])
888
+ back.set(m.to, [...(back.get(m.to) ?? []), m]);
889
+ const out = [];
890
+ const seen = new Set([id]);
891
+ const queue = [id];
892
+ while (queue.length > 0) {
893
+ for (const m of back.get(queue.shift()) ?? []) {
894
+ if (seen.has(m.from))
895
+ continue;
896
+ seen.add(m.from);
897
+ out.push({ id: m.from, path: m.fromPath ?? "" });
898
+ queue.push(m.from);
899
+ }
900
+ }
901
+ return out;
902
+ };
903
+ /** What is wrong with a baseline's Surfaces list against the catalogue (M-ADR 0222 §4.3, §4.4). */
904
+ const surfaceProblems = async (dir, evidence, after = -1) => {
905
+ const out = [];
906
+ const parsed = parseSurfaces(evidence);
907
+ if (!parsed.present)
908
+ out.push(`${dir}/baseline-evidence.md: no **Surfaces:** list; name every contract the observed repositories serve, covered at transport or excluded with a reason`);
909
+ for (const p of parsed.problems)
910
+ out.push(`${dir}/baseline-evidence.md: Surfaces: ${p}`);
911
+ if (!parsed.present)
912
+ return out;
913
+ if (servedBy === null) {
914
+ out.push(`${dir}/baseline-evidence.md: Surfaces cannot be cross-checked: catalogues/repo_contract.csv or catalogues/contracts.csv is missing; build the catalogue`);
915
+ return out;
916
+ }
917
+ const repos = observedIn(evidence);
918
+ if (repos.length === 0)
919
+ out.push(`${dir}/baseline-evidence.md: Surfaces cannot be cross-checked: no **Observed in:** line names a repository`);
920
+ for (const r of repos) {
921
+ const known = servedBy.get(r);
922
+ if ((known?.served ?? []).length === 0) {
923
+ const consumed = known?.consumed ? ` (it consumes ${known.consumed}, which a baseline does not list)` : "";
924
+ out.push(`${dir}/baseline-evidence.md: Surfaces cannot be cross-checked: the catalogue records no contract that ${r} serves${consumed}; declare the contracts it serves in its repo-config`);
925
+ }
926
+ for (const c of known?.served ?? []) {
927
+ surfacesChecked++;
928
+ const former = formerIdentities(c.id, after);
929
+ if (!parsed.entries.some((e) => surfaceMatches(e.contract, c.id, c.specPath) || former.some((f) => surfaceMatches(e.contract, f.id, f.path)))) {
930
+ out.push(`${dir}/baseline-evidence.md: Surfaces does not list ${c.specPath || c.id} (${c.id}), which ${r} serves (catalogues/repo_contract.csv, less what service_contract records it only consuming); list it as covered at transport or excluded with a reason`);
931
+ }
932
+ }
933
+ }
934
+ return out;
935
+ };
823
936
  // A baseline whose every requirement has since left the living specs (removed, or its spec retired) records a
824
937
  // surface that no longer exists; its Surfaces list is history, not checked against today's catalogue, or a
825
938
  // retired surface could never drop its contract.
@@ -831,33 +944,23 @@ export async function checkBehaviourStore(repo, options = {}) {
831
944
  const evidence = await readText(join(repo, dir, "baseline-evidence.md"));
832
945
  const isBaseline = evidence !== null;
833
946
  if (isBaseline) {
834
- const parsed = parseSurfaces(evidence);
835
- if (!parsed.present)
836
- failures.push(`${dir}/baseline-evidence.md: no **Surfaces:** list; name every contract the observed repositories serve, covered at transport or excluded with a reason`);
837
- for (const p of parsed.problems)
838
- failures.push(`${dir}/baseline-evidence.md: Surfaces: ${p}`);
839
- if (parsed.present && historical.has(name)) {
840
- // history: see historicalBaselines
947
+ if (historical.has(name)) {
948
+ // history: see historicalBaselines; its gate and Surfaces list are not checked
949
+ const parsed = parseSurfaces(evidence);
950
+ if (!parsed.present)
951
+ failures.push(`${dir}/baseline-evidence.md: no **Surfaces:** list; name every contract the observed repositories serve, covered at transport or excluded with a reason`);
952
+ for (const p of parsed.problems)
953
+ failures.push(`${dir}/baseline-evidence.md: Surfaces: ${p}`);
841
954
  }
842
- else if (parsed.present) {
843
- if (servedBy === null) {
844
- failures.push(`${dir}/baseline-evidence.md: Surfaces cannot be cross-checked: catalogues/repo_contract.csv or catalogues/contracts.csv is missing; build the catalogue`);
845
- }
846
- else {
847
- const repos = observedIn(evidence);
848
- if (repos.length === 0)
849
- failures.push(`${dir}/baseline-evidence.md: Surfaces cannot be cross-checked: no **Observed in:** line names a repository`);
850
- for (const r of repos) {
851
- if ((servedBy.get(r) ?? []).length === 0) {
852
- failures.push(`${dir}/baseline-evidence.md: Surfaces cannot be cross-checked: catalogues/repo_contract.csv records no contract for ${r}; declare the contracts it serves in its repo-config`);
853
- }
854
- for (const c of servedBy.get(r) ?? []) {
855
- surfacesChecked++;
856
- if (!parsed.entries.some((e) => surfaceMatches(e.contract, c.id, c.specPath))) {
857
- failures.push(`${dir}/baseline-evidence.md: Surfaces does not list ${c.specPath || c.id} (${c.id}), which ${r} serves (catalogues/repo_contract.csv); list it as covered at transport or excluded with a reason`);
858
- }
859
- }
860
- }
955
+ else {
956
+ failures.push(...(await surfaceProblems(dir, evidence, order.indexOf(name))));
957
+ // The gate's outcome is recorded where the SDD ships record schema 1.4.0 or later when the baseline archives;
958
+ // a baseline archived earlier carries none and is not held to it.
959
+ const archivedAt = await integratedAt(repo, dir);
960
+ if (versionAtLeast(await shippedRecordSchema(repo, archivedAt), "1.4.0")) {
961
+ const gate = baselineGate(evidence);
962
+ if (gate !== "PASS")
963
+ failures.push(`${dir}/baseline-evidence.md: archived ${gate === null ? "with no **Baseline gate:** line" : `with **Baseline gate:** ${gate}`}; a baseline archives once its gate passes: every requirement cites its evidence, characterization tests exist where the code has none, and every covered surface is evidenced at its transport (M-ADR 0222 §4.3)`);
861
964
  }
862
965
  }
863
966
  }
@@ -892,8 +995,34 @@ export async function checkBehaviourStore(repo, options = {}) {
892
995
  lastTouch.delete(`${feature}::${from}`);
893
996
  }
894
997
  }
895
- const unseen = servedBy === null ? "" : `; repositories with no catalogue contracts: ${[...new Set(await observedInAll(repo, archiveRel, order))].filter((r) => (servedBy.get(r) ?? []).length === 0).join(", ") || "none"}`;
896
- lines.push(baselines === 0 ? "surfaces: vacuous (no baseline)" : `surfaces: ${baselines} baseline(s), ${surfacesChecked} served contract(s) checked against catalogues/repo_contract.csv${unseen}${historical.size ? `; ${historical.size} historical baseline(s) not checked (every requirement they baselined has left the living specs)` : ""}`);
998
+ // An open baseline is asked the archive's questions while it can still be fixed: its Surfaces list, what it may
999
+ // change given the lineage so far, the features its deltas are keyed by, and its gate. Warnings, since it is in
1000
+ // progress; `change archive` refuses what is still wrong when it archives.
1001
+ const featureIds = await readText(join(repo, "catalogues", "features.csv")).then((t) => (t === null ? null : new Set(parseCatalogueCsv(t).rows.map((r) => r["id"] ?? ""))));
1002
+ for (const change of openBaselines) {
1003
+ const dir = `${STORE_DIR}/changes/${change}`;
1004
+ const evidence = (await readText(join(repo, dir, "baseline-evidence.md"))) ?? "";
1005
+ const found = [...(await surfaceProblems(dir, evidence))];
1006
+ for (const feature of await dirs(join(repo, dir, "specs"))) {
1007
+ if (featureIds !== null && !featureIds.has(feature))
1008
+ found.push(`${dir}/specs/${feature}: no catalogue feature has the id "${feature}"; give the behaviour a feature first (M-ADR 0222 §4.4)`);
1009
+ const text = (await readText(join(repo, dir, "specs", feature, "spec.md"))) ?? "";
1010
+ for (const target of [...parseRequirements(text).filter((r) => r.operation === "MODIFIED" || r.operation === "REMOVED").map((r) => r.name), ...renamedFrom(text)]) {
1011
+ scopeChecked++;
1012
+ const last = lastTouch.get(`${feature}::${target}`);
1013
+ if (last !== "baseline")
1014
+ found.push(`${dir}: a baseline changes "${target}" (${feature}), which ${last === "change" ? "a judged change last shipped" : "no earlier archived change admitted"}; a baseline may remove or modify only what a baseline admitted (M-ADR 0222 §4.4)`);
1015
+ }
1016
+ }
1017
+ const gate = baselineGate(evidence);
1018
+ if (gate !== "PASS")
1019
+ found.push(`${dir}/baseline-evidence.md: ${gate === null ? "no **Baseline gate:** line yet" : `**Baseline gate:** ${gate}`}; it archives once the gate passes (M-ADR 0222 §4.3)`);
1020
+ warnings.push(...found.map((w) => `open baseline: ${w}`));
1021
+ }
1022
+ if (openBaselines.length > 0)
1023
+ lines.push(`open baselines: ${openBaselines.length}, asked the archive's questions as warnings`);
1024
+ const unseen = servedBy === null ? "" : `; repositories with no catalogue contracts: ${[...new Set(await observedInAll(repo, archiveRel, order))].filter((r) => (servedBy.get(r)?.served ?? []).length === 0).join(", ") || "none"}`;
1025
+ lines.push(baselines === 0 ? "surfaces: vacuous (no baseline)" : `surfaces: ${baselines} baseline(s), ${surfacesChecked} served contract(s) checked against catalogues/repo_contract.csv less what service_contract records only consumed${unseen}${historical.size ? `; ${historical.size} historical baseline(s) not checked (every requirement they baselined has left the living specs)` : ""}`);
897
1026
  lines.push(scopeChecked === 0 ? "scope: vacuous (no baseline removes, modifies or renames a requirement)" : `scope: ${scopeChecked} requirement(s) a baseline removes, modifies or renames, checked against their lineage`);
898
1027
  if (!history && archived.length > 0)
899
1028
  lines.push("order: no git history (not a git work tree, or git log failed), so same-day archives cannot be ordered");
@@ -944,6 +1073,29 @@ export async function checkBehaviourStore(repo, options = {}) {
944
1073
  lines.push(drift === 0 ? `schema: ${SCHEMA_NAME}, identical to the shipped copy` : `schema: ${SCHEMA_NAME}, ${drift} file(s) differ from the shipped copy`);
945
1074
  }
946
1075
  }
1076
+ // The baseline schema: checked as sdd-change is where the bundle ships it (a bundle from before it shipped has none),
1077
+ // and an open baseline selects it rather than sdd-change, whose Gate 0, test plan and agreement a baseline lacks.
1078
+ if (selected === SCHEMA_NAME) {
1079
+ const b = await compareStoreSchema(repo, BASELINE_SCHEMA_NAME);
1080
+ if (b.shipped) {
1081
+ const fix = "sdd-cli sync refreshes it; put adopter guidance in the store's config.yaml rules, not in the schema";
1082
+ for (const f of b.missing)
1083
+ failures.push(`${storeSchemaDir(BASELINE_SCHEMA_NAME)}/${f}: missing from the store's copy of the schema; ${fix}`);
1084
+ for (const f of b.differing)
1085
+ failures.push(`${storeSchemaDir(BASELINE_SCHEMA_NAME)}/${f}: differs from the shipped ${shippedSchemaDir(BASELINE_SCHEMA_NAME)}/${f}; ${fix}`);
1086
+ for (const f of b.extra)
1087
+ failures.push(`${storeSchemaDir(BASELINE_SCHEMA_NAME)}/${f}: not in the shipped schema; ${fix}`);
1088
+ const drift = b.missing.length + b.differing.length + b.extra.length;
1089
+ lines.push(drift === 0 ? `schema: ${BASELINE_SCHEMA_NAME}, identical to the shipped copy` : `schema: ${BASELINE_SCHEMA_NAME}, ${drift} file(s) differ from the shipped copy`);
1090
+ for (const change of openBaselines) {
1091
+ const meta = await readText(join(repo, STORE_DIR, "changes", change, ".openspec.yaml"));
1092
+ const chosen = meta === null ? null : parseDocument(meta).toJS()?.schema;
1093
+ if (chosen !== BASELINE_SCHEMA_NAME) {
1094
+ warnings.push(`open baseline: ${STORE_DIR}/changes/${change}/.openspec.yaml selects ${chosen === null || chosen === undefined ? "no schema" : `"${String(chosen)}"`}; a baseline selects ${BASELINE_SCHEMA_NAME} (schema: ${BASELINE_SCHEMA_NAME}), whose artifacts are a baseline's rather than a change's Gate 0, test plan and agreement`);
1095
+ }
1096
+ }
1097
+ }
1098
+ }
947
1099
  const population = recordFiles.length + open.length + archived.length;
948
1100
  const body = lines.map((l) => ` ${l}`).join("\n") + "\n";
949
1101
  if (failures.length > 0) {
@@ -1002,25 +1154,47 @@ async function historicalBaselines(repo, archiveRel, order) {
1002
1154
  });
1003
1155
  return out;
1004
1156
  }
1005
- /** Per repository, the contracts the catalogue says it serves (repo_contract ⋈ contracts.spec_path); null when either file is missing. */
1157
+ /**
1158
+ * Per repository, the contracts the catalogue says it serves, with how many it only consumes; null when repo_contract or
1159
+ * contracts is missing. repo_contract binds a repository to a contract in either direction, so a row is dropped where
1160
+ * service_contract records the repository's services consuming that contract and none exposing it. Rows
1161
+ * service_contract says nothing about stay: a model that omits a service's exposes rows does not shrink what is checked.
1162
+ */
1006
1163
  async function catalogueContracts(repo) {
1007
1164
  const rc = await readText(join(repo, "catalogues", "repo_contract.csv"));
1008
1165
  const cs = await readText(join(repo, "catalogues", "contracts.csv"));
1009
1166
  if (rc === null || cs === null)
1010
1167
  return null;
1011
1168
  const spec = new Map(parseCatalogueCsv(cs).rows.map((r) => [r["id"] ?? "", r["spec_path"] ?? ""]));
1169
+ const repoOf = new Map(parseCatalogueCsv((await readText(join(repo, "catalogues", "services.csv"))) ?? "").rows.map((r) => [r["id"] ?? "", r["primary_repo_id"] ?? ""]));
1170
+ const roles = new Map();
1171
+ for (const r of parseCatalogueCsv((await readText(join(repo, "catalogues", "service_contract.csv"))) ?? "").rows) {
1172
+ const key = `${repoOf.get(r["service_id"] ?? "") ?? ""}\u0000${r["contract_id"] ?? ""}`;
1173
+ roles.set(key, (roles.get(key) ?? new Set()).add(r["role"] ?? ""));
1174
+ }
1012
1175
  const out = new Map();
1013
1176
  for (const r of parseCatalogueCsv(rc).rows) {
1014
1177
  const repoId = r["repo_id"] ?? "";
1015
1178
  const id = r["contract_id"] ?? "";
1016
1179
  if (repoId === "" || id === "")
1017
1180
  continue;
1018
- const list = out.get(repoId) ?? [];
1019
- list.push({ id, specPath: spec.get(id) ?? "" });
1020
- out.set(repoId, list);
1181
+ const entry = out.get(repoId) ?? { served: [], consumed: 0 };
1182
+ const role = roles.get(`${repoId}\u0000${id}`);
1183
+ if (role?.has("consumes") && !role.has("exposes"))
1184
+ entry.consumed++;
1185
+ else
1186
+ entry.served.push({ id, specPath: spec.get(id) ?? "" });
1187
+ out.set(repoId, entry);
1021
1188
  }
1022
1189
  return out;
1023
1190
  }
1191
+ /** A baseline's recorded gate outcome: the last `**Baseline gate:** <outcome>` line's first word, upper-cased; null when there is none. */
1192
+ function baselineGate(evidence) {
1193
+ let out = null;
1194
+ for (const m of evidence.matchAll(/^\s*\*\*Baseline gate:\*\*\s*(\S+)/gm))
1195
+ out = m[1].replace(/[^A-Za-z]/g, "").toUpperCase();
1196
+ return out;
1197
+ }
1024
1198
  async function observedInAll(repo, archiveRel, names) {
1025
1199
  const out = [];
1026
1200
  for (const n of names) {
@@ -20,6 +20,9 @@
20
20
  // element another open change adds names that change in depends_on
21
21
  // no-op contradicted by a behaviour delta for a feature the model does not hold; a structural delta
22
22
  // must add that feature
23
+ // removals each id a delta removes, against the rows outside the model that still name it: this SDD's catalogues
24
+ // built from its records and its members' catalogues (lib/structure/removals.ts). Reported as [WARN],
25
+ // not counted as a finding: a removal may be meant, and the warning names what it owes before the push
23
26
  // Composition (§4.5; lib/structure/compose.ts), with a change agreed when its agreement record says so:
24
27
  // overlap two agreed changes writing one element or adding one id; a change overlapping an agreed one
25
28
  // composed each change's view (with its dependency closure) and the intended view (every agreed change)
@@ -44,6 +47,7 @@ import { agreedOrder, attribute, checkDependencies, checkOverlap, closure, inten
44
47
  import { describeState, renderViews, VIEW_PATHS, viewState } from "../structure/render.js";
45
48
  import { Additions, CONTRACTS, readAgreement } from "../structure/intended.js";
46
49
  import { validateDeltas, validateView, viewOf } from "../structure/view.js";
50
+ import { checkRemovals } from "../structure/removals.js";
47
51
  const VIEW_COUNT = Object.keys(VIEW_PATHS).length;
48
52
  /** 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
53
  export const ENFORCED = true;
@@ -222,6 +226,8 @@ export async function checkStructuralModel(repo, options = {}) {
222
226
  }
223
227
  }
224
228
  lines.push(`changes: ${changes.length} open, ${withDeltas} with structural deltas, ${noOps} claiming none, ${without} without the artifact`);
229
+ const removals = await checkRemovals(repo, schema, structures);
230
+ lines.push(...removals.lines);
225
231
  // Composition (M-ADR 0230 §4.5): the three views and the three checks.
226
232
  const archived = await archivedNames(repo);
227
233
  checkOverlap(open, problems);
@@ -268,7 +274,7 @@ export async function checkStructuralModel(repo, options = {}) {
268
274
  if (raw > 0)
269
275
  lines.push(`archived changes whose structure is not applied: ${raw}`);
270
276
  lines.push(`views: current; at each of ${[...open.values()].filter((o) => o.structure.deltas.length > 0).length} change(s) with deltas; intended from ${agreed.length} agreed change(s)`);
271
- const body = lines.map((l) => `${l}\n`).join("");
277
+ const body = lines.map((l) => `${l}\n`).join("") + removals.findings.map((w) => `[WARN] ${w}\n`).join("");
272
278
  if (problems.length === 0)
273
279
  return { exitCode: 0, stdout: `structural-model:\n${body}[PASS] structural-model: ok\n` };
274
280
  const tag = enforced ? "FAIL" : "WARN";
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "0.16.0",
3
- "built_against_sdd_cli": "5.2.0"
2
+ "version": "0.17.0",
3
+ "built_against_sdd_cli": "5.3.0"
4
4
  }
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "5.2.0",
3
- "built_against_sdd_cli": "5.2.0"
2
+ "version": "5.3.0",
3
+ "built_against_sdd_cli": "5.3.0"
4
4
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sdd-method/author-cli",
3
- "version": "0.15.0",
4
- "sddCliVersion": "5.2.0",
3
+ "version": "0.16.0",
4
+ "sddCliVersion": "5.3.0",
5
5
  "description": "Re-author one SDD's content — scaffold, plan, contracts, conformance, api-surface, snapshots, briefings, catalogue check — plus every read-only verify verb. Ships only the code those verbs reach; it cannot sync the baseline or rebuild the catalogues. Maintenance is in @sdd-method/maintain-cli, administration in @sdd-method/sdd-cli.",
6
6
  "license": "UNLICENSED",
7
7
  "type": "module",