@sdd-method/verify-cli 1.5.0 → 2.1.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: "3.20.0"
10
+ schema_version: "4.0.0"
11
11
  schema_owner: "sdd-method"
12
12
  schema_status: "generated"
13
13
  enums:
@@ -389,7 +389,7 @@ enums:
389
389
  description: M-ADR 0159 entity persistence kind. Bound at 3.13.0 from the column descriptions; measured against both reference estates before binding (M-ADR 0205 §4.4).
390
390
  required_stability:
391
391
  values:
392
- - value: locked
392
+ - value: stable
393
393
  - value: provisional
394
394
  - value: unknown
395
395
  description: "M-ADR 0204: the stability a consumer requires of a target; external | legacy live in target_locus, not here. Bound at 3.13.0 from the column descriptions; measured against both reference estates before binding (M-ADR 0205 §4.4)."
@@ -613,6 +613,35 @@ enums:
613
613
  - value: generated-from
614
614
  description: Its content was produced from the other repository's templates.
615
615
  description: How one repository depends on another's code (M-ADR 0217 §4.1).
616
+ requirement_stage:
617
+ values:
618
+ - value: living
619
+ description: "In the living spec: shipped behaviour."
620
+ - value: agreed
621
+ description: Added by an open change whose agreement record is agreed; not yet shipped.
622
+ description: Where a catalogued requirement stands (M-ADR 0221 §4.4).
623
+ requirement_change_kind:
624
+ values:
625
+ - value: baseline
626
+ description: Recorded existing behaviour through the baseline gate.
627
+ - value: change
628
+ description: A delta agreed, built and judged through the change gate.
629
+ description: What kind of archived change a lineage row names (M-ADR 0222 §4.3).
630
+ requirement_operation:
631
+ values:
632
+ - value: ADDED
633
+ - value: MODIFIED
634
+ - value: RENAMED
635
+ description: How a change's delta treated a requirement that is current now (a removed requirement has no row).
636
+ requirement_status_value:
637
+ values:
638
+ - value: agreed
639
+ description: In an agreed, open change.
640
+ - value: declared-shipped
641
+ description: Its change archived on a passing judge, with this repository's task group complete.
642
+ - value: observed
643
+ description: Independent evidence from this repository's code matches (a baseline's characterization tests or a reverse snapshot).
644
+ description: A requirement's status in one implementing repository (M-ADR 0221 §4.5).
616
645
  catalogues:
617
646
  domains:
618
647
  primary_key: id
@@ -1276,7 +1305,7 @@ catalogues:
1276
1305
  - name: contract_provenance
1277
1306
  type: string
1278
1307
  nullable: true
