@sdd-method/author-cli 0.15.1 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -129,10 +129,17 @@ export async function orderArchives(sddPath, archiveRel, names) {
129
129
  }
130
130
  return { order, ties, history: history !== null };
131
131
  }
132
+ /**
133
+ * Whether the evidence's `**Observed in:**` line is the typed claim `none`: a baseline that admits no behaviour (no
134
+ * spec deltas), such as a structure-only removal, observed no repository serving any.
135
+ */
136
+ export function observedNone(evidence) {
137
+ return /^\s*\*\*Observed in:\*\*\s*none\b/im.test(evidence);
138
+ }
132
139
  /** The repositories a baseline was observed in: its evidence file's `**Observed in:** repo[@sha], repo[@sha]` line. */
133
140
  export function observedIn(evidence) {
134
141
  const m = /^\s*\*\*Observed in:\*\*\s*(.+)$/m.exec(evidence);
135
- if (!m)
142
+ if (!m || observedNone(evidence))
136
143
  return [];
137
144
  return m[1]
138
145
  .split(",")
@@ -148,18 +155,30 @@ export function observedIn(evidence) {
148
155
  * `**Surfaces:**` line is read, so an earlier prose section under the same label does not shadow the list.
149
156
  * Covered needs evidence that exercises the surface as the service exposes it; evidence below the transport is
150
157
  * not coverage, so `covered at handler` is a problem, not an entry. A wildcard may only exclude.
158
+ *
159
+ * `**Surfaces:** none` on one line is the typed claim that the baseline lists no surface, which only a baseline that
160
+ * admits no behaviour may make (the caller decides; `none` is true and the list is empty).
151
161
  */
152
162
  export function parseSurfaces(evidence) {
153
163
  const lines = evidence.split("\n");
154
164
  // The LAST standalone line: an evidence file may carry an earlier prose section under the same label, and
155
165
  // the list is what the file ends up asserting.
156
166
  let at = -1;
167
+ let none = false;
157
168
  lines.forEach((l, i) => {
158
- if (/^\s*\*\*Surfaces:\*\*\s*$/.test(l))
169
+ if (/^\s*\*\*Surfaces:\*\*\s*$/.test(l)) {
159
170
  at = i;
171
+ none = false;
172
+ }
173
+ else if (/^\s*\*\*Surfaces:\*\*\s*none\b/i.test(l)) {
174
+ at = i;
175
+ none = true;
176
+ }
160
177
  });
161
178
  if (at < 0)
162
- return { present: false, entries: [], problems: [] };
179
+ return { present: false, none: false, entries: [], problems: [] };
180
+ if (none)
181
+ return { present: true, none: true, entries: [], problems: [] };
163
182
  const entries = [];
164
183
  const problems = [];
165
184
  for (const line of lines.slice(at + 1)) {
@@ -193,8 +212,36 @@ export function parseSurfaces(evidence) {
193
212
  }
194
213
  }
195
214
  if (entries.length + problems.length === 0)
196
- problems.push("the **Surfaces:** line lists no contract");
197
- return { present: true, entries, problems };
215
+ problems.push('the **Surfaces:** line lists no contract; a baseline that admits no behaviour writes "**Surfaces:** none"');
216
+ return { present: true, none: false, entries, problems };
217
+ }
218
+ /**
219
+ * A structural delta's `moved: {contracts: [{from, to, from_path?}]}` block. A contract's id is derived from its path,
220
+ * so moving the file changes the id; the change that moves it records the move once, and what named the old
221
+ * identity (an archived baseline's Surfaces list) follows it rather than being edited.
222
+ */
223
+ export function contractMoves(raw) {
224
+ const moves = [];
225
+ const problems = [];
226
+ if (raw === undefined || raw === null)
227
+ return { moves, problems };
228
+ const contracts = typeof raw === "object" && !Array.isArray(raw) ? raw["contracts"] : undefined;
229
+ const extra = typeof raw === "object" && raw !== null && !Array.isArray(raw) ? Object.keys(raw).filter((k) => k !== "contracts") : [];
230
+ if (!Array.isArray(contracts) || extra.length > 0) {
231
+ problems.push(`moved must be {contracts: [{from: <old contract id>, to: <new contract id>, from_path?: <old spec path>}]}`);
232
+ return { moves, problems };
233
+ }
234
+ for (const [i, m] of contracts.entries()) {
235
+ const e = (m ?? {});
236
+ const from = typeof e["from"] === "string" ? e["from"].trim() : "";
237
+ const to = typeof e["to"] === "string" ? e["to"].trim() : "";
238
+ const fromPath = typeof e["from_path"] === "string" ? e["from_path"].trim() : undefined;
239
+ if (from === "" || to === "" || from === to)
240
+ problems.push(`moved.contracts[${i}] needs a from and a to that differ`);
241
+ else
242
+ moves.push({ from, to, ...(fromPath ? { fromPath } : {}) });
243
+ }
244
+ return { moves, problems };
198
245
  }
199
246
  /** Whether a Surfaces entry names a catalogue contract: its id, or a path suffix of its spec_path on a segment boundary. */
200
247
  export function surfaceMatches(entry, contractId, specPath) {
@@ -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
@@ -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);
@@ -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 = [];
@@ -24,9 +24,18 @@
24
24
  //
25
25
  // Dedup ordering matters: walker (1) runs first (capability features),
26
26
  // then walker (2) (standalone specs), then walker (3) (bullets). When
27
- // a bullet's id is already in `seen` from a standalone spec the spec
28
- // wins — divergence finding fires for the bullet. This matches the
29
- // "standalone is more authoritative than bullet inventory" rule.
27
+ // a bullet's id is already claimed by a standalone app spec (2), the two
28
+ // are one feature and make one row (sdm-mbhi): the feature tree is the home
29
+ // of its placement and inventory facts (parent_group_id, maturity, runway,
30
+ // description), the spec of its lifecycle (status, kind), and source stays
31
+ // feature-spec, since a spec exists. A spec does not restate the tree's
32
+ // fields, and its H1 title is a name, not a description. A bullet whose id a
33
+ // capability feature (1) already claims is still a divergence.
34
+ //
35
+ // A standalone spec's stem is the feature's slug, without the product
36
+ // prefix: features/<group>/alerts.md is <productId>-alerts. A stem that
37
+ // already starts with "<productId>-" would double the prefix, so it is
38
+ // refused with a convention finding naming the stem to use.
30
39
  import { readdir, readFile } from "node:fs/promises";
31
40
  import { basename, join, relative, sep } from "node:path";
32
41
  import { featureGroupId, findFeatureTreeFile, walkFeatureTree, } from "./feature-tree-walker.js";
@@ -103,10 +112,15 @@ export async function buildFeatures(inputs) {
103
112
  // These features are owned by the SDD's product (not by a capability) —
104
113
  // capability_id is left empty; product_id carries the link.
105
114
  if (inputs.profile === "application-product") {
115
+ const appSpecRow = new Map();
106
116
  const appFeatures = await collectAppFeatureSpecs(inputs.sddPath);
107
117
  for (const path of appFeatures) {
108
118
  const relPath = relative(inputs.sddPath, path);
109
119
  const stem = basename(relPath).replace(/\.md$/i, "");
120
+ if (stem.startsWith(`${inputs.productId}-`)) {
121
+ inputs.findings.add("convention", `${relPath}: feature spec stem "${stem}" repeats the product prefix, so its id would read "${inputs.productId}-${stem}"; name the file after the feature's slug ("${stem.slice(inputs.productId.length + 1)}.md"), the slug feature-tree.md uses`);
122
+ continue;
123
+ }
110
124
  const id = `${inputs.productId}-${stem}`;
111
125
  if (seen.has(id)) {
112
126
  inputs.findings.add("divergence", `${relPath}: feature id "${id}" already emitted by an earlier file`);
@@ -133,6 +147,7 @@ export async function buildFeatures(inputs) {
133
147
  idByPath.set(relPath, id);
134
148
  if (!idBySlug.has(stem))
135
149
  idBySlug.set(stem, id);
150
+ appSpecRow.set(id, rows.length - 1);
136
151
  }
137
152
  // Bullet branch — feature-tree.md.
138
153
  await emitBulletRows({
@@ -143,6 +158,7 @@ export async function buildFeatures(inputs) {
143
158
  seen,
144
159
  rows,
145
160
  idBySlug,
161
+ appSpecRow,
146
162
  });
147
163
  }
148
164
  return { rows, idByPath, idBySlug };
@@ -178,7 +194,8 @@ async function emitBulletRows(ctx) {
178
194
  continue;
179
195
  bulletCount++;
180
196
  const id = `${ctx.productId}-${ev.slug}`;
181
- if (ctx.seen.has(id)) {
197
+ const specIndex = ctx.appSpecRow.get(id);
198
+ if (ctx.seen.has(id) && specIndex === undefined) {
182
199
  ctx.findings.add("divergence", `${relPath}: feature-tree.md bullet "${ev.slug}" superseded by an earlier feature spec with id "${id}"`);
183
200
  continue;
184
201
  }
@@ -196,6 +213,19 @@ async function emitBulletRows(ctx) {
196
213
  const description = ev.description.length > DESCRIPTION_MAX
197
214
  ? ev.description.slice(0, DESCRIPTION_MAX)
198
215
  : ev.description;
216
+ if (specIndex !== undefined) {
217
+ // One feature, one row: the tree's placement and inventory facts, the spec's lifecycle.
218
+ ctx.rows[specIndex] = {
219
+ ...ctx.rows[specIndex],
220
+ parent_group_id: parentGroupId,
221
+ description,
222
+ maturity: maturityFromRunway(ev.runway),
223
+ runway: ev.runway,
224
+ };
225
+ if (!ctx.idBySlug.has(ev.slug))
226
+ ctx.idBySlug.set(ev.slug, id);
227
+ continue;
228
+ }
199
229
  ctx.rows.push({
200
230
  id,
201
231
  name: ev.slug,
@@ -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({
@@ -276,7 +276,7 @@ export async function buildRequirements(inputs) {
276
276
  structureEffects.push(effectRow(scoped(dir), "structure", e.element, e.operation, e.base ?? "", e.target ?? ""));
277
277
  if (evidence !== null) {
278
278
  const repos = observedIn(evidence);
279
- if (repos.length === 0) {
279
+ if (repos.length === 0 && delta.length > 0) {
280
280
  findings.add("convention", `${rel(sddPath, join(changeDir, "baseline-evidence.md"))}: no "**Observed in:**" line, so its requirements carry no observed status`);
281
281
  }
282
282
  for (const repo of repos)
@@ -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 = [];