@sdd-method/maintain-cli 0.6.0 → 0.8.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.
@@ -0,0 +1,180 @@
1
+ // What the catalogue and the behaviour-store checks both read from a store's archive: the order its changes
2
+ // were archived in, and the requirements each delta names. Pure reads, no catalogue or validator types, so the
3
+ // verify-only CLI can carry it.
4
+ //
5
+ // Archive order is the archive date, then the order in which the SDD's history first added each archive
6
+ // directory, then the name. The date prefix alone ties for changes archived the same day, and their names say
7
+ // nothing about which came first; an untracked archive is newer than every tracked one. Same-day archives the
8
+ // history cannot order are returned as ties, for the caller to report.
9
+ import { execFile as execFileCb } from "node:child_process";
10
+ import { promisify } from "node:util";
11
+ const execFile = promisify(execFileCb);
12
+ /** Requirement names and scenario counts under each `## <OP> Requirements` section (or the whole file for a living spec). */
13
+ export function parseRequirements(text) {
14
+ const out = [];
15
+ let op = null;
16
+ let current = null;
17
+ for (const line of text.split("\n")) {
18
+ const section = /^##\s+(ADDED|MODIFIED|REMOVED|RENAMED)\s+Requirements\b/.exec(line);
19
+ if (section) {
20
+ op = section[1];
21
+ current = null;
22
+ continue;
23
+ }
24
+ if (/^##\s/.test(line) && !/^###/.test(line)) {
25
+ current = null;
26
+ continue;
27
+ }
28
+ // RENAMED: "- FROM: `### Requirement: Old name`" then "- TO: `### Requirement: New name`"
29
+ const renamedFrom = /^\s*-\s*FROM:\s*`?###\s+Requirement:\s*(.+?)`?\s*$/.exec(line);
30
+ if (op === "RENAMED" && renamedFrom) {
31
+ current = null;
32
+ continue;
33
+ }
34
+ const renamedTo = /^\s*-\s*TO:\s*`?###\s+Requirement:\s*(.+?)`?\s*$/.exec(line);
35
+ if (op === "RENAMED" && renamedTo) {
36
+ current = { name: renamedTo[1].trim(), operation: op, scenarios: 0 };
37
+ out.push(current);
38
+ continue;
39
+ }
40
+ const req = /^###\s+Requirement:\s*(.+?)\s*$/.exec(line);
41
+ if (req) {
42
+ current = { name: req[1], operation: op, scenarios: 0 };
43
+ out.push(current);
44
+ continue;
45
+ }
46
+ if (current && /^####\s+Scenario:/.test(line))
47
+ current.scenarios++;
48
+ }
49
+ return out;
50
+ }
51
+ /** The names a RENAMED section renames FROM (the requirement a rename removes). */
52
+ export function renamedFrom(text) {
53
+ const out = [];
54
+ let op = null;
55
+ for (const line of text.split("\n")) {
56
+ const section = /^##\s+(ADDED|MODIFIED|REMOVED|RENAMED)\s+Requirements\b/.exec(line);
57
+ if (section) {
58
+ op = section[1];
59
+ continue;
60
+ }
61
+ const m = /^\s*-\s*FROM:\s*`?###\s+Requirement:\s*(.+?)`?\s*$/.exec(line);
62
+ if (op === "RENAMED" && m)
63
+ out.push(m[1].trim());
64
+ }
65
+ return out;
66
+ }
67
+ /** Each archive directory's position in the SDD's history: the index of the first commit that added a file under it. */
68
+ async function archiveHistory(sddPath, archiveRel) {
69
+ let stdout;
70
+ try {
71
+ ({ stdout } = await execFile("git", ["-C", sddPath, "log", "--relative", "--reverse", "--diff-filter=A", "--format=@%H", "--name-only", "--", archiveRel], { maxBuffer: 64 * 1024 * 1024 }));
72
+ }
73
+ catch {
74
+ return null;
75
+ }
76
+ const order = new Map();
77
+ let commit = -1;
78
+ for (const line of stdout.split("\n")) {
79
+ if (line.startsWith("@")) {
80
+ commit++;
81
+ continue;
82
+ }
83
+ if (!line.startsWith(`${archiveRel}/`))
84
+ continue;
85
+ const dir = line.slice(archiveRel.length + 1).split("/")[0];
86
+ if (!order.has(dir))
87
+ order.set(dir, commit);
88
+ }
89
+ return order;
90
+ }
91
+ /** Archive directories in archive order, and the adjacent same-day pairs the history could not order. */
92
+ export async function orderArchives(sddPath, archiveRel, names) {
93
+ const history = await archiveHistory(sddPath, archiveRel);
94
+ const date = (d) => /^(\d{4}-\d{2}-\d{2})-/.exec(d)?.[1] ?? "";
95
+ const position = (d) => history?.get(d) ?? Number.POSITIVE_INFINITY;
96
+ const order = [...names].sort((a, b) => date(a).localeCompare(date(b)) || position(a) - position(b) || a.localeCompare(b));
97
+ const ties = [];
98
+ for (let i = 1; i < order.length; i++) {
99
+ const [a, b] = [order[i - 1], order[i]];
100
+ if (date(a) === date(b) && position(a) === position(b))
101
+ ties.push([a, b]);
102
+ }
103
+ return { order, ties };
104
+ }
105
+ /** The repositories a baseline was observed in: its evidence file's `**Observed in:** repo[@sha], repo[@sha]` line. */
106
+ export function observedIn(evidence) {
107
+ const m = /^\s*\*\*Observed in:\*\*\s*(.+)$/m.exec(evidence);
108
+ if (!m)
109
+ return [];
110
+ return m[1]
111
+ .split(",")
112
+ .map((s) => s.trim().split(/\s*@\s*/)[0].replace(/`/g, "").trim())
113
+ .filter((s) => s !== "");
114
+ }
115
+ /**
116
+ * A baseline's `**Surfaces:**` list (M-ADR 0222 §4.3, §4.4; agreement-record-schema.yaml, Baselines): one entry per
117
+ * contract the observed repositories serve, either
118
+ * - <contract>: covered at transport[; <note>]
119
+ * - <contract>: excluded, <reason>[; <note>]
120
+ * A note carries detail inside the contract, such as an operation left out and why. The last standalone
121
+ * `**Surfaces:**` line is read, so an earlier prose section under the same label does not shadow the list.
122
+ * Covered needs evidence that exercises the surface as the service exposes it; evidence below the transport is
123
+ * not coverage, so `covered at handler` is a problem, not an entry. A wildcard may only exclude.
124
+ */
125
+ export function parseSurfaces(evidence) {
126
+ const lines = evidence.split("\n");
127
+ // The LAST standalone line: an evidence file may carry an earlier prose section under the same label, and
128
+ // the list is what the file ends up asserting.
129
+ let at = -1;
130
+ lines.forEach((l, i) => {
131
+ if (/^\s*\*\*Surfaces:\*\*\s*$/.test(l))
132
+ at = i;
133
+ });
134
+ if (at < 0)
135
+ return { present: false, entries: [], problems: [] };
136
+ const entries = [];
137
+ const problems = [];
138
+ for (const line of lines.slice(at + 1)) {
139
+ if (line.trim() === "" && entries.length + problems.length === 0)
140
+ continue;
141
+ const item = /^\s*-\s+(.+?)\s*$/.exec(line);
142
+ if (!item)
143
+ break;
144
+ const m = /^`?([^`]+?)`?\s*:\s*(.+)$/.exec(item[1]);
145
+ if (!m) {
146
+ problems.push(`"${item[1]}" is not "<contract>: covered at transport" or "<contract>: excluded, <reason>"`);
147
+ continue;
148
+ }
149
+ const contract = m[1].trim();
150
+ const status = m[2].split(";")[0].trim();
151
+ const excluded = /^excluded\s*,\s*(.+)$/i.exec(status);
152
+ if (/^covered at transport$/i.test(status)) {
153
+ if (contract.includes("*"))
154
+ problems.push(`"${contract}": a wildcard may only exclude; list each covered contract`);
155
+ else
156
+ entries.push({ contract, covered: true, reason: "" });
157
+ }
158
+ else if (excluded) {
159
+ entries.push({ contract, covered: false, reason: excluded[1].trim() });
160
+ }
161
+ else if (/^covered\b/i.test(status)) {
162
+ problems.push(`"${contract}: ${status}": covered needs evidence at the transport; evidence below it (a handler called directly, a hand-built request) is an exclusion: "excluded, handler-only evidence"`);
163
+ }
164
+ else {
165
+ problems.push(`"${contract}: ${status}" is neither "covered at transport" nor "excluded, <reason>"`);
166
+ }
167
+ }
168
+ if (entries.length + problems.length === 0)
169
+ problems.push("the **Surfaces:** line lists no contract");
170
+ return { present: true, entries, problems };
171
+ }
172
+ /** Whether a Surfaces entry names a catalogue contract: its id, or a path suffix of its spec_path on a segment boundary. */
173
+ export function surfaceMatches(entry, contractId, specPath) {
174
+ const e = entry.replace(/^\.\//, "");
175
+ if (e === contractId)
176
+ return true;
177
+ const re = new RegExp(`(^|/)${e.split("*").map((p) => p.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join("[^/]*")}$`);
178
+ return re.test(specPath.split("\\").join("/"));
179
+ }
180
+ //# sourceMappingURL=archive.js.map
@@ -826,7 +826,11 @@ async function collectRows(schema, manifest, findings, sddPath, snapshotStore) {
826
826
  // name/domain/publisher/transport; avsc augments schema_path/version.
827
827
  // service_event edges populate from both repo-config events.produces/consumes
828
828
  // (forward) and event-catalog.yaml publisher + consumers[] (reverse).
829
- const avroContracts = contractsResult.rows.filter((c) => c["kind"] === "event");
829
+ // Discovered AND declared-external event contracts: an event schema in a sibling contracts repository
830
+ // (M-ADR 0110) is as much an event as one in the SDD. Filtering the discovered rows alone left every
831
+ // declared-external schema without an event row, so produces/consumes edges that resolved to one were
832
+ // dropped as unknown events.
833
+ const avroContracts = [...contractsResult.rows, ...declaredExternal.rows].filter((c) => c["kind"] === "event");
830
834
  const eventsResult = await buildEvents({
831
835
  sddPath,
832
836
  findings,
@@ -46,13 +46,18 @@ const KIND_SPECS = [
46
46
  // and it stayed invisible for the same reason: an unreachable value is emitted
47
47
  // for nothing and so never complains.
48
48
  //
49
- // NOT zero-delta, unlike spicedb: the reverse estate already holds 7 `.graphql`
50
- // files under the conventional `docs/domains/<domain>/contracts/graphql/` layout
51
- // (vehicle 5, applications 2), invisible to the catalogue until now. An author
49
+ // NOT zero-delta, unlike spicedb: an estate already held `.graphql` files under
50
+ // the conventional `docs/domains/<domain>/contracts/graphql/` layout, invisible
51
+ // to the catalogue until then. An author
52
52
  // whose GraphQL surface could not be keyed documented the wall in their own
53
53
  // manifest — naming the schema lines that blocked them — and wrote the only
54
54
  // token left in `contracts.status`.
55
55
  { subdir: "graphql", extensions: [".graphql", ".graphqls"], kind: "graphql" },
56
+ // `cli` — a command-line interface's command manifest (schema 4.1.0). A command-line tool often serves no
57
+ // REST, RPC, event or authorization contract, so without this kind its behaviour baselines had nothing to
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
+ // traceability manifest's `cli:` block (DECLARED_KINDS), loaded (CONTRACT_LIST_KEYS) and keyed by repo-config.
60
+ { subdir: "cli", extensions: [".yaml", ".yml"], kind: "cli" },
56
61
  ];
57
62
  export async function buildContracts(inputs) {
58
63
  const rows = [];
@@ -319,6 +324,7 @@ const DECLARED_KINDS = {
319
324
  avro: "event",
320
325
  spicedb: "authz",
321
326
  graphql: "graphql",
327
+ cli: "cli",
322
328
  };
323
329
  export function buildDeclaredExternalContracts(inputs) {
324
330
  const rows = [];
@@ -137,12 +137,19 @@ export async function buildEvents(inputs) {
137
137
  function normaliseSchemaPath(p) {
138
138
  return p.trim().replace(/\\/g, "/").replace(/^\.\//, "");
139
139
  }
140
- function inferDomainFromAvroPath(relPath) {
141
- // Layout: contracts/avro/<domain>/<file>.avsc
142
- const parts = relPath.split(sep);
143
- if (parts.length < 4)
140
+ export function inferDomainFromAvroPath(relPath) {
141
+ // Layouts: contracts/avro/<domain>/…/<file>.avsc (in the SDD); <x>-avro/<domain>/…/<file>.avsc (a sibling
142
+ // contracts repository, M-ADR 0110, as declared-external rows give it); docs/domains/<domain>/…
143
+ // The domain is the segment after the avro root, never a fixed position: the sibling layout is one level
144
+ // shallower, and a fixed index read its `events/` folder as the domain.
145
+ const parts = relPath.split("\\").join("/").split("/");
146
+ const d = parts.indexOf("domains");
147
+ if (d >= 0 && parts[d - 1] === "docs" && parts.length > d + 2)
148
+ return parts[d + 1] ?? "";
149
+ const root = parts.findIndex((p) => p === "avro" || p.endsWith("-avro"));
150
+ if (root < 0 || parts.length < root + 3)
144
151
  return "";
145
- return parts[2] ?? "";
152
+ return parts[root + 1] ?? "";
146
153
  }
147
154
  function resolveContractByPath(schema, kind, index) {
148
155
  const cleaned = schema.replace(/^\/+/, "");
@@ -23,6 +23,7 @@
23
23
  // openapi: [<path-relative-to-contracts/openapi/>]
24
24
  // proto: [<path-relative-to-contracts/proto/>]
25
25
  // avro: [<path-relative-to-contracts/avro/>]
26
+ // spicedb: [<path-relative-to-contracts/spicedb/>]
26
27
  //
27
28
  // Reverse-method derived sources (universal — work for any SDD with the
28
29
  // underlying catalogue rows populated):
@@ -49,7 +50,7 @@
49
50
  // feature-set` path is documented in the metamodel but unused by any
50
51
  // observed adopter — left empty here pending first-adopter authoring.
51
52
  import { basename, sep } from "node:path";
52
- import { normaliseAdditionalContextEntry, } from "./repositories.js";
53
+ import { normaliseAdditionalContextEntry, REPO_CONFIG_CONTRACT_KINDS, } from "./repositories.js";
53
54
  /**
54
55
  * feature_repo derivation — `feature_service ⋈ services.primary_repo_id`.
55
56
  *
@@ -263,7 +264,7 @@ function collectRepoContract(inputs) {
263
264
  seen.add(id);
264
265
  rows.push({ id, repo_id: repoId, contract_id: contractId });
265
266
  };
266
- // Forward path — declared in repo-config contracts.{openapi,proto,avro}[].
267
+ // Forward path — declared in repo-config contracts.{openapi,proto,avro,spicedb}[].
267
268
  // Pre-compute suffix and basename indexes for fuzzy matching.
268
269
  const suffixIndex = new Map();
269
270
  const basenameIndex = new Map();
@@ -278,7 +279,7 @@ function collectRepoContract(inputs) {
278
279
  const block = cfg.data.contracts;
279
280
  if (!block)
280
281
  continue;
281
- for (const kind of ["openapi", "proto", "avro"]) {
282
+ for (const kind of REPO_CONFIG_CONTRACT_KINDS) {
282
283
  const list = block[kind] ?? [];
283
284
  for (const rawEntry of list) {
284
285
  const entry = rawEntry.replace(/^\/+/, "");
@@ -32,11 +32,18 @@ const LOC_REPORT_REL = join("docs", "platform", "reference", "loc-report.csv");
32
32
  // mirrored source, where the manifest profile remains the honest answer.
33
33
  const REPOS_DIR_REL = "repos";
34
34
  const REVERSE_METADATA_BASENAME = "reverse-metadata.yaml";
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,
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"];
35
40
  const ContractsBlockSchema = z
36
41
  .object({
37
42
  openapi: z.array(z.string()).optional(),
38
43
  proto: z.array(z.string()).optional(),
39
44
  avro: z.array(z.string()).optional(),
45
+ spicedb: z.array(z.string()).optional(),
46
+ cli: z.array(z.string()).optional(),
40
47
  })
41
48
  .optional();
42
49
  const EventEntrySchema = z.object({
@@ -14,10 +14,14 @@
14
14
  // its work orders' `implements` lists; agreed from an open change whose record is agreed.
15
15
  // The latest event wins, archived changes in archive order, then open changes.
16
16
  //
17
+ // Archive order is ../../behaviour-store/archive.ts's; same-day archives the history cannot order are a finding.
18
+ //
17
19
  // A feature or repository the catalogue does not hold is a finding and produces no row, so no foreign key dangles.
18
20
  import { readdir, readFile, stat } from "node:fs/promises";
19
21
  import { join } from "node:path";
20
22
  import { parse as parseYaml } from "yaml";
23
+ import { observedIn, orderArchives, parseRequirements } from "../../behaviour-store/archive.js";
24
+ export { observedIn, parseRequirements };
21
25
  const STORE = "openspec";
22
26
  const AGREEMENTS = join("docs", "agreements");
23
27
  async function isDir(p) {
@@ -44,40 +48,6 @@ async function readText(p) {
44
48
  return null;
45
49
  }
46
50
  }
47
- /** Requirement names and scenario counts under each `## <OP> Requirements` section (or the whole file for a living spec). */
48
- export function parseRequirements(text) {
49
- const out = [];
50
- let op = null;
51
- let current = null;
52
- for (const line of text.split("\n")) {
53
- const section = /^##\s+(ADDED|MODIFIED|REMOVED|RENAMED)\s+Requirements\b/.exec(line);
54
- if (section) {
55
- op = section[1];
56
- current = null;
57
- continue;
58
- }
59
- if (/^##\s/.test(line) && !/^###/.test(line)) {
60
- current = null;
61
- continue;
62
- }
63
- // RENAMED: "- TO: `### Requirement: New name`"
64
- const renamedTo = /^\s*-\s*TO:\s*`?###\s+Requirement:\s*(.+?)`?\s*$/.exec(line);
65
- if (op === "RENAMED" && renamedTo) {
66
- current = { name: renamedTo[1].trim(), operation: op, scenarios: 0 };
67
- out.push(current);
68
- continue;
69
- }
70
- const req = /^###\s+Requirement:\s*(.+?)\s*$/.exec(line);
71
- if (req) {
72
- current = { name: req[1], operation: op, scenarios: 0 };
73
- out.push(current);
74
- continue;
75
- }
76
- if (current && /^####\s+Scenario:/.test(line))
77
- current.scenarios++;
78
- }
79
- return out;
80
- }
81
51
  async function deltaOf(changeDir) {
82
52
  const out = [];
83
53
  for (const feature of await dirs(join(changeDir, "specs"))) {
@@ -104,15 +74,13 @@ async function agreementRecord(sddPath, change) {
104
74
  return null;
105
75
  }
106
76
  }
107
- /** The repositories a baseline was observed in: its evidence file's `**Observed in:** repo[@sha], repo[@sha]` line. */
108
- export function observedIn(evidence) {
109
- const m = /^\s*\*\*Observed in:\*\*\s*(.+)$/m.exec(evidence);
110
- if (!m)
111
- return [];
112
- return m[1]
113
- .split(",")
114
- .map((s) => s.trim().split(/\s*@\s*/)[0].replace(/`/g, "").trim())
115
- .filter((s) => s !== "");
77
+ /** Archive directories in archive order; same-day archives the history cannot order are reported. */
78
+ export async function archiveOrder(sddPath, names, findings) {
79
+ const { order, ties } = await orderArchives(sddPath, `${STORE}/changes/archive`, names);
80
+ for (const [a, b] of ties) {
81
+ findings.add("convention", `${STORE}/changes/archive: ${a} and ${b} were archived the same day and the SDD's history does not say which came first; ordered by name, so a status that depends on their order may be wrong`);
82
+ }
83
+ return order;
116
84
  }
117
85
  const rel = (sddPath, p) => p.slice(sddPath.length + 1).split("\\").join("/");
118
86
  export async function buildRequirements(inputs) {
@@ -156,7 +124,7 @@ export async function buildRequirements(inputs) {
156
124
  events.push(e);
157
125
  };
158
126
  // 2. Archived changes, in archive order.
159
- for (const dir of await dirs(join(store, "changes", "archive"))) {
127
+ for (const dir of await archiveOrder(sddPath, await dirs(join(store, "changes", "archive")), findings)) {
160
128
  const changeDir = join(store, "changes", "archive", dir);
161
129
  const change = dir.replace(/^\d{4}-\d{2}-\d{2}-/, "");
162
130
  const archivedOn = /^(\d{4}-\d{2}-\d{2})-/.exec(dir)?.[1] ?? "";
@@ -1,10 +1,11 @@
1
1
  // service_contract edge — emits one row per (service, contract, exposes)
2
- // triple from `contracts.{openapi,proto,avro}` blocks in repo-config.
2
+ // triple from `contracts.{openapi,proto,avro,spicedb}` blocks in repo-config.
3
3
  //
4
4
  // Forward method convention: a service's repo-config declares the
5
5
  // contracts it exposes. The "consumes" role isn't declared in current
6
6
  // adopters; this builder emits role=exposes only.
7
7
  import { basename, sep } from "node:path";
8
+ import { REPO_CONFIG_CONTRACT_KINDS } from "./repositories.js";
8
9
  export function buildServiceContract(inputs) {
9
10
  const rows = [];
10
11
  const seen = new Set();
@@ -30,13 +31,9 @@ export function buildServiceContract(inputs) {
30
31
  // stays silent, as it always has.
31
32
  // NOTE: `block` here is repo-config's `contracts` (cfg.data.contracts), a
32
33
  // DIFFERENT schema from the traceability manifest's contracts block — so its
33
- // three kinds are not a stale copy of the manifest's five and must not be
34
- // "fixed" by the shared traceability helper. Whether repo-config should also
35
- // key graphql is its own question, on its own schema.
36
- const hasContracts = block !== undefined &&
37
- ((block.openapi?.length ?? 0) > 0 ||
38
- (block.proto?.length ?? 0) > 0 ||
39
- (block.avro?.length ?? 0) > 0);
34
+ // kinds (REPO_CONFIG_CONTRACT_KINDS) are not a stale copy of the manifest's five
35
+ // and must not be "fixed" by the shared traceability helper.
36
+ const hasContracts = block !== undefined && REPO_CONFIG_CONTRACT_KINDS.some((k) => (block[k]?.length ?? 0) > 0);
40
37
  if (hasContracts) {
41
38
  inputs.findings.add("orphan", `${cfg.relPath}: contracts block declares exposes-edges for service "${cfg.repoId}", which does not resolve in services.csv — dropped. Check a services: block entry exists at this id (M-ADR 0197); this can also mean the repo's own default service id was never a runtime type.`);
42
39
  }
@@ -44,7 +41,7 @@ export function buildServiceContract(inputs) {
44
41
  }
45
42
  if (!block)
46
43
  continue;
47
- for (const kind of ["openapi", "proto", "avro"]) {
44
+ for (const kind of REPO_CONFIG_CONTRACT_KINDS) {
48
45
  const list = block[kind] ?? [];
49
46
  for (const rawEntry of list) {
50
47
  const entry = rawEntry.replace(/^\/+/, "");
@@ -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: "4.0.0"
10
+ schema_version: "4.1.0"
11
11
  schema_owner: "sdd-method"
12
12
  schema_status: "generated"
13
13
  enums:
@@ -361,6 +361,7 @@ enums:
361
361
  - value: graphql
362
362
  - value: rpc
363
363
  - value: authz
364
+ - value: cli
364
365
  description: Contract kind. Bound at 3.13.0 from the column descriptions; measured against both reference estates before binding (M-ADR 0205 §4.4).
365
366
  contract_status:
366
367
  values:
@@ -1552,7 +1553,7 @@ catalogues:
1552
1553
  - name: kind
1553
1554
  type: string
1554
1555
  nullable: false
1555
- description: rest | graphql | rpc | event | authz. 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.
1556
+ 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.
1556
1557
  - name: version
1557
1558
  type: string
1558
1559
  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). The kindless 2.x form made one
5
+ // (kind ∈ rest | graphql | rpc | event | authz | cli). 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
@@ -47,6 +47,7 @@ export const CONTRACT_LIST_KEYS = [
47
47
  "avro",
48
48
  "spicedb",
49
49
  "graphql",
50
+ "cli",
50
51
  ];
51
52
  /**
52
53
  * Every contract path a contracts block declares, across all kinds and both
@@ -279,6 +280,7 @@ function extractContracts(raw) {
279
280
  const avro = arr("avro");
280
281
  const spicedb = arr("spicedb");
281
282
  const graphql = arr("graphql");
283
+ const cli = arr("cli");
282
284
  // `provenance` (3.5.0) MUST be extracted here or the builder that reads it
283
285
  // sees nothing — this function reconstructs the block key-by-key rather than
284
286
  // passing the parsed object through, which is precisely the shape the
@@ -295,6 +297,7 @@ function extractContracts(raw) {
295
297
  ...(avro ? { avro } : {}),
296
298
  ...(spicedb ? { spicedb } : {}),
297
299
  ...(graphql ? { graphql } : {}),
300
+ ...(cli ? { cli } : {}),
298
301
  ...(provenance ? { provenance } : {}),
299
302
  };
300
303
  }
@@ -14,6 +14,16 @@
14
14
  // With no such targets the check is vacuous, and says so.
15
15
  // 4. verdicts: every archived change except a baseline has a verdict.md and a record whose
16
16
  // verdict is PASS and names it (M-ADR 0223 §4.6).
17
+ // 5. surfaces: every archived baseline's evidence lists, under **Surfaces:**, each contract the
18
+ // repositories it was observed in serve, as covered at transport or excluded with a
19
+ // reason, and the list is checked against the catalogue (catalogues/repo_contract.csv),
20
+ // never against itself (M-ADR 0222 §4.3, §4.4). A list the catalogue gives nothing to
21
+ // check against (no Observed in line, or a repository with no catalogue contracts) fails:
22
+ // a cross-check of zero contracts is unresolved, not a pass.
23
+ // 6. scope: a baseline ADDS requirements; it REMOVES, MODIFIES or RENAMES only a requirement whose
24
+ // latest archived change was a baseline, which is how a corrective baseline withdraws
25
+ // what a baseline admitted wrongly. A requirement a judged change shipped is changed only
26
+ // by a change (M-ADR 0222 §4.4). Read in archive order.
17
27
  //
18
28
  // A sibling-repository store (M-ADR 0221 §4.2) is checked in that repository, not here.
19
29
  //
@@ -25,6 +35,8 @@ import { access, readdir, readFile } from "node:fs/promises";
25
35
  import { basename, join } from "node:path";
26
36
  import { promisify } from "node:util";
27
37
  import { parseDocument } from "yaml";
38
+ import { observedIn, orderArchives, parseRequirements, parseSurfaces, renamedFrom, surfaceMatches } from "../behaviour-store/archive.js";
39
+ import { parseCatalogueCsv } from "../catalogue/csv-reader.js";
28
40
  const execFileAsync = promisify(execFile);
29
41
  /** The OpenSpec release the method depends on (M-ADR 0223 ruling 2). The module installs exactly this. */
30
42
  export const OPENSPEC_VERSION = "1.14.3";
@@ -271,6 +283,77 @@ export async function checkBehaviourStore(repo) {
271
283
  failures.push(`${record.file}: verdict.record ${JSON.stringify(vr)} does not name an existing file`);
272
284
  }
273
285
  lines.push(`verdicts: ${archived.length} archived change(s), ${baselines} baseline(s), ${archived.length - baselines} needing a PASS verdict`);
286
+ // 5. surfaces, and 6. what each baseline may change, in archive order
287
+ const archiveRel = `${STORE_DIR}/changes/archive`;
288
+ const { order, ties } = await orderArchives(repo, archiveRel, archived);
289
+ const servedBy = baselines > 0 ? await catalogueContracts(repo) : null;
290
+ let surfacesChecked = 0;
291
+ const lastTouch = new Map();
292
+ let scopeChecked = 0;
293
+ for (const name of order) {
294
+ const dir = `${archiveRel}/${name}`;
295
+ const evidence = await readText(join(repo, dir, "baseline-evidence.md"));
296
+ const isBaseline = evidence !== null;
297
+ if (isBaseline) {
298
+ const parsed = parseSurfaces(evidence);
299
+ if (!parsed.present)
300
+ failures.push(`${dir}/baseline-evidence.md: no **Surfaces:** list; name every contract the observed repositories serve, covered at transport or excluded with a reason`);
301
+ for (const p of parsed.problems)
302
+ failures.push(`${dir}/baseline-evidence.md: Surfaces: ${p}`);
303
+ if (parsed.present) {
304
+ if (servedBy === null) {
305
+ failures.push(`${dir}/baseline-evidence.md: Surfaces cannot be cross-checked: catalogues/repo_contract.csv or catalogues/contracts.csv is missing; build the catalogue`);
306
+ }
307
+ else {
308
+ const repos = observedIn(evidence);
309
+ if (repos.length === 0)
310
+ failures.push(`${dir}/baseline-evidence.md: Surfaces cannot be cross-checked: no **Observed in:** line names a repository`);
311
+ for (const r of repos) {
312
+ if ((servedBy.get(r) ?? []).length === 0) {
313
+ 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`);
314
+ }
315
+ for (const c of servedBy.get(r) ?? []) {
316
+ surfacesChecked++;
317
+ if (!parsed.entries.some((e) => surfaceMatches(e.contract, c.id, c.specPath))) {
318
+ 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`);
319
+ }
320
+ }
321
+ }
322
+ }
323
+ }
324
+ }
325
+ for (const feature of await dirs(join(repo, dir, "specs"))) {
326
+ const text = (await readText(join(repo, dir, "specs", feature, "spec.md"))) ?? "";
327
+ const targets = [
328
+ ...parseRequirements(text).filter((r) => r.operation === "MODIFIED" || r.operation === "REMOVED").map((r) => r.name),
329
+ ...renamedFrom(text),
330
+ ];
331
+ for (const t of targets) {
332
+ const key = `${feature}::${t}`;
333
+ if (isBaseline) {
334
+ scopeChecked++;
335
+ const last = lastTouch.get(key);
336
+ if (last !== "baseline") {
337
+ failures.push(`${dir}: a baseline changes "${t}" (${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, and anything else takes a change (M-ADR 0222 §4.4)`);
338
+ }
339
+ }
340
+ }
341
+ for (const r of parseRequirements(text)) {
342
+ const key = `${feature}::${r.name}`;
343
+ if (r.operation === "REMOVED")
344
+ lastTouch.delete(key);
345
+ else if (r.operation)
346
+ lastTouch.set(key, isBaseline ? "baseline" : "change");
347
+ }
348
+ for (const from of renamedFrom(text))
349
+ lastTouch.delete(`${feature}::${from}`);
350
+ }
351
+ }
352
+ 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"}`;
353
+ lines.push(baselines === 0 ? "surfaces: vacuous (no baseline)" : `surfaces: ${baselines} baseline(s), ${surfacesChecked} served contract(s) checked against catalogues/repo_contract.csv${unseen}`);
354
+ 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`);
355
+ for (const [a, b] of ties)
356
+ lines.push(`order: ${a} and ${b} were archived the same day and the history does not order them; ordered by name`);
274
357
  const population = recordFiles.length + open.length + archived.length;
275
358
  const body = lines.map((l) => ` ${l}`).join("\n") + "\n";
276
359
  if (failures.length > 0) {
@@ -278,7 +361,35 @@ export async function checkBehaviourStore(repo) {
278
361
  }
279
362
  if (population === 0)
280
363
  return { exitCode: 0, stdout: body + `[N/A] the behaviour store has no changes and no agreement records yet\n` };
281
- return { exitCode: 0, stdout: body + "[PASS] behaviour store: records, agreement, stale and verdict checks hold\n" };
364
+ return { exitCode: 0, stdout: body + "[PASS] behaviour store: records, agreement, stale, verdict, surfaces and scope checks hold\n" };
365
+ }
366
+ /** Per repository, the contracts the catalogue says it serves (repo_contract ⋈ contracts.spec_path); null when either file is missing. */
367
+ async function catalogueContracts(repo) {
368
+ const rc = await readText(join(repo, "catalogues", "repo_contract.csv"));
369
+ const cs = await readText(join(repo, "catalogues", "contracts.csv"));
370
+ if (rc === null || cs === null)
371
+ return null;
372
+ const spec = new Map(parseCatalogueCsv(cs).rows.map((r) => [r["id"] ?? "", r["spec_path"] ?? ""]));
373
+ const out = new Map();
374
+ for (const r of parseCatalogueCsv(rc).rows) {
375
+ const repoId = r["repo_id"] ?? "";
376
+ const id = r["contract_id"] ?? "";
377
+ if (repoId === "" || id === "")
378
+ continue;
379
+ const list = out.get(repoId) ?? [];
380
+ list.push({ id, specPath: spec.get(id) ?? "" });
381
+ out.set(repoId, list);
382
+ }
383
+ return out;
384
+ }
385
+ async function observedInAll(repo, archiveRel, names) {
386
+ const out = [];
387
+ for (const n of names) {
388
+ const ev = await readText(join(repo, archiveRel, n, "baseline-evidence.md"));
389
+ if (ev !== null)
390
+ out.push(...observedIn(ev));
391
+ }
392
+ return out;
282
393
  }
283
394
  async function runOpenspec(repo, args) {
284
395
  try {
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "0.6.0",
3
- "built_against_sdd_cli": "2.0.0"
2
+ "version": "0.8.0",
3
+ "built_against_sdd_cli": "2.2.0"
4
4
  }
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "2.0.0",
3
- "built_against_sdd_cli": "2.0.0"
2
+ "version": "2.2.0",
3
+ "built_against_sdd_cli": "2.2.0"
4
4
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sdd-method/maintain-cli",
3
- "version": "0.6.0",
4
- "sddCliVersion": "2.0.0",
3
+ "version": "0.8.0",
4
+ "sddCliVersion": "2.2.0",
5
5
  "description": "Bring an SDD repository current and rebuild its derived model — sync, briefing, catalogue build and aggregate, pack build, context pull — plus every read-only verify verb. Ships only the code those verbs reach; it cannot author specification content. Authoring is in @sdd-method/sdd-cli.",
6
6
  "license": "UNLICENSED",
7
7
  "type": "module",