@sdd-method/author-cli 0.15.0 → 0.16.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/aggregation-manifest-loader.js +173 -0
- package/dist/lib/catalogue/build.js +16 -1
- 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/plan/read-sdd.js +2 -1
- package/dist/lib/scaffold/behaviour-store.js +5 -3
- package/dist/lib/sdd-index/repo-config.js +1 -0
- package/dist/lib/snapshot/advance.js +13 -5
- package/dist/lib/snapshot/fold.js +3 -3
- 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/validate/behaviour-store.js +213 -39
- 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
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// Aggregation-manifest loader per
|
|
2
|
+
// docs/method/methods/aggregation-manifest-schema.yaml.
|
|
3
|
+
//
|
|
4
|
+
// v1 supports `pull.kind: filesystem` only; the other kinds (git-ref,
|
|
5
|
+
// release-asset) are schema-reserved and reject with a
|
|
6
|
+
// clear "deferred" error so adopters get unambiguous feedback.
|
|
7
|
+
import { readFile } from "node:fs/promises";
|
|
8
|
+
import { isAbsolute, relative, resolve } from "node:path";
|
|
9
|
+
import { parse as parseYaml } from "yaml";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
import { SCHEMA_COMPAT, formatSchemaVersionMismatch } from "./schema-compat.js";
|
|
12
|
+
import { isInRange } from "./manifest-loader.js";
|
|
13
|
+
export const DEFAULT_AGGREGATION_MANIFEST_REL = "orchestration/aggregation-manifest.yaml";
|
|
14
|
+
const PullSchema = z
|
|
15
|
+
.object({
|
|
16
|
+
kind: z.string(),
|
|
17
|
+
path: z.string().optional(),
|
|
18
|
+
})
|
|
19
|
+
.passthrough();
|
|
20
|
+
const SiblingSchema = z.object({
|
|
21
|
+
sdd_id: z.string().regex(/^[a-z][a-z0-9-]*$/, {
|
|
22
|
+
message: "sdd_id must match /^[a-z][a-z0-9-]*$/",
|
|
23
|
+
}),
|
|
24
|
+
pull: PullSchema,
|
|
25
|
+
description: z.string().optional(),
|
|
26
|
+
level: z.string().optional(),
|
|
27
|
+
});
|
|
28
|
+
/**
|
|
29
|
+
* An ecosystem member that is real and intended but has no SDD yet
|
|
30
|
+
* (aggregation-manifest-schema 1.1.0, per M-ADR 0195 §4.3).
|
|
31
|
+
*
|
|
32
|
+
* A SEPARATE list rather than a flag on `siblings[]`: `pull` is required on a sibling
|
|
33
|
+
* and a pending member has nothing to pull, and `siblings[]` must keep meaning exactly
|
|
34
|
+
* "members whose catalogues merge in" — adding skippable entries would give one list
|
|
35
|
+
* two meanings, which every existing reader would keep miscounting.
|
|
36
|
+
*
|
|
37
|
+
* `reason` is required and non-empty. A bare marker suppresses the build failure while
|
|
38
|
+
* saying nothing about why, losing the signal that a pending member is a WORK ITEM —
|
|
39
|
+
* the state this replaces.
|
|
40
|
+
*/
|
|
41
|
+
const PendingMemberSchema = z.object({
|
|
42
|
+
sdd_id: z.string().regex(/^[a-z][a-z0-9-]*$/, {
|
|
43
|
+
message: "pending_members[].sdd_id must match /^[a-z][a-z0-9-]*$/",
|
|
44
|
+
}),
|
|
45
|
+
reason: z.string().min(1, {
|
|
46
|
+
message: "pending_members[].reason must be non-empty — a marker without a reason suppresses a failure without explaining it",
|
|
47
|
+
}),
|
|
48
|
+
blocked_on: z.string().optional(),
|
|
49
|
+
});
|
|
50
|
+
const SLUG = /^[a-z][a-z0-9-]*$/;
|
|
51
|
+
// M-ADR 0208 §4.3: `root_sdd_id` is canonical; `ecosystem_root` is its DEPRECATED alias
|
|
52
|
+
// (at organisation tier the old name called an organisation-root an "ecosystem root").
|
|
53
|
+
// Exactly one of the two must be present. Removed at 2.0.0.
|
|
54
|
+
const AggregationManifestSchema = z
|
|
55
|
+
.object({
|
|
56
|
+
schema_version: z.string(),
|
|
57
|
+
root_sdd_id: z.string().regex(SLUG).optional(),
|
|
58
|
+
ecosystem_root: z.string().regex(SLUG).optional(),
|
|
59
|
+
level: z.enum(["platform", "organisation"]).optional(),
|
|
60
|
+
siblings: z.array(SiblingSchema),
|
|
61
|
+
pending_members: z.array(PendingMemberSchema).optional(),
|
|
62
|
+
})
|
|
63
|
+
.refine((m) => (m.root_sdd_id !== undefined) !== (m.ecosystem_root !== undefined), {
|
|
64
|
+
message: "exactly one of `root_sdd_id` (canonical, M-ADR 0208 §4.3) or `ecosystem_root` (deprecated alias) must be present",
|
|
65
|
+
});
|
|
66
|
+
export async function loadAggregationManifest(manifestPath, options) {
|
|
67
|
+
let text;
|
|
68
|
+
try {
|
|
69
|
+
text = await readFile(manifestPath, "utf-8");
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
if (err.code === "ENOENT") {
|
|
73
|
+
throw new Error(`Aggregation manifest not found at ${manifestPath}\n\n` +
|
|
74
|
+
`Ecosystem-root SDDs declare an aggregation manifest at the path\n` +
|
|
75
|
+
`recorded in .sdd/sdd-manifest.yaml#aggregation_manifest_path, or\n` +
|
|
76
|
+
`at ${DEFAULT_AGGREGATION_MANIFEST_REL} by default.\n` +
|
|
77
|
+
`Schema: docs/method/methods/aggregation-manifest-schema.yaml.`);
|
|
78
|
+
}
|
|
79
|
+
throw err;
|
|
80
|
+
}
|
|
81
|
+
let raw;
|
|
82
|
+
try {
|
|
83
|
+
raw = parseYaml(text);
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
throw new Error(`failed to parse YAML at ${manifestPath}: ${err.message}`);
|
|
87
|
+
}
|
|
88
|
+
const parsed = AggregationManifestSchema.safeParse(raw);
|
|
89
|
+
if (!parsed.success) {
|
|
90
|
+
const issues = parsed.error.issues
|
|
91
|
+
.map((i) => ` - ${i.path.join(".") || "<root>"}: ${i.message}`)
|
|
92
|
+
.join("\n");
|
|
93
|
+
throw new Error(`invalid aggregation manifest at ${manifestPath}:\n${issues}`);
|
|
94
|
+
}
|
|
95
|
+
if (!isInRange(parsed.data.schema_version, SCHEMA_COMPAT.aggregation_manifest)) {
|
|
96
|
+
throw new Error(formatSchemaVersionMismatch({
|
|
97
|
+
schema: "aggregation_manifest",
|
|
98
|
+
readVersion: parsed.data.schema_version,
|
|
99
|
+
cliVersion: options.cliVersion,
|
|
100
|
+
sourcePath: manifestPath,
|
|
101
|
+
}));
|
|
102
|
+
}
|
|
103
|
+
// Duplicate sibling-id check.
|
|
104
|
+
const seen = new Set();
|
|
105
|
+
for (const s of parsed.data.siblings) {
|
|
106
|
+
if (seen.has(s.sdd_id)) {
|
|
107
|
+
throw new Error(`aggregation manifest has duplicate sibling sdd_id "${s.sdd_id}"\n` +
|
|
108
|
+
` (${manifestPath})`);
|
|
109
|
+
}
|
|
110
|
+
seen.add(s.sdd_id);
|
|
111
|
+
}
|
|
112
|
+
// Resolve each sibling's pull. v1 supports `kind: filesystem` only.
|
|
113
|
+
const resolved = [];
|
|
114
|
+
for (const s of parsed.data.siblings) {
|
|
115
|
+
const kind = s.pull.kind;
|
|
116
|
+
if (kind !== "filesystem") {
|
|
117
|
+
throw new Error(`aggregation manifest: sibling "${s.sdd_id}" uses pull.kind="${kind}"\n` +
|
|
118
|
+
` v1 supports filesystem only. The other kinds (git-ref, release-asset)\n` +
|
|
119
|
+
` are schema-reserved and not yet implemented.\n` +
|
|
120
|
+
` See: docs/method/methods/aggregation-manifest-schema.yaml#pull_kinds`);
|
|
121
|
+
}
|
|
122
|
+
const path = s.pull.path;
|
|
123
|
+
if (!path || typeof path !== "string") {
|
|
124
|
+
throw new Error(`aggregation manifest: sibling "${s.sdd_id}" pull.kind=filesystem requires \`path\``);
|
|
125
|
+
}
|
|
126
|
+
const cataloguesPath = isAbsolute(path)
|
|
127
|
+
? path
|
|
128
|
+
: resolve(options.ecosystemRootPath, path);
|
|
129
|
+
// The sibling's repo root is the parent of `catalogues/`.
|
|
130
|
+
const sddRoot = resolve(cataloguesPath, "..");
|
|
131
|
+
// Portable form for the committed JSON snapshot: keep a relative source
|
|
132
|
+
// verbatim; anchor an absolute source to the ecosystem root so the emitted
|
|
133
|
+
// value never carries a machine-specific $HOME/checkout prefix.
|
|
134
|
+
const relativeCataloguesPath = isAbsolute(path)
|
|
135
|
+
? relative(options.ecosystemRootPath, cataloguesPath)
|
|
136
|
+
: path;
|
|
137
|
+
resolved.push({
|
|
138
|
+
sddId: s.sdd_id,
|
|
139
|
+
description: s.description ?? "",
|
|
140
|
+
cataloguesPath,
|
|
141
|
+
relativeCataloguesPath,
|
|
142
|
+
sddRoot,
|
|
143
|
+
level: s.level,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
// A member is pending authoring OR contributing catalogues, never both. Declaring
|
|
147
|
+
// both is a contradiction rather than something to merge, and silently preferring
|
|
148
|
+
// one reading would make the work queue untrustworthy — so it fails with the id.
|
|
149
|
+
const pendingMembers = [];
|
|
150
|
+
const siblingIds = new Set(resolved.map((r) => r.sddId));
|
|
151
|
+
for (const p of parsed.data.pending_members ?? []) {
|
|
152
|
+
if (siblingIds.has(p.sdd_id)) {
|
|
153
|
+
throw new Error(`aggregation manifest: "${p.sdd_id}" appears in BOTH siblings and pending_members.\n` +
|
|
154
|
+
` A member is either pending authoring or contributing catalogues, not both.\n` +
|
|
155
|
+
` Remove whichever declaration is stale (${parsed.data.root_sdd_id ?? parsed.data.ecosystem_root}).`);
|
|
156
|
+
}
|
|
157
|
+
pendingMembers.push({
|
|
158
|
+
sddId: p.sdd_id,
|
|
159
|
+
reason: p.reason,
|
|
160
|
+
...(p.blocked_on !== undefined ? { blockedOn: p.blocked_on } : {}),
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
schemaVersion: parsed.data.schema_version,
|
|
165
|
+
ecosystemRoot: (parsed.data.root_sdd_id ?? parsed.data.ecosystem_root),
|
|
166
|
+
level: parsed.data.level,
|
|
167
|
+
rootFieldDeprecated: parsed.data.root_sdd_id === undefined,
|
|
168
|
+
siblings: resolved,
|
|
169
|
+
pendingMembers,
|
|
170
|
+
sourcePath: manifestPath,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
//# sourceMappingURL=aggregation-manifest-loader.js.map
|
|
@@ -90,7 +90,7 @@ export async function runCatalogueBuild(inputs, write) {
|
|
|
90
90
|
const intendedRows = [];
|
|
91
91
|
const rowsByCatalogue = modelFiles === null
|
|
92
92
|
? collected
|
|
93
|
-
: overlayModel(schema, viewOf(modelFiles, modelProblems), collected, findings, intendedWrites(open, agreedOrder(open)), intendedRows);
|
|
93
|
+
: withC4Rollups(overlayModel(schema, viewOf(modelFiles, modelProblems), collected, findings, intendedWrites(open, agreedOrder(open)), intendedRows));
|
|
94
94
|
for (const p of modelProblems)
|
|
95
95
|
findings.add("convention", `${p} (M-ADR 0230)`);
|
|
96
96
|
// 3a. Normalise every keyed catalogue to PK-unique rows. Multi-source edge
|
|
@@ -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);
|
|
@@ -1517,6 +1520,18 @@ async function collectRows(schema, manifest, findings, sddPath, snapshotStore) {
|
|
|
1517
1520
|
});
|
|
1518
1521
|
return out;
|
|
1519
1522
|
}
|
|
1523
|
+
/**
|
|
1524
|
+
* The C4 roll-ups joined again over the catalogues the structural model settles (M-ADR 0159 §4.8, M-ADR 0230 §4.3).
|
|
1525
|
+
* collectRows joins them over the sources, which can state an element an agreed open change adds; the overlay holds
|
|
1526
|
+
* that element back until the change archives, and a roll-up row reaching it would be a current row referencing it.
|
|
1527
|
+
*/
|
|
1528
|
+
function withC4Rollups(rows) {
|
|
1529
|
+
const c4Containers = rows.get("c4_containers") ?? [];
|
|
1530
|
+
const containerEntityRows = buildC4ContainerEntity({ serviceEntityRows: rows.get("service_entity") ?? [], c4Containers });
|
|
1531
|
+
rows.set("c4_container_entity", containerEntityRows);
|
|
1532
|
+
rows.set("c4_system_entity", buildC4SystemEntity({ containerEntityRows, c4Containers }));
|
|
1533
|
+
return rows;
|
|
1534
|
+
}
|
|
1520
1535
|
const CONTEXT_CROSS_CUTTING = "cross-cutting";
|
|
1521
1536
|
/**
|
|
1522
1537
|
* Convention check (schema 2.21.0): where a screen carries `context` and the
|
|
@@ -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. */
|
|
@@ -44,7 +44,7 @@ async function readRepoConfigFor(sddRoot, manifestAbsPath) {
|
|
|
44
44
|
path: manifestAbsPath,
|
|
45
45
|
aggregates: [],
|
|
46
46
|
internalDependencies: [],
|
|
47
|
-
contracts: { openapi: [], proto: [], avro: [], spicedb: [], graphql: [], cli: [] },
|
|
47
|
+
contracts: { openapi: [], proto: [], avro: [], spicedb: [], graphql: [], cli: [], ui: [] },
|
|
48
48
|
};
|
|
49
49
|
if (!(await fileExists(manifestAbsPath)))
|
|
50
50
|
return empty;
|
|
@@ -63,6 +63,7 @@ function legacyShape(node, sourcePath) {
|
|
|
63
63
|
avro: [],
|
|
64
64
|
spicedb: [],
|
|
65
65
|
cli: [],
|
|
66
|
+
ui: [],
|
|
66
67
|
};
|
|
67
68
|
for (const ref of node.contracts)
|
|
68
69
|
byKind[ref.kind].push(ref.path);
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
6
6
|
import { dirname, join } from "node:path";
|
|
7
7
|
import { parseDocument } from "yaml";
|
|
8
|
-
import { SHIPPED_CONFIG_TEMPLATE, SHIPPED_SCHEMA_DIR, STORE_CONFIG,
|
|
8
|
+
import { SHIPPED_CONFIG_TEMPLATE, SHIPPED_SCHEMA_DIR, STORE_CONFIG, writeStoreSchema, STORE_SCHEMAS, storeSchemaDir } from "../behaviour-store/schema.js";
|
|
9
9
|
import { MANIFEST_REL_PATH } from "../catalogue/manifest-loader.js";
|
|
10
10
|
export const STORE_KEEP_FILES = ["openspec/specs/.gitkeep", "openspec/changes/archive/.gitkeep", "docs/agreements/.gitkeep"];
|
|
11
11
|
async function exists(p) {
|
|
@@ -53,8 +53,10 @@ export async function scaffoldBehaviourStore(repo) {
|
|
|
53
53
|
await writeFile(join(repo, rel), "");
|
|
54
54
|
filesWritten.push(rel);
|
|
55
55
|
}
|
|
56
|
-
const
|
|
57
|
-
|
|
56
|
+
for (const name of STORE_SCHEMAS) {
|
|
57
|
+
const copied = await writeStoreSchema(repo, name);
|
|
58
|
+
filesWritten.push(...copied.missing.map((f) => `${storeSchemaDir(name)}/${f}`));
|
|
59
|
+
}
|
|
58
60
|
return { filesWritten, sddId };
|
|
59
61
|
}
|
|
60
62
|
//# sourceMappingURL=behaviour-store.js.map
|
|
@@ -29,7 +29,7 @@ import { parse, parseDocument, stringify } from "yaml";
|
|
|
29
29
|
// unrelated churn in lines the tool had no business touching.
|
|
30
30
|
import { deltaPath } from "./diff.js";
|
|
31
31
|
import { loadCanonicalSchema } from "../catalogue/schema-loader.js";
|
|
32
|
-
import { SnapshotError, credentialNotice, exists, isIsoDate, readManifest, splitFolder, } from "./manifest.js";
|
|
32
|
+
import { SnapshotError, credentialNotice, exists, isIsoDate, parseSnapshotYaml, readManifest, splitFolder, } from "./manifest.js";
|
|
33
33
|
export const REVERSE_STATUSES = ["classified", "reversed"];
|
|
34
34
|
export const IMPACTS = ["none", "repo-minor", "repo-major", "product-major"];
|
|
35
35
|
/**
|
|
@@ -179,7 +179,7 @@ export async function advance(inputs, write) {
|
|
|
179
179
|
if (!(await exists(dp))) {
|
|
180
180
|
throw new SnapshotError(`no ${dp}: the pin advances after the delta is recorded (run snapshot diff, then classify)`);
|
|
181
181
|
}
|
|
182
|
-
const delta =
|
|
182
|
+
const delta = parseSnapshotYaml(await readFile(dp, "utf8"));
|
|
183
183
|
const provenance = delta.t0_provenance?.result;
|
|
184
184
|
const provenanceNote = provenance ? ` T0 provenance: ${provenance}${delta.t0_provenance?.waiver?.who ? ` (waiver: ${delta.t0_provenance.waiver.who})` : ""}.` : "";
|
|
185
185
|
if (provenance && !/^pass$/.test(provenance)) {
|
|
@@ -215,8 +215,8 @@ export async function advance(inputs, write) {
|
|
|
215
215
|
const hFile = join(dir, "reverse-history.yaml");
|
|
216
216
|
const mFile = join(dir, "reverse-metadata.yaml");
|
|
217
217
|
const metadataText = await readFile(mFile, "utf8");
|
|
218
|
-
const metadata =
|
|
219
|
-
let history = (await exists(hFile)) ?
|
|
218
|
+
const metadata = parseSnapshotYaml(metadataText);
|
|
219
|
+
let history = (await exists(hFile)) ? parseSnapshotYaml(await readFile(hFile, "utf8")) : { canonical_repo_key: key, history: [] };
|
|
220
220
|
let migrated = false;
|
|
221
221
|
if (Array.isArray(history.entries)) {
|
|
222
222
|
if (!inputs.migrateHistory) {
|
|
@@ -315,6 +315,14 @@ export async function advance(inputs, write) {
|
|
|
315
315
|
}
|
|
316
316
|
// A classification is one of the bound vocabulary (M-ADR 0214 §4.5), read from the
|
|
317
317
|
// schema rather than restated here: a typo is not a classification.
|
|
318
|
+
// Both effects are stated, `none` or the baseline change that carries it (delta-reverse.md §7): a refresh that
|
|
319
|
+
// changed nothing must read differently from one nobody checked.
|
|
320
|
+
const silent = mine
|
|
321
|
+
.filter((f) => f.classification !== null && f.classification !== undefined)
|
|
322
|
+
.flatMap((f) => ["structural_effect", "behavioural_effect"].filter((k) => typeof f[k] !== "string" || f[k].trim() === "").map((k) => `${f.id} ${k}`));
|
|
323
|
+
if (silent.length > 0) {
|
|
324
|
+
problems.push(`${key}: ${silent.join(", ")} not stated — each classified finding says none, or names the baseline change that carries it (delta-reverse.md §7)`);
|
|
325
|
+
}
|
|
318
326
|
const outOfVocabulary = mine.filter((f) => !dispositions.has(String(f.classification)));
|
|
319
327
|
if (outOfVocabulary.length > 0) {
|
|
320
328
|
problems.push(`${key}: ${outOfVocabulary.map((f) => `${f.id} '${String(f.classification)}'`).join(", ")} — not a delta_disposition (${[...dispositions].join(", ")}; M-ADR 0214 §4.5)`);
|
|
@@ -371,7 +379,7 @@ export async function advance(inputs, write) {
|
|
|
371
379
|
else {
|
|
372
380
|
await writeFile(hFile, appendHistoryEntry(await readFile(hFile, "utf8"), p.entry));
|
|
373
381
|
}
|
|
374
|
-
const after =
|
|
382
|
+
const after = parseSnapshotYaml(await readFile(hFile, "utf8")).history;
|
|
375
383
|
if (after.length !== before.length + 1 || !isDeepStrictEqual(after.slice(0, before.length), before)) {
|
|
376
384
|
throw new Error(`${hFile}: post-write check failed — prior entries changed or not exactly one appended`);
|
|
377
385
|
}
|
|
@@ -15,9 +15,9 @@
|
|
|
15
15
|
// (`support-repo` → `ancillary`, delta-reverse.md §1) and left unset otherwise.
|
|
16
16
|
import { mkdir, readdir, readFile, rename, stat, writeFile } from "node:fs/promises";
|
|
17
17
|
import { join, relative, sep } from "node:path";
|
|
18
|
-
import {
|
|
18
|
+
import { stringify } from "yaml";
|
|
19
19
|
import { setTopLevelScalars } from "./advance.js";
|
|
20
|
-
import { SnapshotError, baselineIds, deriveKey, knownRepositoryIds, exists, isIsoDate, manifestPath, memberFolders, splitFolder, } from "./manifest.js";
|
|
20
|
+
import { SnapshotError, baselineIds, deriveKey, knownRepositoryIds, exists, isIsoDate, manifestPath, memberFolders, parseSnapshotYaml, splitFolder, } from "./manifest.js";
|
|
21
21
|
const ROLE_BY_PROFILE = { "support-repo": "ancillary" };
|
|
22
22
|
/** The same mapping by M-ADR 0213's scope, which replaces the per-repo profile: a support repo is ancillary. */
|
|
23
23
|
const ROLE_BY_SCOPE = { support: "ancillary" };
|
|
@@ -32,7 +32,7 @@ async function pinsByFolder(sddPath) {
|
|
|
32
32
|
if (!e.isDirectory() || !(await exists(file)))
|
|
33
33
|
continue;
|
|
34
34
|
const text = await readFile(file, "utf8");
|
|
35
|
-
const meta = (
|
|
35
|
+
const meta = (parseSnapshotYaml(text) ?? {});
|
|
36
36
|
const folder = String(meta.source_path ?? "").split("/").pop() || String(meta.exported_folder_name ?? "");
|
|
37
37
|
if (folder)
|
|
38
38
|
out.set(folder, { file, text, meta });
|
|
@@ -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
|
|
@@ -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";
|
|
@@ -86,6 +90,8 @@ const COMMIT_RE = /^[0-9a-f]{7,40}$/;
|
|
|
86
90
|
// OpenSpec counts a task done when its box holds only `x`, either case, any spacing.
|
|
87
91
|
const TASK_DONE_RE = /^\s*-\s*\[\s*[xX]\s*\]/m;
|
|
88
92
|
const TARGET_HEADING_RE = /^##\s+(MODIFIED|REMOVED|RENAMED)\s+Requirements\b/m;
|
|
93
|
+
// A tasks.md group headed by one word, `## <n>. <word>`, is that repository's work order (taskGroups).
|
|
94
|
+
const GROUP_REPO = (repo) => `tasks.md has a work order for ${repo} and the record's work_orders do not; its evidence cannot be checked (a group heading of one word, \`## <n>. ${repo}\`, names the group's repository)`;
|
|
89
95
|
async function exists(p) {
|
|
90
96
|
try {
|
|
91
97
|
await access(p);
|
|
@@ -516,6 +522,14 @@ export async function checkBehaviourStore(repo, options = {}) {
|
|
|
516
522
|
}
|
|
517
523
|
}
|
|
518
524
|
}
|
|
525
|
+
// The archive check reads a task group's repository from its heading; once the record declares work orders, a
|
|
526
|
+
// heading it would read as a repository the record lacks fails here, while the change can still be amended.
|
|
527
|
+
if (versionAtLeast(record?.schemaVersion, "1.1.0") && record?.status === "agreed" && record.workOrders.length > 0 && tasks !== null) {
|
|
528
|
+
const recorded = new Set(record.workOrders.map((w) => w.repo));
|
|
529
|
+
for (const g of taskGroups(tasks))
|
|
530
|
+
if (!recorded.has(g.repo))
|
|
531
|
+
failures.push(`${record.file}: ${GROUP_REPO(g.repo)}`);
|
|
532
|
+
}
|
|
519
533
|
// A test plan's kinds (M-ADR 0223 §4.4 as amended): changed and repaired scenarios go red then green, carried ones stay green.
|
|
520
534
|
if (versionAtLeast(record?.schemaVersion, "1.1.0") && record?.status === "agreed") {
|
|
521
535
|
const plan = await readText(join(repo, dir, "test-plan.md"));
|
|
@@ -717,7 +731,7 @@ export async function checkBehaviourStore(repo, options = {}) {
|
|
|
717
731
|
const recorded = new Set(record.workOrders.map((w) => w.repo));
|
|
718
732
|
for (const g of groups)
|
|
719
733
|
if (!recorded.has(g.repo))
|
|
720
|
-
failures.push(`${record.file}:
|
|
734
|
+
failures.push(`${record.file}: ${GROUP_REPO(g.repo)}`);
|
|
721
735
|
// Requirement names are unique within a feature, not across a change: a work order names `<feature>::<name>`,
|
|
722
736
|
// or a bare name only one feature in the change has (schema 5.0.0). An ambiguous bare name implements nothing.
|
|
723
737
|
const built = [];
|
|
@@ -818,8 +832,107 @@ export async function checkBehaviourStore(repo, options = {}) {
|
|
|
818
832
|
}
|
|
819
833
|
const orderDecides = (name, key) => [...(peers.get(name) ?? [])].some((p) => p !== name && touches.get(p)?.has(key));
|
|
820
834
|
let scopeUnresolved = 0;
|
|
821
|
-
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;
|
|
822
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
|
+
};
|
|
823
936
|
// A baseline whose every requirement has since left the living specs (removed, or its spec retired) records a
|
|
824
937
|
// surface that no longer exists; its Surfaces list is history, not checked against today's catalogue, or a
|
|
825
938
|
// retired surface could never drop its contract.
|
|
@@ -831,33 +944,23 @@ export async function checkBehaviourStore(repo, options = {}) {
|
|
|
831
944
|
const evidence = await readText(join(repo, dir, "baseline-evidence.md"));
|
|
832
945
|
const isBaseline = evidence !== null;
|
|
833
946
|
if (isBaseline) {
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
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}`);
|
|
841
954
|
}
|
|
842
|
-
else
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
if ((servedBy.get(r) ?? []).length === 0) {
|
|
852
|
-
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`);
|
|
853
|
-
}
|
|
854
|
-
for (const c of servedBy.get(r) ?? []) {
|
|
855
|
-
surfacesChecked++;
|
|
856
|
-
if (!parsed.entries.some((e) => surfaceMatches(e.contract, c.id, c.specPath))) {
|
|
857
|
-
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`);
|
|
858
|
-
}
|
|
859
|
-
}
|
|
860
|
-
}
|
|
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)`);
|
|
861
964
|
}
|
|
862
965
|
}
|
|
863
966
|
}
|
|
@@ -892,8 +995,34 @@ export async function checkBehaviourStore(repo, options = {}) {
|
|
|
892
995
|
lastTouch.delete(`${feature}::${from}`);
|
|
893
996
|
}
|
|
894
997
|
}
|
|
895
|
-
|
|
896
|
-
|
|
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)` : ""}`);
|
|
897
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`);
|
|
898
1027
|
if (!history && archived.length > 0)
|
|
899
1028
|
lines.push("order: no git history (not a git work tree, or git log failed), so same-day archives cannot be ordered");
|
|
@@ -944,6 +1073,29 @@ export async function checkBehaviourStore(repo, options = {}) {
|
|
|
944
1073
|
lines.push(drift === 0 ? `schema: ${SCHEMA_NAME}, identical to the shipped copy` : `schema: ${SCHEMA_NAME}, ${drift} file(s) differ from the shipped copy`);
|
|
945
1074
|
}
|
|
946
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
|
+
}
|
|
947
1099
|
const population = recordFiles.length + open.length + archived.length;
|
|
948
1100
|
const body = lines.map((l) => ` ${l}`).join("\n") + "\n";
|
|
949
1101
|
if (failures.length > 0) {
|
|
@@ -1002,25 +1154,47 @@ async function historicalBaselines(repo, archiveRel, order) {
|
|
|
1002
1154
|
});
|
|
1003
1155
|
return out;
|
|
1004
1156
|
}
|
|
1005
|
-
/**
|
|
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
|
+
*/
|
|
1006
1163
|
async function catalogueContracts(repo) {
|
|
1007
1164
|
const rc = await readText(join(repo, "catalogues", "repo_contract.csv"));
|
|
1008
1165
|
const cs = await readText(join(repo, "catalogues", "contracts.csv"));
|
|
1009
1166
|
if (rc === null || cs === null)
|
|
1010
1167
|
return null;
|
|
1011
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
|
+
}
|
|
1012
1175
|
const out = new Map();
|
|
1013
1176
|
for (const r of parseCatalogueCsv(rc).rows) {
|
|
1014
1177
|
const repoId = r["repo_id"] ?? "";
|
|
1015
1178
|
const id = r["contract_id"] ?? "";
|
|
1016
1179
|
if (repoId === "" || id === "")
|
|
1017
1180
|
continue;
|
|
1018
|
-
const
|
|
1019
|
-
|
|
1020
|
-
|
|
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);
|
|
1021
1188
|
}
|
|
1022
1189
|
return out;
|
|
1023
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
|
+
}
|
|
1024
1198
|
async function observedInAll(repo, archiveRel, names) {
|
|
1025
1199
|
const out = [];
|
|
1026
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/author-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"sddCliVersion": "5.
|
|
3
|
+
"version": "0.16.0",
|
|
4
|
+
"sddCliVersion": "5.3.0",
|
|
5
5
|
"description": "Re-author one SDD's content — scaffold, plan, contracts, conformance, api-surface, snapshots, briefings, catalogue check — plus every read-only verify verb. Ships only the code those verbs reach; it cannot sync the baseline or rebuild the catalogues. Maintenance is in @sdd-method/maintain-cli, administration in @sdd-method/sdd-cli.",
|
|
6
6
|
"license": "UNLICENSED",
|
|
7
7
|
"type": "module",
|