@sdd-method/verify-cli 2.0.0 → 2.2.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
@@ -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
@@ -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/verify-cli",
3
- "version": "2.0.0",
4
- "sddCliVersion": "2.0.0",
3
+ "version": "2.2.0",
4
+ "sddCliVersion": "2.2.0",
5
5
  "description": "Verify an SDD repository without changing it — validate, list, and read-only catalogue and pack checks. Ships only the code those verbs reach; it cannot write to the repository it is pointed at. Authoring and maintenance are in @sdd-method/sdd-cli.",
6
6
  "license": "UNLICENSED",
7
7
  "type": "module",