@sdd-method/maintain-cli 0.16.1 → 0.17.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 +28 -0
- package/dist/lib/behaviour-store/schema.js +24 -12
- package/dist/lib/catalogue/build.js +3 -0
- package/dist/lib/catalogue/builders/adrs.js +1 -1
- package/dist/lib/catalogue/builders/contracts.js +5 -0
- package/dist/lib/catalogue/builders/repositories.js +5 -3
- package/dist/lib/catalogue/canonical-schema.yaml +3 -2
- package/dist/lib/catalogue/contract-id.js +1 -1
- package/dist/lib/catalogue/impact-run.js +6 -1
- package/dist/lib/catalogue/traceability-loader.js +3 -0
- package/dist/lib/contracts-availability/index.js +4 -1
- package/dist/lib/snapshot/manifest.js +18 -2
- package/dist/lib/structure/model.js +6 -1
- package/dist/lib/structure/removals.js +121 -0
- package/dist/lib/sync/index.js +8 -5
- package/dist/lib/validate/behaviour-store.js +202 -38
- package/dist/lib/validate/structural-model.js +7 -1
- package/dist/maintain-version.json +2 -2
- package/dist/verify-version.json +2 -2
- package/package.json +2 -2
|
@@ -196,6 +196,34 @@ export function parseSurfaces(evidence) {
|
|
|
196
196
|
problems.push("the **Surfaces:** line lists no contract");
|
|
197
197
|
return { present: true, entries, problems };
|
|
198
198
|
}
|
|
199
|
+
/**
|
|
200
|
+
* A structural delta's `moved: {contracts: [{from, to, from_path?}]}` block. A contract's id is derived from its path,
|
|
201
|
+
* so moving the file changes the id; the change that moves it records the move once, and what named the old
|
|
202
|
+
* identity (an archived baseline's Surfaces list) follows it rather than being edited.
|
|
203
|
+
*/
|
|
204
|
+
export function contractMoves(raw) {
|
|
205
|
+
const moves = [];
|
|
206
|
+
const problems = [];
|
|
207
|
+
if (raw === undefined || raw === null)
|
|
208
|
+
return { moves, problems };
|
|
209
|
+
const contracts = typeof raw === "object" && !Array.isArray(raw) ? raw["contracts"] : undefined;
|
|
210
|
+
const extra = typeof raw === "object" && raw !== null && !Array.isArray(raw) ? Object.keys(raw).filter((k) => k !== "contracts") : [];
|
|
211
|
+
if (!Array.isArray(contracts) || extra.length > 0) {
|
|
212
|
+
problems.push(`moved must be {contracts: [{from: <old contract id>, to: <new contract id>, from_path?: <old spec path>}]}`);
|
|
213
|
+
return { moves, problems };
|
|
214
|
+
}
|
|
215
|
+
for (const [i, m] of contracts.entries()) {
|
|
216
|
+
const e = (m ?? {});
|
|
217
|
+
const from = typeof e["from"] === "string" ? e["from"].trim() : "";
|
|
218
|
+
const to = typeof e["to"] === "string" ? e["to"].trim() : "";
|
|
219
|
+
const fromPath = typeof e["from_path"] === "string" ? e["from_path"].trim() : undefined;
|
|
220
|
+
if (from === "" || to === "" || from === to)
|
|
221
|
+
problems.push(`moved.contracts[${i}] needs a from and a to that differ`);
|
|
222
|
+
else
|
|
223
|
+
moves.push({ from, to, ...(fromPath ? { fromPath } : {}) });
|
|
224
|
+
}
|
|
225
|
+
return { moves, problems };
|
|
226
|
+
}
|
|
199
227
|
/** Whether a Surfaces entry names a catalogue contract: its id, or a path suffix of its spec_path on a segment boundary. */
|
|
200
228
|
export function surfaceMatches(entry, contractId, specPath) {
|
|
201
229
|
const e = entry.replace(/^\.\//, "");
|
|
@@ -14,6 +14,16 @@ export const SCHEMA_NAME = "sdd-change";
|
|
|
14
14
|
export const SHIPPED_SCHEMA_DIR = "docs/method/foundations/openspec/sdd-change";
|
|
15
15
|
export const SHIPPED_CONFIG_TEMPLATE = "docs/method/foundations/openspec/config-template.yaml";
|
|
16
16
|
export const STORE_SCHEMA_DIR = `openspec/schemas/${SCHEMA_NAME}`;
|
|
17
|
+
/**
|
|
18
|
+
* The baseline schema (M-ADR 0222 §4.3-4.4): a baseline change selects it in its own .openspec.yaml, the store's
|
|
19
|
+
* config.yaml keeping sdd-change as the default. Copied and checked as sdd-change is, where the SDD's bundle
|
|
20
|
+
* ships it; a bundle from before it shipped has none, and nothing is asked of the store.
|
|
21
|
+
*/
|
|
22
|
+
export const BASELINE_SCHEMA_NAME = "sdd-baseline";
|
|
23
|
+
/** Every schema the method ships for a store, the default first. */
|
|
24
|
+
export const STORE_SCHEMAS = [SCHEMA_NAME, BASELINE_SCHEMA_NAME];
|
|
25
|
+
export const shippedSchemaDir = (name) => `docs/method/foundations/openspec/${name}`;
|
|
26
|
+
export const storeSchemaDir = (name) => `openspec/schemas/${name}`;
|
|
17
27
|
export const STORE_CONFIG = "openspec/config.yaml";
|
|
18
28
|
async function files(dir) {
|
|
19
29
|
const out = [];
|
|
@@ -50,36 +60,38 @@ export async function storeSchemaName(repo) {
|
|
|
50
60
|
const v = parseDocument(text.toString("utf8")).toJS()?.schema;
|
|
51
61
|
return typeof v === "string" ? v : "";
|
|
52
62
|
}
|
|
53
|
-
/** Compare the store's copy of
|
|
54
|
-
export async function compareStoreSchema(repo) {
|
|
55
|
-
const
|
|
63
|
+
/** Compare the store's copy of a schema with the one the SDD's method bundle shipped, file by file. */
|
|
64
|
+
export async function compareStoreSchema(repo, name = SCHEMA_NAME) {
|
|
65
|
+
const shippedDir = shippedSchemaDir(name);
|
|
66
|
+
const storeDir = storeSchemaDir(name);
|
|
67
|
+
const shipped = await files(join(repo, shippedDir));
|
|
56
68
|
if (shipped === null || shipped.length === 0)
|
|
57
69
|
return { shipped: false, missing: [], differing: [], extra: [] };
|
|
58
|
-
const store = (await files(join(repo,
|
|
70
|
+
const store = (await files(join(repo, storeDir))) ?? [];
|
|
59
71
|
const missing = shipped.filter((f) => !store.includes(f));
|
|
60
72
|
const extra = store.filter((f) => !shipped.includes(f));
|
|
61
73
|
const differing = [];
|
|
62
74
|
for (const f of shipped.filter((x) => store.includes(x))) {
|
|
63
|
-
const a = await read(join(repo,
|
|
64
|
-
const b = await read(join(repo,
|
|
75
|
+
const a = await read(join(repo, shippedDir, f));
|
|
76
|
+
const b = await read(join(repo, storeDir, f));
|
|
65
77
|
if (a === null || b === null || !a.equals(b))
|
|
66
78
|
differing.push(f);
|
|
67
79
|
}
|
|
68
80
|
return { shipped: true, missing, differing, extra };
|
|
69
81
|
}
|
|
70
82
|
/**
|
|
71
|
-
* Replace the store's copy of
|
|
83
|
+
* Replace the store's copy of a schema with the shipped one. Returns what changed, as a
|
|
72
84
|
* comparison taken before the write; a store already current returns empty lists.
|
|
73
85
|
*/
|
|
74
|
-
export async function writeStoreSchema(repo) {
|
|
75
|
-
const before = await compareStoreSchema(repo);
|
|
86
|
+
export async function writeStoreSchema(repo, name = SCHEMA_NAME) {
|
|
87
|
+
const before = await compareStoreSchema(repo, name);
|
|
76
88
|
if (!before.shipped)
|
|
77
89
|
return before;
|
|
78
90
|
if (before.missing.length + before.differing.length + before.extra.length === 0)
|
|
79
91
|
return before;
|
|
80
|
-
await rm(join(repo,
|
|
81
|
-
await mkdir(join(repo,
|
|
82
|
-
await cp(join(repo,
|
|
92
|
+
await rm(join(repo, storeSchemaDir(name)), { recursive: true, force: true });
|
|
93
|
+
await mkdir(join(repo, storeSchemaDir(name)), { recursive: true });
|
|
94
|
+
await cp(join(repo, shippedSchemaDir(name)), join(repo, storeSchemaDir(name)), { recursive: true });
|
|
83
95
|
return before;
|
|
84
96
|
}
|
|
85
97
|
//# sourceMappingURL=schema.js.map
|
|
@@ -394,6 +394,9 @@ async function collectRows(schema, manifest, findings, sddPath, snapshotStore) {
|
|
|
394
394
|
knownDomainIds,
|
|
395
395
|
profile: manifest.profile,
|
|
396
396
|
category: manifest.category,
|
|
397
|
+
domainExternal: schema.catalogues
|
|
398
|
+
.find((c) => c.name === "adrs")
|
|
399
|
+
?.foreignKeys.some((fk) => fk.column === "domain_id" && (fk.external || (fk.externalForProfiles ?? []).includes(manifest.profile))) ?? false,
|
|
397
400
|
});
|
|
398
401
|
const supersedes = buildAdrSupersedes({ findings, adrs: adrResult.parsed });
|
|
399
402
|
out.set("adr_supersedes", supersedes.rows);
|
|
@@ -88,7 +88,7 @@ export async function buildAdrs(inputs) {
|
|
|
88
88
|
inputs.findings.add("convention", `${relPath}: **Effective:** is "${effectiveRaw}", neither "on acceptance" nor "on archive of <change>"; read as on acceptance (M-ADR 0230 §4.2)`);
|
|
89
89
|
}
|
|
90
90
|
const effectiveChange = onArchive?.[1] ?? "";
|
|
91
|
-
if (scope === "domain" && !inputs.knownDomainIds.has(domainId)) {
|
|
91
|
+
if (scope === "domain" && !inputs.knownDomainIds.has(domainId) && !inputs.domainExternal) {
|
|
92
92
|
inputs.findings.add("orphan", `${relPath}: ADR is in domain "${domainId}" but no docs/domains/${domainId}/ exists`);
|
|
93
93
|
}
|
|
94
94
|
// Method-scoped ADRs are cross-cutting governance and belong to no
|
|
@@ -58,6 +58,10 @@ const KIND_SPECS = [
|
|
|
58
58
|
// name as their surface (M-ADR 0222 §4.4). Reachable from every path at once, unlike graphql above: discovered here, mapped from a
|
|
59
59
|
// traceability manifest's `cli:` block (DECLARED_KINDS), loaded (CONTRACT_LIST_KEYS) and keyed by repo-config.
|
|
60
60
|
{ subdir: "cli", extensions: [".yaml", ".yml"], kind: "cli" },
|
|
61
|
+
// `ui` — an embeddable UI surface's manifest (schema 7.2.0): its custom elements and the messages it sends and
|
|
62
|
+
// receives. A UI-only repository that other applications load serves no other contract, so its behaviour
|
|
63
|
+
// baselines had nothing to name as their surface (M-ADR 0222 §4.4). Reachable from every path, as `cli` is.
|
|
64
|
+
{ subdir: "ui", extensions: [".yaml", ".yml"], kind: "ui" },
|
|
61
65
|
];
|
|
62
66
|
export async function buildContracts(inputs) {
|
|
63
67
|
const rows = [];
|
|
@@ -318,6 +322,7 @@ const DECLARED_KINDS = {
|
|
|
318
322
|
spicedb: "authz",
|
|
319
323
|
graphql: "graphql",
|
|
320
324
|
cli: "cli",
|
|
325
|
+
ui: "ui",
|
|
321
326
|
};
|
|
322
327
|
export function buildDeclaredExternalContracts(inputs) {
|
|
323
328
|
const rows = [];
|
|
@@ -33,10 +33,11 @@ const LOC_REPORT_REL = join("docs", "platform", "reference", "loc-report.csv");
|
|
|
33
33
|
const REPOS_DIR_REL = "repos";
|
|
34
34
|
const REVERSE_METADATA_BASENAME = "reverse-metadata.yaml";
|
|
35
35
|
// The contract kinds a repo-config `contracts:` block keys, under `contracts/<kind>/`. The sdd-index reader
|
|
36
|
-
// accepts the same
|
|
36
|
+
// accepts the same six; `spicedb` (an `authz` contract) was once missing here, and zod strips an unknown key,
|
|
37
37
|
// so a declared `.zed` schema vanished without a finding. `cli` (schema 4.1.0) declares the command manifest a
|
|
38
|
-
// command-line repository serves
|
|
39
|
-
|
|
38
|
+
// command-line repository serves, and `ui` (schema 7.2.0) the manifest of an embeddable UI surface a UI-only
|
|
39
|
+
// repository serves. GraphQL is not keyed in repo-config: that is its own question, on this schema.
|
|
40
|
+
export const REPO_CONFIG_CONTRACT_KINDS = ["openapi", "proto", "avro", "spicedb", "cli", "ui"];
|
|
40
41
|
const ContractsBlockSchema = z
|
|
41
42
|
.object({
|
|
42
43
|
openapi: z.array(z.string()).optional(),
|
|
@@ -44,6 +45,7 @@ const ContractsBlockSchema = z
|
|
|
44
45
|
avro: z.array(z.string()).optional(),
|
|
45
46
|
spicedb: z.array(z.string()).optional(),
|
|
46
47
|
cli: z.array(z.string()).optional(),
|
|
48
|
+
ui: z.array(z.string()).optional(),
|
|
47
49
|
})
|
|
48
50
|
.optional();
|
|
49
51
|
const EventEntrySchema = z.object({
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
# Emitted by sdd-cli's YAML emitter with no line folding; the three header scalars are quoted for the
|
|
8
8
|
# textual readers downstream. The one deliberate difference from the authored form is schema_status:
|
|
9
9
|
# the authored form says canonical; a projection never does.
|
|
10
|
-
schema_version: "7.
|
|
10
|
+
schema_version: "7.2.0"
|
|
11
11
|
schema_owner: "sdd-method"
|
|
12
12
|
schema_status: "generated"
|
|
13
13
|
enums:
|
|
@@ -364,6 +364,7 @@ enums:
|
|
|
364
364
|
- value: rpc
|
|
365
365
|
- value: authz
|
|
366
366
|
- value: cli
|
|
367
|
+
- value: ui
|
|
367
368
|
description: Contract kind. Bound at 3.13.0 from the column descriptions; measured against both reference estates before binding (M-ADR 0205 §4.4).
|
|
368
369
|
contract_status:
|
|
369
370
|
values:
|
|
@@ -1791,7 +1792,7 @@ catalogues:
|
|
|
1791
1792
|
- name: kind
|
|
1792
1793
|
type: string
|
|
1793
1794
|
nullable: false
|
|
1794
|
-
description: rest | graphql | rpc | event | authz | cli. rpc covers Proto/gRPC; event covers Avro schemas; authz covers authorization/policy schemas (SpiceDB .zed and equivalents), added because an adopter contract agreement gate certified .zed schemas as contracts while no kind could represent them; cli covers a command-line interface's command manifest (its commands, options and exit codes), added at 4.1.0 because a command-line tool often serves no REST, RPC, event or authorization contract, so its behaviour baselines had nothing to name as their surface.
|
|
1795
|
+
description: rest | graphql | rpc | event | authz | cli | ui. rpc covers Proto/gRPC; event covers Avro schemas; authz covers authorization/policy schemas (SpiceDB .zed and equivalents), added because an adopter contract agreement gate certified .zed schemas as contracts while no kind could represent them; cli covers a command-line interface's command manifest (its commands, options and exit codes), added at 4.1.0 because a command-line tool often serves no REST, RPC, event or authorization contract, so its behaviour baselines had nothing to name as their surface; ui covers an embeddable UI surface's manifest (its custom elements with their attributes, properties and emitted events, and the messages it sends and receives), added at 7.2.0 because a UI-only repository that other applications load serves no other contract, so its behaviour baselines had nothing to name as their surface.
|
|
1795
1796
|
- name: version
|
|
1796
1797
|
type: string
|
|
1797
1798
|
nullable: true
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// M-ADR 0203 §4.4, the cut's acid test.
|
|
3
3
|
//
|
|
4
4
|
// From 3.0.0 a contract id carries its KIND component: `{domain}-{stem}-{kind}`
|
|
5
|
-
// (kind ∈ rest | graphql | rpc | event | authz | cli). The kindless 2.x form made one
|
|
5
|
+
// (kind ∈ rest | graphql | rpc | event | authz | cli | ui). The kindless 2.x form made one
|
|
6
6
|
// measured collision class unrepresentable: a service published as two contract
|
|
7
7
|
// families (`calendar-service.yaml` + `calendar_service.proto`) minted one id,
|
|
8
8
|
// costing a live estate one row and two edges permanently, and mislabelling
|
|
@@ -103,7 +103,12 @@ export function buildImpactPropagation(edges, semantics) {
|
|
|
103
103
|
for (const [key, rec] of pairs) {
|
|
104
104
|
const abOnly = [...rec.ab].filter((t) => !rec.ba.has(t));
|
|
105
105
|
const baOnly = [...rec.ba].filter((t) => !rec.ab.has(t));
|
|
106
|
-
|
|
106
|
+
// Every opposing type a foreign key of ONE catalogue: an entity-FK step runs between a row and what it
|
|
107
|
+
// references, so such a pair is one row naming the same node in two of its columns, a self-reference (an
|
|
108
|
+
// entity related to itself), and the opposition is that row's own two ends meeting, not drift.
|
|
109
|
+
const catalogues = new Set([...abOnly, ...baOnly].map((t) => (t.includes(".") ? t.slice(0, t.indexOf(".")) : `\u0000${t}`)));
|
|
110
|
+
const selfReference = catalogues.size === 1 && !([...catalogues][0].startsWith("\u0000"));
|
|
111
|
+
if (abOnly.length > 0 && baOnly.length > 0 && !selfReference) {
|
|
107
112
|
const [a, b] = key.split("␟");
|
|
108
113
|
throw new Error(`impact: counter-directional reciprocal pair (M-ADR 0206 §4.4): ` +
|
|
109
114
|
`${abOnly[0]} propagates ${a} -> ${b} while ${baOnly[0]} propagates ` +
|
|
@@ -48,6 +48,7 @@ export const CONTRACT_LIST_KEYS = [
|
|
|
48
48
|
"spicedb",
|
|
49
49
|
"graphql",
|
|
50
50
|
"cli",
|
|
51
|
+
"ui",
|
|
51
52
|
];
|
|
52
53
|
/**
|
|
53
54
|
* Every contract path a contracts block declares, across all kinds and both
|
|
@@ -283,6 +284,7 @@ function extractContracts(raw) {
|
|
|
283
284
|
const spicedb = arr("spicedb");
|
|
284
285
|
const graphql = arr("graphql");
|
|
285
286
|
const cli = arr("cli");
|
|
287
|
+
const ui = arr("ui");
|
|
286
288
|
// `provenance` (3.5.0) MUST be extracted here or the builder that reads it
|
|
287
289
|
// sees nothing — this function reconstructs the block key-by-key rather than
|
|
288
290
|
// passing the parsed object through, which is precisely the shape the
|
|
@@ -300,6 +302,7 @@ function extractContracts(raw) {
|
|
|
300
302
|
...(spicedb ? { spicedb } : {}),
|
|
301
303
|
...(graphql ? { graphql } : {}),
|
|
302
304
|
...(cli ? { cli } : {}),
|
|
305
|
+
...(ui ? { ui } : {}),
|
|
303
306
|
...(provenance ? { provenance } : {}),
|
|
304
307
|
};
|
|
305
308
|
}
|
|
@@ -116,7 +116,7 @@ async function collectReferences(repo) {
|
|
|
116
116
|
const block = m.traceability.contracts;
|
|
117
117
|
if (!block)
|
|
118
118
|
continue;
|
|
119
|
-
for (const list of [block.openapi, block.proto, block.avro, block.spicedb, block.graphql, block.cli]) {
|
|
119
|
+
for (const list of [block.openapi, block.proto, block.avro, block.spicedb, block.graphql, block.cli, block.ui]) {
|
|
120
120
|
if (!Array.isArray(list))
|
|
121
121
|
continue;
|
|
122
122
|
for (const entry of list) {
|
|
@@ -201,6 +201,9 @@ export function inferKind(ref) {
|
|
|
201
201
|
// repository whose own name ends in -cli stays rest.
|
|
202
202
|
if (/(^|\/)cli\/.*\.ya?ml$/.test(ref))
|
|
203
203
|
return "cli";
|
|
204
|
+
// A UI surface manifest lives under a `ui/` contracts folder, tested after rest for the same reason.
|
|
205
|
+
if (/(^|\/)ui\/.*\.ya?ml$/.test(ref))
|
|
206
|
+
return "ui";
|
|
204
207
|
return undefined;
|
|
205
208
|
}
|
|
206
209
|
/** The SDD's attested external artefacts (`.sdd/contract-availability.yaml`); none when it has no manifest. */
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { createHash } from "node:crypto";
|
|
10
10
|
import { readdir, readFile, stat } from "node:fs/promises";
|
|
11
11
|
import { join, relative, sep } from "node:path";
|
|
12
|
-
import {
|
|
12
|
+
import { isScalar, parseDocument, visit } from "yaml";
|
|
13
13
|
/** The line every later verb prints while a baseline carries unresolved credential findings. */
|
|
14
14
|
export function credentialNotice(m) {
|
|
15
15
|
const s = m.credential_scan;
|
|
@@ -60,6 +60,22 @@ export async function exists(p) {
|
|
|
60
60
|
return false;
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* Parse snapshot YAML with every `*revision` value read as the text written. A revision is an identifier, and YAML
|
|
65
|
+
* 1.2 reads an unquoted all-digit one as an integer: `08985180587` became 8985180587, losing the leading zero,
|
|
66
|
+
* so a pin no longer matched its folder's `@<rev>`. Writers quote such a value; this covers files written by hand.
|
|
67
|
+
*/
|
|
68
|
+
export function parseSnapshotYaml(text) {
|
|
69
|
+
const doc = parseDocument(text);
|
|
70
|
+
visit(doc, {
|
|
71
|
+
Pair(_, pair) {
|
|
72
|
+
if (isScalar(pair.key) && /(^|_)revision$/.test(String(pair.key.value)) && isScalar(pair.value) && typeof pair.value.value !== "string" && pair.value.value !== null) {
|
|
73
|
+
pair.value.value = pair.value.source ?? String(pair.value.value);
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
return doc.toJS();
|
|
78
|
+
}
|
|
63
79
|
export function manifestPath(areaRoot, id) {
|
|
64
80
|
return join(areaRoot, id, "snapshot.yaml");
|
|
65
81
|
}
|
|
@@ -67,7 +83,7 @@ export async function readManifest(areaRoot, id) {
|
|
|
67
83
|
const p = manifestPath(areaRoot, id);
|
|
68
84
|
if (!(await exists(p)))
|
|
69
85
|
throw new SnapshotError(`no manifest at ${p}`);
|
|
70
|
-
const raw =
|
|
86
|
+
const raw = parseSnapshotYaml(await readFile(p, "utf8"));
|
|
71
87
|
if (raw === null || typeof raw !== "object")
|
|
72
88
|
throw new SnapshotError(`${p}: not a mapping`);
|
|
73
89
|
const problems = [];
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// openspec/changes/<change>/structure/none.yaml a change's typed claim of no structural effect
|
|
6
6
|
// An element is a catalogue row of a `structure` catalogue, keyed by its immutable id. Its revision is the
|
|
7
7
|
// SHA-256 of its canonical JSON, so two readers agree on it without agreeing on anything else.
|
|
8
|
+
import { contractMoves } from "../behaviour-store/archive.js";
|
|
8
9
|
import { createHash } from "node:crypto";
|
|
9
10
|
import { readdir, readFile } from "node:fs/promises";
|
|
10
11
|
import { join } from "node:path";
|
|
@@ -193,9 +194,12 @@ export async function loadChangeStructure(repo, change, problems) {
|
|
|
193
194
|
removed.push({ catalogue: r.catalogue, id: r.id, reason: typeof reason === "string" ? reason : "" });
|
|
194
195
|
}
|
|
195
196
|
for (const k of Object.keys(m)) {
|
|
196
|
-
if (!["schema_version", "partition", "depends_on", "ADDED", "MODIFIED", "REMOVED", "base"].includes(k))
|
|
197
|
+
if (!["schema_version", "partition", "depends_on", "ADDED", "MODIFIED", "REMOVED", "base", "moved"].includes(k))
|
|
197
198
|
problems.push(`${file}: unknown key ${k}`);
|
|
198
199
|
}
|
|
200
|
+
const moved = contractMoves(m["moved"]);
|
|
201
|
+
for (const p of moved.problems)
|
|
202
|
+
problems.push(`${file}: ${p}`);
|
|
199
203
|
deltas.push({
|
|
200
204
|
change,
|
|
201
205
|
partition,
|
|
@@ -205,6 +209,7 @@ export async function loadChangeStructure(repo, change, problems) {
|
|
|
205
209
|
modified: readElements(m["MODIFIED"], `${file}: MODIFIED`, partition, file, problems),
|
|
206
210
|
removed,
|
|
207
211
|
base,
|
|
212
|
+
...(moved.moves.length > 0 ? { moved: moved.moves } : {}),
|
|
208
213
|
});
|
|
209
214
|
}
|
|
210
215
|
return { change, deltas, noOp, present: true };
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// A removed id against what references it outside the view (M-ADR 0230 §4.3, §4.11 negative proof 1: "a removal
|
|
2
|
+
// against a reference to it"). The view's own references are checked by validateView; these are the rest:
|
|
3
|
+
// this SDD its catalogues built from its records, not its model: an archived change's work orders and declared
|
|
4
|
+
// assertions name repositories, and a row whose target is removed drops from the next build
|
|
5
|
+
// members the catalogues of the SDDs its aggregation manifest names, where they are checked out: a member's row
|
|
6
|
+
// naming the removed id, and no row of its own holding it, dangles in the aggregate
|
|
7
|
+
// A rename is not a removal: ids never change, so a rename keeps the id and modifies the element's name.
|
|
8
|
+
import { access, readFile } from "node:fs/promises";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { parseDocument } from "yaml";
|
|
11
|
+
import { loadAggregationManifest, DEFAULT_AGGREGATION_MANIFEST_REL } from "../catalogue/aggregation-manifest-loader.js";
|
|
12
|
+
import { CLI_VERSION } from "../catalogue/cli-version.js";
|
|
13
|
+
import { parseCatalogueCsv } from "../catalogue/csv-reader.js";
|
|
14
|
+
async function exists(p) {
|
|
15
|
+
try {
|
|
16
|
+
await access(p);
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** A catalogue directory's rows, read once each. */
|
|
24
|
+
function reader(dir) {
|
|
25
|
+
const cache = new Map();
|
|
26
|
+
return async (catalogue) => {
|
|
27
|
+
if (!cache.has(catalogue)) {
|
|
28
|
+
let rows = null;
|
|
29
|
+
try {
|
|
30
|
+
rows = parseCatalogueCsv(await readFile(join(dir, `${catalogue}.csv`), "utf8")).rows;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
rows = null;
|
|
34
|
+
}
|
|
35
|
+
cache.set(catalogue, rows);
|
|
36
|
+
}
|
|
37
|
+
return cache.get(catalogue) ?? null;
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/** `<catalogue>.<column> (<n>)` for each foreign key into `<target>.id` whose rows name `id`. */
|
|
41
|
+
async function referencesTo(schema, rows, target, id, include) {
|
|
42
|
+
const out = [];
|
|
43
|
+
for (const c of schema.catalogues) {
|
|
44
|
+
if (!include(c.name))
|
|
45
|
+
continue;
|
|
46
|
+
for (const fk of c.foreignKeys) {
|
|
47
|
+
if (fk.references !== `${target}.id`)
|
|
48
|
+
continue;
|
|
49
|
+
const rs = await rows(c.name);
|
|
50
|
+
const n = rs?.filter((r) => r[fk.column] === id).length ?? 0;
|
|
51
|
+
if (n > 0)
|
|
52
|
+
out.push(`${c.name}.${fk.column} (${n})`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
/** The aggregation manifest's path: .sdd/sdd-manifest.yaml#aggregation_manifest_path, or the default. */
|
|
58
|
+
async function manifestPath(repo) {
|
|
59
|
+
try {
|
|
60
|
+
const m = parseDocument(await readFile(join(repo, ".sdd", "sdd-manifest.yaml"), "utf8")).toJS();
|
|
61
|
+
if (typeof m?.aggregation_manifest_path === "string" && m.aggregation_manifest_path !== "")
|
|
62
|
+
return join(repo, m.aggregation_manifest_path);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// the manifest check reports an unparseable manifest
|
|
66
|
+
}
|
|
67
|
+
return join(repo, DEFAULT_AGGREGATION_MANIFEST_REL);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Each open change's removals against their references outside the view. Returns the findings, one per removal with
|
|
71
|
+
* references, and the lines saying what was not looked at.
|
|
72
|
+
*/
|
|
73
|
+
export async function checkRemovals(repo, schema, structures) {
|
|
74
|
+
const findings = [];
|
|
75
|
+
const lines = [];
|
|
76
|
+
const removals = structures.flatMap((s) => s.deltas.flatMap((d) => d.removed.map((r) => ({ change: s.change, file: d.file, ...r }))));
|
|
77
|
+
if (removals.length === 0)
|
|
78
|
+
return { findings, lines };
|
|
79
|
+
const fromModel = new Set(schema.catalogues.filter((c) => c.sourceOfTruth === "structure").map((c) => c.name));
|
|
80
|
+
const own = reader(join(repo, "catalogues"));
|
|
81
|
+
const members = [];
|
|
82
|
+
const path = await manifestPath(repo);
|
|
83
|
+
if (await exists(path)) {
|
|
84
|
+
try {
|
|
85
|
+
const manifest = await loadAggregationManifest(path, { cliVersion: CLI_VERSION, ecosystemRootPath: repo });
|
|
86
|
+
const absent = [];
|
|
87
|
+
for (const s of manifest.siblings) {
|
|
88
|
+
if (await exists(s.cataloguesPath))
|
|
89
|
+
members.push({ sddId: s.sddId, rows: reader(s.cataloguesPath) });
|
|
90
|
+
else
|
|
91
|
+
absent.push(s.sddId);
|
|
92
|
+
}
|
|
93
|
+
if (absent.length > 0)
|
|
94
|
+
lines.push(`removed ids: ${absent.length} of ${manifest.siblings.length} member SDD(s) not checked, no checkout of their catalogues (${absent.join(", ")})`);
|
|
95
|
+
}
|
|
96
|
+
catch (e) {
|
|
97
|
+
lines.push(`removed ids: member SDDs not checked (${String(e.message).split("\n")[0]})`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
for (const r of removals) {
|
|
101
|
+
const where = [];
|
|
102
|
+
const mine = await referencesTo(schema, own, r.catalogue, r.id, (c) => !fromModel.has(c));
|
|
103
|
+
if (mine.length > 0)
|
|
104
|
+
where.push(`this SDD's ${mine.join(", ")}`);
|
|
105
|
+
for (const m of members) {
|
|
106
|
+
// A member that holds the id itself resolves its references to its own row.
|
|
107
|
+
if ((await m.rows(r.catalogue))?.some((row) => row.id === r.id))
|
|
108
|
+
continue;
|
|
109
|
+
const theirs = await referencesTo(schema, m.rows, r.catalogue, r.id, () => true);
|
|
110
|
+
if (theirs.length > 0)
|
|
111
|
+
where.push(`${m.sddId}'s ${theirs.join(", ")}`);
|
|
112
|
+
}
|
|
113
|
+
if (where.length === 0)
|
|
114
|
+
continue;
|
|
115
|
+
findings.push(`${r.file}: removes ${r.catalogue}/${r.id}, which rows outside the model still reference: ${where.join("; ")}. ` +
|
|
116
|
+
`A rename keeps the id and modifies the element's name (ids never change, M-ADR 0230 §4.3); a removal needs ` +
|
|
117
|
+
`those references gone, or each row naming it drops from the next build and dangles in the aggregate`);
|
|
118
|
+
}
|
|
119
|
+
return { findings, lines };
|
|
120
|
+
}
|
|
121
|
+
//# sourceMappingURL=removals.js.map
|
package/dist/lib/sync/index.js
CHANGED
|
@@ -16,7 +16,7 @@ import { syncBriefingFiles } from "./briefing-merge.js";
|
|
|
16
16
|
import { DEFAULT_AGENT_HARNESS } from "../agent-harness.js";
|
|
17
17
|
import { BLOCK_BEGIN, BLOCK_END } from "../init/ci-workflow.js";
|
|
18
18
|
import { blockSha256, CI_BINDINGS, DEFAULT_CI_BINDING, isCiBinding, parseCiRunner, syncCiBinding, } from "../ci-binding/index.js";
|
|
19
|
-
import { SCHEMA_NAME,
|
|
19
|
+
import { SCHEMA_NAME, STORE_SCHEMAS, storeSchemaDir, storeSchemaName, writeStoreSchema } from "../behaviour-store/schema.js";
|
|
20
20
|
import { BRIEFING_TEMPLATE_ROOT, WRAPPER_BLOCK_BEGIN, WRAPPER_BLOCK_END, briefingWrappers, } from "../briefing/index.js";
|
|
21
21
|
const SECTION_RULE = "═".repeat(59);
|
|
22
22
|
/**
|
|
@@ -597,11 +597,14 @@ export async function runSync(opts, stdout = (line) => process.stdout.write(line
|
|
|
597
597
|
// docs/method/foundations/openspec/, and OpenSpec reads only the store's copy, so a store inside this
|
|
598
598
|
// SDD that selects the schema gets the shipped one. The copy is method-managed (the behaviour-store
|
|
599
599
|
// validator fails a drifted copy); adopter guidance belongs in the store's config.yaml rules.
|
|
600
|
+
// The baseline schema (sdd-baseline) is refreshed beside it wherever the bundle ships it.
|
|
600
601
|
if ((await storeSchemaName(opts.targetRepo)) === SCHEMA_NAME) {
|
|
601
|
-
const
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
602
|
+
for (const name of STORE_SCHEMAS) {
|
|
603
|
+
const c = await writeStoreSchema(opts.targetRepo, name);
|
|
604
|
+
const changed = [...c.missing, ...c.differing, ...c.extra];
|
|
605
|
+
if (c.shipped && changed.length > 0) {
|
|
606
|
+
stdout(info(`Refreshed ${storeSchemaDir(name)}/ from the shipped schema: ${changed.length} file(s) changed (${changed.join(", ")})`));
|
|
607
|
+
}
|
|
605
608
|
}
|
|
606
609
|
}
|
|
607
610
|
// Record the wrapper block and the module exactly as they now stand on disk, so the next sync
|
|
@@ -34,13 +34,17 @@
|
|
|
34
34
|
// change was withdrawn or replaced (M-ADR 0222 §4.6).
|
|
35
35
|
// 5. surfaces: every archived baseline's evidence lists, under **Surfaces:**, each contract the
|
|
36
36
|
// repositories it was observed in serve, as covered at transport or excluded with a
|
|
37
|
-
// reason, and the list is checked against the catalogue (catalogues/repo_contract.csv
|
|
38
|
-
//
|
|
37
|
+
// reason, and the list is checked against the catalogue (catalogues/repo_contract.csv, less
|
|
38
|
+
// a contract service_contract records the repository's services consuming and none exposing:
|
|
39
|
+
// repo_contract has no direction), never against itself (M-ADR 0222 §4.3, §4.4). A list the catalogue gives nothing to
|
|
39
40
|
// check against (no Observed in line, or a repository with no catalogue contracts) fails:
|
|
40
41
|
// a cross-check of zero contracts is unresolved, not a pass.
|
|
41
42
|
// A baseline whose every requirement has since left the living specs (removed, or its
|
|
42
43
|
// spec retired) is history: its list is not checked, so a retired surface can drop its
|
|
43
|
-
// contract (M-ADR 0222 §4.4).
|
|
44
|
+
// contract (M-ADR 0222 §4.4). A baseline archived where the SDD ships record schema 1.4.0
|
|
45
|
+
// or later records its gate's PASS (**Baseline gate:**), so `change archive` refuses one
|
|
46
|
+
// that has not passed (M-ADR 0222 §4.3). An open baseline is asked these questions, and
|
|
47
|
+
// what it may change and which features key it, as warnings while it can still be fixed.
|
|
44
48
|
// 7. features: every living spec is keyed by a catalogue feature (M-ADR 0221 §4.7). A spec keyed by no
|
|
45
49
|
// feature is dropped from the catalogue by the requirements builder, which reports it only as a
|
|
46
50
|
// build finding; this check makes it a failure, so a shipped requirement cannot vanish from the
|
|
@@ -69,11 +73,11 @@ import { tmpdir } from "node:os";
|
|
|
69
73
|
import { basename, join } from "node:path";
|
|
70
74
|
import { promisify } from "node:util";
|
|
71
75
|
import { parseDocument } from "yaml";
|
|
72
|
-
import { observedIn, orderArchives, parseRequirements, parseSurfaces, renamedFrom, renamePairs, resolveImplements, surfaceMatches } from "../behaviour-store/archive.js";
|
|
76
|
+
import { contractMoves, observedIn, orderArchives, parseRequirements, parseSurfaces, renamedFrom, renamePairs, resolveImplements, surfaceMatches } from "../behaviour-store/archive.js";
|
|
73
77
|
import { parseCatalogueCsv } from "../catalogue/csv-reader.js";
|
|
74
78
|
import { boundFile, conditionProblems, structuralFile, isCommit, normaliseBound, requirementBlock, sameCommit, scenarioKind, taskGroups, testPlanHeader, testPlanMapping, verdictLine, versionAtLeast } from "./agreement-evidence.js";
|
|
75
79
|
import { ENFORCED as STRUCTURE_ENFORCED } from "./structural-model.js";
|
|
76
|
-
import { compareStoreSchema, SCHEMA_NAME, SHIPPED_SCHEMA_DIR, STORE_CONFIG, STORE_SCHEMA_DIR, storeSchemaName } from "../behaviour-store/schema.js";
|
|
80
|
+
import { BASELINE_SCHEMA_NAME, compareStoreSchema, SCHEMA_NAME, SHIPPED_SCHEMA_DIR, shippedSchemaDir, STORE_CONFIG, STORE_SCHEMA_DIR, storeSchemaDir, storeSchemaName } from "../behaviour-store/schema.js";
|
|
77
81
|
const execFileAsync = promisify(execFile);
|
|
78
82
|
/** The OpenSpec release the method depends on (M-ADR 0223 ruling 2). The module installs exactly this. */
|
|
79
83
|
export const OPENSPEC_VERSION = "1.14.3";
|
|
@@ -828,8 +832,107 @@ export async function checkBehaviourStore(repo, options = {}) {
|
|
|
828
832
|
}
|
|
829
833
|
const orderDecides = (name, key) => [...(peers.get(name) ?? [])].some((p) => p !== name && touches.get(p)?.has(key));
|
|
830
834
|
let scopeUnresolved = 0;
|
|
831
|
-
const
|
|
835
|
+
const openBaselines = [];
|
|
836
|
+
for (const change of open)
|
|
837
|
+
if (await exists(join(repo, STORE_DIR, "changes", change, "baseline-evidence.md")))
|
|
838
|
+
openBaselines.push(change);
|
|
839
|
+
const servedBy = baselines + openBaselines.length > 0 ? await catalogueContracts(repo) : null;
|
|
832
840
|
let surfacesChecked = 0;
|
|
841
|
+
// The contract moves each archived change records, in archive order. A contract's id derives from its path, so a
|
|
842
|
+
// move changes it; an archived baseline's list names the contract as it was, and is resolved through the moves
|
|
843
|
+
// archived after it rather than edited (M-ADR 0222 §4.4). A surface that vanished with no move still fails.
|
|
844
|
+
const movesAt = [];
|
|
845
|
+
for (const name of order) {
|
|
846
|
+
const found = [];
|
|
847
|
+
let names = [];
|
|
848
|
+
try {
|
|
849
|
+
names = (await readdir(join(repo, archiveRel, name, "structure"))).filter((f) => f.endsWith(".yaml")).sort();
|
|
850
|
+
}
|
|
851
|
+
catch {
|
|
852
|
+
names = [];
|
|
853
|
+
}
|
|
854
|
+
for (const f of names) {
|
|
855
|
+
try {
|
|
856
|
+
found.push(...contractMoves(parseDocument((await readText(join(repo, archiveRel, name, "structure", f))) ?? "").toJS()?.["moved"]).moves);
|
|
857
|
+
}
|
|
858
|
+
catch {
|
|
859
|
+
// the structural-model check reports an unreadable delta
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
movesAt.push(found);
|
|
863
|
+
}
|
|
864
|
+
// An open change's moves count too, after every archive: the catalogue it is built with already holds the new
|
|
865
|
+
// identity, so the lists that named the old one follow the move while it is open, not only once it archives.
|
|
866
|
+
const openMoves = [];
|
|
867
|
+
for (const change of open) {
|
|
868
|
+
let names = [];
|
|
869
|
+
try {
|
|
870
|
+
names = (await readdir(join(repo, STORE_DIR, "changes", change, "structure"))).filter((f) => f.endsWith(".yaml")).sort();
|
|
871
|
+
}
|
|
872
|
+
catch {
|
|
873
|
+
names = [];
|
|
874
|
+
}
|
|
875
|
+
for (const f of names) {
|
|
876
|
+
try {
|
|
877
|
+
openMoves.push(...contractMoves(parseDocument((await readText(join(repo, STORE_DIR, "changes", change, "structure", f))) ?? "").toJS()?.["moved"]).moves);
|
|
878
|
+
}
|
|
879
|
+
catch {
|
|
880
|
+
// the structural-model check reports an unreadable delta
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
/** The identities a contract had before the moves archived after position `after` (-1: every archived move). */
|
|
885
|
+
const formerIdentities = (id, after) => {
|
|
886
|
+
const back = new Map();
|
|
887
|
+
for (const m of [...movesAt.slice(after + 1).flat(), ...openMoves])
|
|
888
|
+
back.set(m.to, [...(back.get(m.to) ?? []), m]);
|
|
889
|
+
const out = [];
|
|
890
|
+
const seen = new Set([id]);
|
|
891
|
+
const queue = [id];
|
|
892
|
+
while (queue.length > 0) {
|
|
893
|
+
for (const m of back.get(queue.shift()) ?? []) {
|
|
894
|
+
if (seen.has(m.from))
|
|
895
|
+
continue;
|
|
896
|
+
seen.add(m.from);
|
|
897
|
+
out.push({ id: m.from, path: m.fromPath ?? "" });
|
|
898
|
+
queue.push(m.from);
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
return out;
|
|
902
|
+
};
|
|
903
|
+
/** What is wrong with a baseline's Surfaces list against the catalogue (M-ADR 0222 §4.3, §4.4). */
|
|
904
|
+
const surfaceProblems = async (dir, evidence, after = -1) => {
|
|
905
|
+
const out = [];
|
|
906
|
+
const parsed = parseSurfaces(evidence);
|
|
907
|
+
if (!parsed.present)
|
|
908
|
+
out.push(`${dir}/baseline-evidence.md: no **Surfaces:** list; name every contract the observed repositories serve, covered at transport or excluded with a reason`);
|
|
909
|
+
for (const p of parsed.problems)
|
|
910
|
+
out.push(`${dir}/baseline-evidence.md: Surfaces: ${p}`);
|
|
911
|
+
if (!parsed.present)
|
|
912
|
+
return out;
|
|
913
|
+
if (servedBy === null) {
|
|
914
|
+
out.push(`${dir}/baseline-evidence.md: Surfaces cannot be cross-checked: catalogues/repo_contract.csv or catalogues/contracts.csv is missing; build the catalogue`);
|
|
915
|
+
return out;
|
|
916
|
+
}
|
|
917
|
+
const repos = observedIn(evidence);
|
|
918
|
+
if (repos.length === 0)
|
|
919
|
+
out.push(`${dir}/baseline-evidence.md: Surfaces cannot be cross-checked: no **Observed in:** line names a repository`);
|
|
920
|
+
for (const r of repos) {
|
|
921
|
+
const known = servedBy.get(r);
|
|
922
|
+
if ((known?.served ?? []).length === 0) {
|
|
923
|
+
const consumed = known?.consumed ? ` (it consumes ${known.consumed}, which a baseline does not list)` : "";
|
|
924
|
+
out.push(`${dir}/baseline-evidence.md: Surfaces cannot be cross-checked: the catalogue records no contract that ${r} serves${consumed}; declare the contracts it serves in its repo-config`);
|
|
925
|
+
}
|
|
926
|
+
for (const c of known?.served ?? []) {
|
|
927
|
+
surfacesChecked++;
|
|
928
|
+
const former = formerIdentities(c.id, after);
|
|
929
|
+
if (!parsed.entries.some((e) => surfaceMatches(e.contract, c.id, c.specPath) || former.some((f) => surfaceMatches(e.contract, f.id, f.path)))) {
|
|
930
|
+
out.push(`${dir}/baseline-evidence.md: Surfaces does not list ${c.specPath || c.id} (${c.id}), which ${r} serves (catalogues/repo_contract.csv, less what service_contract records it only consuming); list it as covered at transport or excluded with a reason`);
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
return out;
|
|
935
|
+
};
|
|
833
936
|
// A baseline whose every requirement has since left the living specs (removed, or its spec retired) records a
|
|
834
937
|
// surface that no longer exists; its Surfaces list is history, not checked against today's catalogue, or a
|
|
835
938
|
// retired surface could never drop its contract.
|
|
@@ -841,33 +944,23 @@ export async function checkBehaviourStore(repo, options = {}) {
|
|
|
841
944
|
const evidence = await readText(join(repo, dir, "baseline-evidence.md"));
|
|
842
945
|
const isBaseline = evidence !== null;
|
|
843
946
|
if (isBaseline) {
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
947
|
+
if (historical.has(name)) {
|
|
948
|
+
// history: see historicalBaselines; its gate and Surfaces list are not checked
|
|
949
|
+
const parsed = parseSurfaces(evidence);
|
|
950
|
+
if (!parsed.present)
|
|
951
|
+
failures.push(`${dir}/baseline-evidence.md: no **Surfaces:** list; name every contract the observed repositories serve, covered at transport or excluded with a reason`);
|
|
952
|
+
for (const p of parsed.problems)
|
|
953
|
+
failures.push(`${dir}/baseline-evidence.md: Surfaces: ${p}`);
|
|
851
954
|
}
|
|
852
|
-
else
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
if ((servedBy.get(r) ?? []).length === 0) {
|
|
862
|
-
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`);
|
|
863
|
-
}
|
|
864
|
-
for (const c of servedBy.get(r) ?? []) {
|
|
865
|
-
surfacesChecked++;
|
|
866
|
-
if (!parsed.entries.some((e) => surfaceMatches(e.contract, c.id, c.specPath))) {
|
|
867
|
-
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`);
|
|
868
|
-
}
|
|
869
|
-
}
|
|
870
|
-
}
|
|
955
|
+
else {
|
|
956
|
+
failures.push(...(await surfaceProblems(dir, evidence, order.indexOf(name))));
|
|
957
|
+
// The gate's outcome is recorded where the SDD ships record schema 1.4.0 or later when the baseline archives;
|
|
958
|
+
// a baseline archived earlier carries none and is not held to it.
|
|
959
|
+
const archivedAt = await integratedAt(repo, dir);
|
|
960
|
+
if (versionAtLeast(await shippedRecordSchema(repo, archivedAt), "1.4.0")) {
|
|
961
|
+
const gate = baselineGate(evidence);
|
|
962
|
+
if (gate !== "PASS")
|
|
963
|
+
failures.push(`${dir}/baseline-evidence.md: archived ${gate === null ? "with no **Baseline gate:** line" : `with **Baseline gate:** ${gate}`}; a baseline archives once its gate passes: every requirement cites its evidence, characterization tests exist where the code has none, and every covered surface is evidenced at its transport (M-ADR 0222 §4.3)`);
|
|
871
964
|
}
|
|
872
965
|
}
|
|
873
966
|
}
|
|
@@ -902,8 +995,34 @@ export async function checkBehaviourStore(repo, options = {}) {
|
|
|
902
995
|
lastTouch.delete(`${feature}::${from}`);
|
|
903
996
|
}
|
|
904
997
|
}
|
|
905
|
-
|
|
906
|
-
|
|
998
|
+
// An open baseline is asked the archive's questions while it can still be fixed: its Surfaces list, what it may
|
|
999
|
+
// change given the lineage so far, the features its deltas are keyed by, and its gate. Warnings, since it is in
|
|
1000
|
+
// progress; `change archive` refuses what is still wrong when it archives.
|
|
1001
|
+
const featureIds = await readText(join(repo, "catalogues", "features.csv")).then((t) => (t === null ? null : new Set(parseCatalogueCsv(t).rows.map((r) => r["id"] ?? ""))));
|
|
1002
|
+
for (const change of openBaselines) {
|
|
1003
|
+
const dir = `${STORE_DIR}/changes/${change}`;
|
|
1004
|
+
const evidence = (await readText(join(repo, dir, "baseline-evidence.md"))) ?? "";
|
|
1005
|
+
const found = [...(await surfaceProblems(dir, evidence))];
|
|
1006
|
+
for (const feature of await dirs(join(repo, dir, "specs"))) {
|
|
1007
|
+
if (featureIds !== null && !featureIds.has(feature))
|
|
1008
|
+
found.push(`${dir}/specs/${feature}: no catalogue feature has the id "${feature}"; give the behaviour a feature first (M-ADR 0222 §4.4)`);
|
|
1009
|
+
const text = (await readText(join(repo, dir, "specs", feature, "spec.md"))) ?? "";
|
|
1010
|
+
for (const target of [...parseRequirements(text).filter((r) => r.operation === "MODIFIED" || r.operation === "REMOVED").map((r) => r.name), ...renamedFrom(text)]) {
|
|
1011
|
+
scopeChecked++;
|
|
1012
|
+
const last = lastTouch.get(`${feature}::${target}`);
|
|
1013
|
+
if (last !== "baseline")
|
|
1014
|
+
found.push(`${dir}: a baseline changes "${target}" (${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 (M-ADR 0222 §4.4)`);
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
const gate = baselineGate(evidence);
|
|
1018
|
+
if (gate !== "PASS")
|
|
1019
|
+
found.push(`${dir}/baseline-evidence.md: ${gate === null ? "no **Baseline gate:** line yet" : `**Baseline gate:** ${gate}`}; it archives once the gate passes (M-ADR 0222 §4.3)`);
|
|
1020
|
+
warnings.push(...found.map((w) => `open baseline: ${w}`));
|
|
1021
|
+
}
|
|
1022
|
+
if (openBaselines.length > 0)
|
|
1023
|
+
lines.push(`open baselines: ${openBaselines.length}, asked the archive's questions as warnings`);
|
|
1024
|
+
const unseen = servedBy === null ? "" : `; repositories with no catalogue contracts: ${[...new Set(await observedInAll(repo, archiveRel, order))].filter((r) => (servedBy.get(r)?.served ?? []).length === 0).join(", ") || "none"}`;
|
|
1025
|
+
lines.push(baselines === 0 ? "surfaces: vacuous (no baseline)" : `surfaces: ${baselines} baseline(s), ${surfacesChecked} served contract(s) checked against catalogues/repo_contract.csv less what service_contract records only consumed${unseen}${historical.size ? `; ${historical.size} historical baseline(s) not checked (every requirement they baselined has left the living specs)` : ""}`);
|
|
907
1026
|
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`);
|
|
908
1027
|
if (!history && archived.length > 0)
|
|
909
1028
|
lines.push("order: no git history (not a git work tree, or git log failed), so same-day archives cannot be ordered");
|
|
@@ -954,6 +1073,29 @@ export async function checkBehaviourStore(repo, options = {}) {
|
|
|
954
1073
|
lines.push(drift === 0 ? `schema: ${SCHEMA_NAME}, identical to the shipped copy` : `schema: ${SCHEMA_NAME}, ${drift} file(s) differ from the shipped copy`);
|
|
955
1074
|
}
|
|
956
1075
|
}
|
|
1076
|
+
// The baseline schema: checked as sdd-change is where the bundle ships it (a bundle from before it shipped has none),
|
|
1077
|
+
// and an open baseline selects it rather than sdd-change, whose Gate 0, test plan and agreement a baseline lacks.
|
|
1078
|
+
if (selected === SCHEMA_NAME) {
|
|
1079
|
+
const b = await compareStoreSchema(repo, BASELINE_SCHEMA_NAME);
|
|
1080
|
+
if (b.shipped) {
|
|
1081
|
+
const fix = "sdd-cli sync refreshes it; put adopter guidance in the store's config.yaml rules, not in the schema";
|
|
1082
|
+
for (const f of b.missing)
|
|
1083
|
+
failures.push(`${storeSchemaDir(BASELINE_SCHEMA_NAME)}/${f}: missing from the store's copy of the schema; ${fix}`);
|
|
1084
|
+
for (const f of b.differing)
|
|
1085
|
+
failures.push(`${storeSchemaDir(BASELINE_SCHEMA_NAME)}/${f}: differs from the shipped ${shippedSchemaDir(BASELINE_SCHEMA_NAME)}/${f}; ${fix}`);
|
|
1086
|
+
for (const f of b.extra)
|
|
1087
|
+
failures.push(`${storeSchemaDir(BASELINE_SCHEMA_NAME)}/${f}: not in the shipped schema; ${fix}`);
|
|
1088
|
+
const drift = b.missing.length + b.differing.length + b.extra.length;
|
|
1089
|
+
lines.push(drift === 0 ? `schema: ${BASELINE_SCHEMA_NAME}, identical to the shipped copy` : `schema: ${BASELINE_SCHEMA_NAME}, ${drift} file(s) differ from the shipped copy`);
|
|
1090
|
+
for (const change of openBaselines) {
|
|
1091
|
+
const meta = await readText(join(repo, STORE_DIR, "changes", change, ".openspec.yaml"));
|
|
1092
|
+
const chosen = meta === null ? null : parseDocument(meta).toJS()?.schema;
|
|
1093
|
+
if (chosen !== BASELINE_SCHEMA_NAME) {
|
|
1094
|
+
warnings.push(`open baseline: ${STORE_DIR}/changes/${change}/.openspec.yaml selects ${chosen === null || chosen === undefined ? "no schema" : `"${String(chosen)}"`}; a baseline selects ${BASELINE_SCHEMA_NAME} (schema: ${BASELINE_SCHEMA_NAME}), whose artifacts are a baseline's rather than a change's Gate 0, test plan and agreement`);
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
957
1099
|
const population = recordFiles.length + open.length + archived.length;
|
|
958
1100
|
const body = lines.map((l) => ` ${l}`).join("\n") + "\n";
|
|
959
1101
|
if (failures.length > 0) {
|
|
@@ -1012,25 +1154,47 @@ async function historicalBaselines(repo, archiveRel, order) {
|
|
|
1012
1154
|
});
|
|
1013
1155
|
return out;
|
|
1014
1156
|
}
|
|
1015
|
-
/**
|
|
1157
|
+
/**
|
|
1158
|
+
* Per repository, the contracts the catalogue says it serves, with how many it only consumes; null when repo_contract or
|
|
1159
|
+
* contracts is missing. repo_contract binds a repository to a contract in either direction, so a row is dropped where
|
|
1160
|
+
* service_contract records the repository's services consuming that contract and none exposing it. Rows
|
|
1161
|
+
* service_contract says nothing about stay: a model that omits a service's exposes rows does not shrink what is checked.
|
|
1162
|
+
*/
|
|
1016
1163
|
async function catalogueContracts(repo) {
|
|
1017
1164
|
const rc = await readText(join(repo, "catalogues", "repo_contract.csv"));
|
|
1018
1165
|
const cs = await readText(join(repo, "catalogues", "contracts.csv"));
|
|
1019
1166
|
if (rc === null || cs === null)
|
|
1020
1167
|
return null;
|
|
1021
1168
|
const spec = new Map(parseCatalogueCsv(cs).rows.map((r) => [r["id"] ?? "", r["spec_path"] ?? ""]));
|
|
1169
|
+
const repoOf = new Map(parseCatalogueCsv((await readText(join(repo, "catalogues", "services.csv"))) ?? "").rows.map((r) => [r["id"] ?? "", r["primary_repo_id"] ?? ""]));
|
|
1170
|
+
const roles = new Map();
|
|
1171
|
+
for (const r of parseCatalogueCsv((await readText(join(repo, "catalogues", "service_contract.csv"))) ?? "").rows) {
|
|
1172
|
+
const key = `${repoOf.get(r["service_id"] ?? "") ?? ""}\u0000${r["contract_id"] ?? ""}`;
|
|
1173
|
+
roles.set(key, (roles.get(key) ?? new Set()).add(r["role"] ?? ""));
|
|
1174
|
+
}
|
|
1022
1175
|
const out = new Map();
|
|
1023
1176
|
for (const r of parseCatalogueCsv(rc).rows) {
|
|
1024
1177
|
const repoId = r["repo_id"] ?? "";
|
|
1025
1178
|
const id = r["contract_id"] ?? "";
|
|
1026
1179
|
if (repoId === "" || id === "")
|
|
1027
1180
|
continue;
|
|
1028
|
-
const
|
|
1029
|
-
|
|
1030
|
-
|
|
1181
|
+
const entry = out.get(repoId) ?? { served: [], consumed: 0 };
|
|
1182
|
+
const role = roles.get(`${repoId}\u0000${id}`);
|
|
1183
|
+
if (role?.has("consumes") && !role.has("exposes"))
|
|
1184
|
+
entry.consumed++;
|
|
1185
|
+
else
|
|
1186
|
+
entry.served.push({ id, specPath: spec.get(id) ?? "" });
|
|
1187
|
+
out.set(repoId, entry);
|
|
1031
1188
|
}
|
|
1032
1189
|
return out;
|
|
1033
1190
|
}
|
|
1191
|
+
/** A baseline's recorded gate outcome: the last `**Baseline gate:** <outcome>` line's first word, upper-cased; null when there is none. */
|
|
1192
|
+
function baselineGate(evidence) {
|
|
1193
|
+
let out = null;
|
|
1194
|
+
for (const m of evidence.matchAll(/^\s*\*\*Baseline gate:\*\*\s*(\S+)/gm))
|
|
1195
|
+
out = m[1].replace(/[^A-Za-z]/g, "").toUpperCase();
|
|
1196
|
+
return out;
|
|
1197
|
+
}
|
|
1034
1198
|
async function observedInAll(repo, archiveRel, names) {
|
|
1035
1199
|
const out = [];
|
|
1036
1200
|
for (const n of names) {
|
|
@@ -20,6 +20,9 @@
|
|
|
20
20
|
// element another open change adds names that change in depends_on
|
|
21
21
|
// no-op contradicted by a behaviour delta for a feature the model does not hold; a structural delta
|
|
22
22
|
// must add that feature
|
|
23
|
+
// removals each id a delta removes, against the rows outside the model that still name it: this SDD's catalogues
|
|
24
|
+
// built from its records and its members' catalogues (lib/structure/removals.ts). Reported as [WARN],
|
|
25
|
+
// not counted as a finding: a removal may be meant, and the warning names what it owes before the push
|
|
23
26
|
// Composition (§4.5; lib/structure/compose.ts), with a change agreed when its agreement record says so:
|
|
24
27
|
// overlap two agreed changes writing one element or adding one id; a change overlapping an agreed one
|
|
25
28
|
// composed each change's view (with its dependency closure) and the intended view (every agreed change)
|
|
@@ -44,6 +47,7 @@ import { agreedOrder, attribute, checkDependencies, checkOverlap, closure, inten
|
|
|
44
47
|
import { describeState, renderViews, VIEW_PATHS, viewState } from "../structure/render.js";
|
|
45
48
|
import { Additions, CONTRACTS, readAgreement } from "../structure/intended.js";
|
|
46
49
|
import { validateDeltas, validateView, viewOf } from "../structure/view.js";
|
|
50
|
+
import { checkRemovals } from "../structure/removals.js";
|
|
47
51
|
const VIEW_COUNT = Object.keys(VIEW_PATHS).length;
|
|
48
52
|
/** Findings FAIL (from M-ADR 0230's acceptance); false reports them only, as while M-ADR 0230 was Proposed. Also binds a change's structural deltas as agreed content in the behaviour-store check. */
|
|
49
53
|
export const ENFORCED = true;
|
|
@@ -222,6 +226,8 @@ export async function checkStructuralModel(repo, options = {}) {
|
|
|
222
226
|
}
|
|
223
227
|
}
|
|
224
228
|
lines.push(`changes: ${changes.length} open, ${withDeltas} with structural deltas, ${noOps} claiming none, ${without} without the artifact`);
|
|
229
|
+
const removals = await checkRemovals(repo, schema, structures);
|
|
230
|
+
lines.push(...removals.lines);
|
|
225
231
|
// Composition (M-ADR 0230 §4.5): the three views and the three checks.
|
|
226
232
|
const archived = await archivedNames(repo);
|
|
227
233
|
checkOverlap(open, problems);
|
|
@@ -268,7 +274,7 @@ export async function checkStructuralModel(repo, options = {}) {
|
|
|
268
274
|
if (raw > 0)
|
|
269
275
|
lines.push(`archived changes whose structure is not applied: ${raw}`);
|
|
270
276
|
lines.push(`views: current; at each of ${[...open.values()].filter((o) => o.structure.deltas.length > 0).length} change(s) with deltas; intended from ${agreed.length} agreed change(s)`);
|
|
271
|
-
const body = lines.map((l) => `${l}\n`).join("");
|
|
277
|
+
const body = lines.map((l) => `${l}\n`).join("") + removals.findings.map((w) => `[WARN] ${w}\n`).join("");
|
|
272
278
|
if (problems.length === 0)
|
|
273
279
|
return { exitCode: 0, stdout: `structural-model:\n${body}[PASS] structural-model: ok\n` };
|
|
274
280
|
const tag = enforced ? "FAIL" : "WARN";
|
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": "5.
|
|
3
|
+
"version": "0.17.0",
|
|
4
|
+
"sddCliVersion": "5.3.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",
|