1279
- description: "How this capability's declared contract SET was produced, from `traceability.contracts.provenance`. hand-authored | reverse-extracted | generated — the vocabulary the method already ships for `authoring-mode`, reused deliberately so provenance has one vocabulary and not two. Empty means undeclared, and undeclared reads as `hand-authored` by convention. Added at 3.5.0. ⚠ STRICTLY ORTHOGONAL to the contract-set maturity ladder (`traceability.contracts.status`: planned | provisional | partial | complete | locked | none): provenance never constrains status and status never constrains provenance, and `reverse-extracted` + `complete` is the legitimate pair that motivated the field. Evidence: 2 reverse-estate manifests had written `reverse-extracted` INTO contracts.status, where it is invalid, because a complete reverse-derived contract surface had no way to say both true things at once and the author picked the provenance one. A rule here that constrained maturity would re-create exactly that defect. ⚠ Note the asymmetry this column exposes and does not fix: the maturity ladder it is orthogonal to has NO catalogue column at all — `traceability.contracts.status` is validator-enforced but uncatalogued, so it is queryable by no consumer. That is the authored-but-uncatalogued class (a block with no column has no automated reader, so nothing can notice when it is absent or wrong); this column is given one precisely so the provenance half does not join it. Giving the ladder a column is a candidate follow-on, not done here."
1308
+ description: "How this capability's declared contract SET was produced, from `traceability.contracts.provenance`. hand-authored | reverse-extracted | generated — the vocabulary the method already ships for `authoring-mode`, reused deliberately so provenance has one vocabulary and not two. Empty means undeclared, and undeclared reads as `hand-authored` by convention. Added at 3.5.0. ⚠ STRICTLY ORTHOGONAL to the contract-set maturity ladder (`traceability.contracts.status`: planned | provisional | partial | complete | stable | none): provenance never constrains status and status never constrains provenance, and `reverse-extracted` + `complete` is the legitimate pair that motivated the field. Evidence: 2 reverse-estate manifests had written `reverse-extracted` INTO contracts.status, where it is invalid, because a complete reverse-derived contract surface had no way to say both true things at once and the author picked the provenance one. A rule here that constrained maturity would re-create exactly that defect. ⚠ Note the asymmetry this column exposes and does not fix: the maturity ladder it is orthogonal to has NO catalogue column at all — `traceability.contracts.status` is validator-enforced but uncatalogued, so it is queryable by no consumer. That is the authored-but-uncatalogued class (a block with no column has no automated reader, so nothing can notice when it is absent or wrong); this column is given one precisely so the provenance half does not join it. Giving the ladder a column is a candidate follow-on, not done here."
1280
1309
  - name: source
1281
1310
  type: string
1282
1311
  nullable: false
@@ -1523,7 +1552,7 @@ catalogues:
1523
1552
  - name: kind
1524
1553
  type: string
1525
1554
  nullable: false
1526
- 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 lock-in gate certified .zed schemas as contracts while no kind could represent them.
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.
1527
1556
  - name: version
1528
1557
  type: string
1529
1558
  nullable: true
@@ -1888,7 +1917,7 @@ catalogues:
1888
1917
  - name: version
1889
1918
  type: string
1890
1919
  nullable: true
1891
- description: Optional explicit version; null until the domain model is formally versioned (typically at first contract lock-in).
1920
+ description: Optional explicit version; null until the domain model is formally versioned (typically when a contract is first pinned).
1892
1921
  - name: evidence_class
1893
1922
  type: string
1894
1923
  nullable: true
@@ -2142,7 +2171,7 @@ catalogues:
2142
2171
  - name: required_stability
2143
2172
  type: string
2144
2173
  nullable: true
2145
- description: "provisional | locked | unknown. The consuming SDD's expectation of contract stability, carried verbatim from app-platform-dependencies.yaml. Not a bound enum: an out-of-vocabulary value is carried, not rejected, and is reported by validate-app-dependencies.sh."
2174
+ description: "provisional | stable | unknown. The consuming SDD's expectation of contract stability, carried verbatim from app-platform-dependencies.yaml. Not a bound enum: an out-of-vocabulary value is carried, not rejected, and is reported by validate-app-dependencies.sh."
2146
2175
  description: "Edge: product depends on / consumes service. Forward sources from app-platform-dependencies.yaml. service_id is external because the consuming product (an application-product SDD) references a service owned by the platform SDD."
2147
2176
  foreign_keys:
2148
2177
  - column: product_id
@@ -2930,7 +2959,7 @@ catalogues:
2930
2959
  - name: required_stability
2931
2960
  type: string
2932
2961
  nullable: true
2933
- description: "provisional | locked | unknown. Optional; the consuming SDD's expectation of contract stability — and only that: where the target lives and what lifecycle its implementation is in are separate concerns that do not belong in this field. Not a bound enum: an out-of-vocabulary value is carried, not rejected, and is reported by validate-app-dependencies.sh."
2962
+ description: "provisional | stable | unknown. Optional; the consuming SDD's expectation of contract stability — and only that: where the target lives and what lifecycle its implementation is in are separate concerns that do not belong in this field. Not a bound enum: an out-of-vocabulary value is carried, not rejected, and is reported by validate-app-dependencies.sh."
2934
2963
  - name: contract_path
