@sdd-method/maintain-cli 0.6.0 → 0.7.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.
- package/dist/lib/behaviour-store/archive.js +180 -0
- package/dist/lib/catalogue/build.js +5 -1
- package/dist/lib/catalogue/builders/events.js +12 -5
- package/dist/lib/catalogue/builders/repo-edges.js +4 -3
- package/dist/lib/catalogue/builders/repositories.js +6 -0
- package/dist/lib/catalogue/builders/requirements.js +12 -44
- package/dist/lib/catalogue/builders/service-contract.js +6 -9
- package/dist/lib/validate/behaviour-store.js +112 -1
- package/dist/maintain-version.json +2 -2
- package/dist/verify-version.json +2 -2
- package/package.json +2 -2
|
@@ -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
|
-
|
|
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,
|
|
@@ -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
|
-
//
|
|
142
|
-
|
|
143
|
-
|
|
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[
|
|
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
|
|
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,17 @@ 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 four; `spicedb` (an `authz` contract) was once missing here, and zod strips an unknown key,
|
|
37
|
+
// so a declared `.zed` schema vanished without a finding. GraphQL is not keyed in repo-config: that is its own
|
|
38
|
+
// question, on this schema.
|
|
39
|
+
export const REPO_CONFIG_CONTRACT_KINDS = ["openapi", "proto", "avro", "spicedb"];
|
|
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(),
|
|
40
46
|
})
|
|
41
47
|
.optional();
|
|
42
48
|
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
|
-
/**
|
|
108
|
-
export function
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
//
|
|
34
|
-
// "fixed" by the shared traceability helper.
|
|
35
|
-
|
|
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
|
|
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(/^\/+/, "");
|
|
@@ -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
|
|
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 {
|
package/dist/verify-version.json
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sdd-method/maintain-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"sddCliVersion": "2.
|
|
3
|
+
"version": "0.7.0",
|
|
4
|
+
"sddCliVersion": "2.1.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",
|