2935
2964
  type: string
2936
2965
  nullable: true
@@ -4115,3 +4144,127 @@ catalogues:
4115
4144
  external: true
4116
4145
  column_enums:
4117
4146
  kind: enums.repo_dependency_kind
4147
+ requirements:
4148
+ primary_key: id
4149
+ columns:
4150
+ - name: id
4151
+ type: string
4152
+ nullable: false
4153
+ description: <feature_id>::<requirement name>. The name is unique within its spec and stable across changes (M-ADR 0221 §4.7).
4154
+ - name: feature_id
4155
+ type: string
4156
+ nullable: false
4157
+ description: "The catalogue feature whose living spec holds the requirement: the spec's directory name under openspec/specs/."
4158
+ - name: name
4159
+ type: string
4160
+ nullable: false
4161
+ description: The requirement's name as the spec heads it; automated tests carry it.
4162
+ - name: stage
4163
+ type: string
4164
+ nullable: false
4165
+ description: "Bound to requirement_stage: living (in the living spec) or agreed (added by an open change whose agreement record is agreed)."
4166
+ - name: spec_path
4167
+ type: string
4168
+ nullable: false
4169
+ description: "The file that holds the requirement: the living spec, or the open change's delta."
4170
+ - name: scenario_count
4171
+ type: integer
4172
+ nullable: false
4173
+ description: Scenarios under the requirement in that file.
4174
+ - name: source_sdd_id
4175
+ type: string
4176
+ nullable: true
4177
+ description: Originating SDD (stamped at aggregation; empty in a per-SDD build).
4178
+ - name: platform_sdd_id
4179
+ type: string
4180
+ nullable: true
4181
+ description: The platform SDD the row belongs to (stamped at aggregation).
4182
+ description: "A requirement in the SDD's behaviour store (M-ADR 0221): one row per requirement of a living spec, keyed by the catalogue feature the spec describes, and per requirement an open agreed change adds. Projected from openspec/ at the SDD's commit; never authored here."
4183
+ foreign_keys:
4184
+ - column: feature_id
4185
+ references: features.id
4186
+ nullable: false
4187
+ column_enums:
4188
+ stage: enums.requirement_stage
4189
+ requirement_lineage:
4190
+ primary_key: id
4191
+ columns:
4192
+ - name: id
4193
+ type: string
4194
+ nullable: false
4195
+ description: <requirement_id>__<change_id>.
4196
+ - name: requirement_id
4197
+ type: string
4198
+ nullable: false
4199
+ - name: change_id
4200
+ type: string
4201
+ nullable: false
4202
+ description: The archived change's directory name (its archive date prefix included).
4203
+ - name: change_kind
4204
+ type: string
4205
+ nullable: false
4206
+ description: "Bound to requirement_change_kind: baseline or change."
4207
+ - name: operation
4208
+ type: string
4209
+ nullable: false
4210
+ description: "Bound to requirement_operation: how the change's delta treated the requirement."
4211
+ - name: archived_on
4212
+ type: string
4213
+ nullable: false
4214
+ description: The archive date (YYYY-MM-DD) from the change's directory name.
4215
+ - name: source_sdd_id
4216
+ type: string
4217
+ nullable: true
4218
+ description: Originating SDD (stamped at aggregation; empty in a per-SDD build).
4219
+ - name: platform_sdd_id
4220
+ type: string
4221
+ nullable: true
4222
+ description: The platform SDD the row belongs to (stamped at aggregation).
4223
+ description: "Annotation: an archived change that made a current requirement what it is (M-ADR 0221 §4.6), in archive order. A baseline change (one carrying baseline-evidence.md) records existing behaviour; any other change carries a verdict."
4224
+ foreign_keys:
4225
+ - column: requirement_id
4226
+ references: requirements.id
4227
+ nullable: false
4228
+ column_enums:
4229
+ change_kind: enums.requirement_change_kind
4230
+ operation: enums.requirement_operation
4231
+ requirement_status:
4232
+ primary_key: id
4233
+ columns:
4234
+ - name: id
4235
+ type: string
4236
+ nullable: false
4237
+ description: <requirement_id>__<repo_id>.
4238
+ - name: requirement_id
4239
+ type: string
4240
+ nullable: false
4241
+ - name: repo_id
4242
+ type: string
4243
+ nullable: false
4244
+ description: "The implementing repository: a work order's repository, or a repository a baseline was observed in."
4245
+ - name: status
4246
+ type: string
4247
+ nullable: false
4248
+ description: Bound to requirement_status_value.
4249
+ - name: basis
4250
+ type: string
4251
+ nullable: false
4252
+ description: "The change that produced the status: the open change's or the archived change's directory name."
4253
+ - name: source_sdd_id
4254
+ type: string
4255
+ nullable: true
4256
+ description: Originating SDD (stamped at aggregation; empty in a per-SDD build).
4257
+ - name: platform_sdd_id
4258
+ type: string
4259
+ nullable: true
4260
+ description: The platform SDD the row belongs to (stamped at aggregation).
4261
+ description: "Edge: a requirement's status in a repository that implements it (M-ADR 0221 §4.5; M-ADR 0223 §4.7). Derived, never authored: agreed from an open agreed change's work orders, declared-shipped from an archived change's PASS verdict and its work orders, observed from a baseline's evidence. The latest event in archive order wins."
4262
+ foreign_keys:
4263
+ - column: requirement_id
4264
+ references: requirements.id
4265
+ nullable: false
4266
+ - column: repo_id
4267
+ references: repositories.id
4268
+ nullable: false
4269
+ column_enums:
4270
+ status: enums.requirement_status_value
@@ -869,3 +869,39 @@ coverage:
869
869
  kind: enum
870
870
  scoring: not-applicable
871
871
  reason: closed vocabulary, not an artefact
872
+ requirements:
873
+ kind: entity
874
+ scoring: not-applicable
875
+ reason: projected from the behaviour store, whose baseline and change gates hold its quality (M-ADR 0222)
876
+ relationships:
877
+ feature_id:
878
+ class: membership
879
+ impact_flow: toward-target
880
+ requirement_lineage:
881
+ kind: annotation
882
+ scoring: derived-edge
883
+ class: lineage
884
+ impact_flow: none
885
+ reason: derived from the archived changes scored by their own judge
886
+ requirement_status:
887
+ kind: edge
888
+ scoring: derived-edge
889
+ class: realisation
890
+ impact_flow: both
891
+ reason: derived from agreement records, verdicts and baseline evidence
892
+ requirement_stage:
893
+ kind: enum
894
+ scoring: not-applicable
895
+ reason: closed vocabulary, not an artefact
896
+ requirement_change_kind:
897
+ kind: enum
898
+ scoring: not-applicable
899
+ reason: closed vocabulary, not an artefact
900
+ requirement_operation:
901
+ kind: enum
902
+ scoring: not-applicable
903
+ reason: closed vocabulary, not an artefact
904
+ requirement_status_value:
905
+ kind: enum
906
+ scoring: not-applicable
907
+ reason: closed vocabulary, not an artefact
@@ -27,7 +27,7 @@
27
27
  * ADR 0134 §4.6 (canonical builder + aggregator).
28
28
  */
29
29
  export const SCHEMA_COMPAT = {
30
- catalogue_schema: "^3.0.0",
30
+ catalogue_schema: "^4.0.0",
31
31
  sdd_manifest: "^1.0.0",
32
32
  aggregation_manifest: "^1.0.0",
33
33
  };
@@ -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.5.0",
3
- "built_against_sdd_cli": "1.46.0"
2
+ "version": "0.7.0",
3
+ "built_against_sdd_cli": "2.1.0"
4
4
  }
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.5.0",
3
- "built_against_sdd_cli": "1.46.0"
2
+ "version": "2.1.0",
3
+ "built_against_sdd_cli": "2.1.0"
4
4
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sdd-method/verify-cli",
3
- "version": "1.5.0",
4
- "sddCliVersion": "1.46.0",
3
+ "version": "2.1.0",
4
+ "sddCliVersion": "2.1.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",
@@ -11,23 +11,23 @@
11
11
  #
12
12
  # Checks (for each entry in app-platform-dependencies.yaml), split by whether they
13
13
  # need a platform surface at all — LOCAL checks run wherever the app repo is:
14
- # 1. (local) required_stability is a valid value (provisional | locked | unknown)
14
+ # 1. (local) required_stability is a valid value (provisional | stable | unknown)
15
15
  # 2a. (local) service_id is declared and non-empty
16
16
  # 2b. (platform) service_id resolves against the platform source of truth:
17
17
  # catalogues/services.csv (exact id match) when the catalogue exists,
18
18
  # else docs/platform/service-architecture.md (word-anchored match)
19
- # 3a. (local) a `locked` dependency declares a contract_path at all
19
+ # 3a. (local) a `stable` dependency declares a contract_path at all
20
20
  # 3b. (platform) that contract_path resolves to a file under the platform root
21
21
  #
22
22
  # Outputs a dependency status table.
23
- # Used as the Stage 6 agreement gate in the App Exploration Workflow.
23
+ # Used as the Stage 9 agreement gate in the App Exploration Workflow.
24
24
  #
25
25
  # THE STABILITY VOCABULARY, AND WHY IT IS WHAT IT IS
26
26
  # `required_stability` states the CONSUMING app's expectation of contract
27
27
  # stability. Three values express that:
28
28
  #
29
29
  # provisional the contract may still change; the app tolerates it
30
- # locked the contract is fixed; a contract_path must exist (check 3)
30
+ # stable the contract is fixed; a contract_path must exist (check 3)
31
31
  # unknown the expectation is not determined
32
32
  #
33
33
  # `unknown` is accepted because the canonical schema's
@@ -158,7 +158,7 @@ APP_DEPS_FILE="$APP_ROOT/app-platform-dependencies.yaml"
158
158
  # This gate read only the forward file, so an entire equal-status source went
159
159
  # ungated. Measured in one reverse estate: 15 evidence files declaring 65
160
160
  # further dependencies, 38 of them `locked` — precisely the population
161
- # a Stage 6 agreement gate exists to check — none of which this gate could see.
161
+ # a Stage 9 agreement gate exists to check — none of which this gate could see.
162
162
  #
163
163
  # A source that the catalogue reads and the gate does not is a coverage gap by
164
164
  # construction, so the gate now reads both.
@@ -180,7 +180,7 @@ PLATFORM_SERVICES_CSV="$PLATFORM_ROOT/catalogues/services.csv"
180
180
  # offer (instances app-dependency-stability-vocabulary-forward and -reverse).
181
181
  VALID_STABILITY_VALUES=(
182
182
  "provisional" # contract may still change; the app tolerates it
183
- "locked" # contract is fixed; check 3 requires a resolvable contract_path
183
+ "stable" # contract is fixed; check 3 requires a resolvable contract_path
184
184
  "unknown" # expectation not determined; no contract_path required
185
185
  )
186
186
 
@@ -353,6 +353,13 @@ validate_dependency() {
353
353
  service_id="$(yq e ".platform_dependencies[$index].service_id // \"\"" "$source_file")"
354
354
  contract_type="$(yq e ".platform_dependencies[$index].contract_type // \"\"" "$source_file")"
355
355
  required_stability="$(yq e ".platform_dependencies[$index].required_stability // \"\"" "$source_file")"
356
+ # vocabulary-alias: locked -> stable
357
+ # The fixed-contract stability class was `locked` (M-ADR 0222 §4.1; docs/method/foundations/vocabulary-renames.yaml).
358
+ # Read as `stable` for one major release and reported; from sdd-cli 3.0.0 it is invalid.
359
+ if [[ "$required_stability" == "locked" ]]; then
360
+ log_warn "$source_file: platform_dependencies[$index] uses the former required_stability 'locked', read as 'stable' (M-ADR 0222 §4.1). Run 'sdd-cli migrate vocabulary'; from sdd-cli 3.0.0 the former value is invalid."
361
+ required_stability="stable"
362
+ fi
356
363
  contract_path="$(yq e ".platform_dependencies[$index].contract_path // \"\"" "$source_file")"
357
364
 
358
365
  local dep_label="${service_id:-dep[$index]}"
@@ -403,7 +410,7 @@ validate_dependency() {
403
410
  done
404
411
 
405
412
  if [[ "$stability_deprecated" -eq 1 ]]; then
406
- log_fail "$dep_label: required_stability '$required_stability' is not a stability value (M-ADR 0204 §4.4). Declare target_locus: '$required_stability' on the entry and give required_stability a real value (${VALID_STABILITY_VALUES[*]}) — a third-party contract can be locked."
413
+ log_fail "$dep_label: required_stability '$required_stability' is not a stability value (M-ADR 0204 §4.4). Declare target_locus: '$required_stability' on the entry and give required_stability a real value (${VALID_STABILITY_VALUES[*]}) — a third-party contract can be stable."
407
414
  ((dep_errors++))
408
415
  else
409
416
  log_fail "$dep_label: invalid required_stability '$required_stability' (allowed: ${VALID_STABILITY_VALUES[*]})"
@@ -490,26 +497,26 @@ validate_dependency() {
490
497
  ((WARNINGS++))
491
498
  fi
492
499
 
493
- # Check 3: a locked dependency declares a contract_path (3a, local) and that path
494
- # resolves to a file (3b, needs the platform). Same split, same reason: "locked
500
+ # Check 3: a stable dependency declares a contract_path (3a, local) and that path
501
+ # resolves to a file (3b, needs the platform). Same split, same reason: "stable
495
502
  # with no path at all" is an app-repo defect that needs nothing external to catch.
496
- if [[ "$required_stability" == "locked" ]]; then
503
+ if [[ "$required_stability" == "stable" ]]; then
497
504
  if [[ -z "$contract_path" || "$contract_path" == "null" ]]; then
498
- log_fail "$dep_label: required_stability is 'locked' but contract_path is empty"
505
+ log_fail "$dep_label: required_stability is 'stable' but contract_path is empty"
499
506
  ((dep_errors++))
500
507
  elif [[ "$PLATFORM_AVAILABLE" -eq 0 ]]; then
501
508
  ((UNRESOLVED_CONTRACT_PATHS++))
502
509
  else
503
510
  local full_contract_path="$PLATFORM_ROOT/$contract_path"
504
511
  if [[ ! -f "$full_contract_path" ]]; then
505
- log_fail "$dep_label: locked contract_path '$contract_path' does not exist in platform repo"
512
+ log_fail "$dep_label: stable contract_path '$contract_path' does not exist in platform repo"
506
513
  ((dep_errors++))
507
514
  fi
508
515
  fi
509
516
  elif [[ "$required_stability" == "provisional" || "$required_stability" == "unknown" ]]; then
510
517
  # Neither requires a contract_path. If one is declared it is still worth
511
518
  # reporting when it does not resolve — silently accepting a path that
512
- # points at nothing is how a locked dependency later inherits a dead path.
519
+ # points at nothing is how a stable dependency later inherits a dead path.
513
520
  if [[ -n "$contract_path" && "$contract_path" != "null" && "$PLATFORM_AVAILABLE" -eq 1 ]]; then
514
521
  local full_contract_path="$PLATFORM_ROOT/$contract_path"
515
522
  if [[ ! -f "$full_contract_path" ]]; then
@@ -552,21 +559,22 @@ print_status_table() {
552
559
  local service_id required_stability contract_type contract_path dep_status
553
560
  service_id="$(yq e ".platform_dependencies[$i].service_id // \"(missing)\"" "$source_file")"
554
561
  required_stability="$(yq e ".platform_dependencies[$i].required_stability // \"(missing)\"" "$source_file")"
562
+ if [[ "$required_stability" == "locked" ]]; then required_stability="stable"; fi # the former name, reported above
555
563
  contract_type="$(yq e ".platform_dependencies[$i].contract_type // \"(missing)\"" "$source_file")"
556
564
  contract_path="$(yq e ".platform_dependencies[$i].contract_path // \"\"" "$source_file")"
557
565
 
558
566
  # Determine display status
559
- if [[ "$required_stability" == "locked" ]]; then
567
+ if [[ "$required_stability" == "stable" ]]; then
560
568
  if [[ -z "$contract_path" || "$contract_path" == "null" ]]; then
561
- dep_status="${RED}LOCKED UNMET${NC}"
569
+ dep_status="${RED}STABLE UNMET${NC}"
562
570
  elif [[ "$PLATFORM_AVAILABLE" -eq 0 ]]; then
563
571
  # A declared path that was never resolved is not "OK" and not "UNMET".
564
572
  # Reporting either would be a claim the run cannot support.
565
- dep_status="${YELLOW}LOCKED UNRESOLVED${NC}"
573
+ dep_status="${YELLOW}STABLE UNRESOLVED${NC}"
566
574
  elif [[ -f "$PLATFORM_ROOT/$contract_path" ]]; then
567
- dep_status="${GREEN}LOCKED OK${NC}"
575
+ dep_status="${GREEN}STABLE OK${NC}"
568
576
  else
569
- dep_status="${RED}LOCKED UNMET${NC}"
577
+ dep_status="${RED}STABLE UNMET${NC}"
570
578
  fi
571
579
  elif [[ "$required_stability" == "provisional" ]]; then
572
580
  dep_status="${YELLOW}PROVISIONAL${NC}"
@@ -637,7 +645,7 @@ main() {
637
645
  fi
638
646
 
639
647
  if [[ "$FAILURES" -gt 0 ]]; then
640
- log_fail "$FAILURES dependency check(s) failed. Stage 6 agreement gate NOT met."
648
+ log_fail "$FAILURES dependency check(s) failed. Stage 9 agreement gate NOT met."
641
649
  exit 1
642
650
  fi
643
651
 
@@ -648,7 +656,7 @@ main() {
648
656
  # while never executing (M-ADR 0200 §4.3).
649
657
  if [[ "$PLATFORM_AVAILABLE" -eq 0 ]]; then
650
658
  log_warn "Ran check 1 (stability vocabulary) and the local halves of checks 2-3 over $TOTAL_DEPS dependency(ies): $FAILURES failure(s), $WARNINGS warning(s)."
651
- log_warn "NOT RUN, for want of --platform-root: $UNRESOLVED_SERVICE_IDS service_id resolution(s) and $UNRESOLVED_CONTRACT_PATHS locked contract_path resolution(s)."
659
+ log_warn "NOT RUN, for want of --platform-root: $UNRESOLVED_SERVICE_IDS service_id resolution(s) and $UNRESOLVED_CONTRACT_PATHS stable contract_path resolution(s)."
652
660
  log_info "This is a partial run, not a met gate — pass --platform-root to complete it."
653
661
  exit 0
654
662
  fi
@@ -656,7 +664,7 @@ main() {
656
664
  if [[ "$WARNINGS" -gt 0 ]]; then
657
665
  log_warn "All required checks passed with $WARNINGS warning(s)."
658
666
  else
659
- log_pass "All dependency checks passed. Stage 6 agreement gate met."
667
+ log_pass "All dependency checks passed. Stage 9 agreement gate met."
660
668
  fi
661
669
  exit 0
662
670
  }