@sdd-method/author-cli 0.15.1 → 0.16.1
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 +52 -5
- 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 +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/features.js +34 -4
- package/dist/lib/catalogue/builders/repositories.js +5 -3
- package/dist/lib/catalogue/builders/requirements.js +1 -1
- 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 +228 -38
- package/dist/lib/validate/feature-spec-pointers.js +7 -0
- 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
- package/scripts/validate-app-docs.sh +18 -2
|
@@ -129,10 +129,17 @@ export async function orderArchives(sddPath, archiveRel, names) {
|
|
|
129
129
|
}
|
|
130
130
|
return { order, ties, history: history !== null };
|
|
131
131
|
}
|
|
132
|
+
/**
|
|
133
|
+
* Whether the evidence's `**Observed in:**` line is the typed claim `none`: a baseline that admits no behaviour (no
|
|
134
|
+
* spec deltas), such as a structure-only removal, observed no repository serving any.
|
|
135
|
+
*/
|
|
136
|
+
export function observedNone(evidence) {
|
|
137
|
+
return /^\s*\*\*Observed in:\*\*\s*none\b/im.test(evidence);
|
|
138
|
+
}
|
|
132
139
|
/** The repositories a baseline was observed in: its evidence file's `**Observed in:** repo[@sha], repo[@sha]` line. */
|
|
133
140
|
export function observedIn(evidence) {
|
|
134
141
|
const m = /^\s*\*\*Observed in:\*\*\s*(.+)$/m.exec(evidence);
|
|
135
|
-
if (!m)
|
|
142
|
+
if (!m || observedNone(evidence))
|
|
136
143
|
return [];
|
|
137
144
|
return m[1]
|
|
138
145
|
.split(",")
|
|
@@ -148,18 +155,30 @@ export function observedIn(evidence) {
|
|
|
148
155
|
* `**Surfaces:**` line is read, so an earlier prose section under the same label does not shadow the list.
|
|
149
156
|
* Covered needs evidence that exercises the surface as the service exposes it; evidence below the transport is
|
|
150
157
|
* not coverage, so `covered at handler` is a problem, not an entry. A wildcard may only exclude.
|
|
158
|
+
*
|
|
159
|
+
* `**Surfaces:** none` on one line is the typed claim that the baseline lists no surface, which only a baseline that
|
|
160
|
+
* admits no behaviour may make (the caller decides; `none` is true and the list is empty).
|
|
151
161
|
*/
|
|
152
162
|
export function parseSurfaces(evidence) {
|
|
153
163
|
const lines = evidence.split("\n");
|
|
154
164
|
// The LAST standalone line: an evidence file may carry an earlier prose section under the same label, and
|
|
155
165
|
// the list is what the file ends up asserting.
|
|
156
166
|
let at = -1;
|
|
167
|
+
let none = false;
|
|
157
168
|
lines.forEach((l, i) => {
|
|
158
|
-
if (/^\s*\*\*Surfaces:\*\*\s*$/.test(l))
|
|
169
|
+
if (/^\s*\*\*Surfaces:\*\*\s*$/.test(l)) {
|
|
159
170
|
at = i;
|
|
171
|
+
none = false;
|
|
172
|
+
}
|
|
173
|
+
else if (/^\s*\*\*Surfaces:\*\*\s*none\b/i.test(l)) {
|
|
174
|
+
at = i;
|
|
175
|
+
none = true;
|
|
176
|
+
}
|
|
160
177
|
});
|
|
161
178
|
if (at < 0)
|
|
162
|
-
return { present: false, entries: [], problems: [] };
|
|
179
|
+
return { present: false, none: false, entries: [], problems: [] };
|
|
180
|
+
if (none)
|
|
181
|
+
return { present: true, none: true, entries: [], problems: [] };
|
|
163
182
|
const entries = [];
|
|
164
183
|
const problems = [];
|
|
165
184
|
for (const line of lines.slice(at + 1)) {
|
|
@@ -193,8 +212,36 @@ export function parseSurfaces(evidence) {
|
|
|
193
212
|
}
|
|
194
213
|
}
|
|
195
214
|
if (entries.length + problems.length === 0)
|
|
196
|
-
problems.push(
|
|
197
|
-
return { present: true, entries, problems };
|
|
215
|
+
problems.push('the **Surfaces:** line lists no contract; a baseline that admits no behaviour writes "**Surfaces:** none"');
|
|
216
|
+
return { present: true, none: false, entries, problems };
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* A structural delta's `moved: {contracts: [{from, to, from_path?}]}` block. A contract's id is derived from its path,
|
|
220
|
+
* so moving the file changes the id; the change that moves it records the move once, and what named the old
|
|
221
|
+
* identity (an archived baseline's Surfaces list) follows it rather than being edited.
|
|
222
|
+
*/
|
|
223
|
+
export function contractMoves(raw) {
|
|
224
|
+
const moves = [];
|
|
225
|
+
const problems = [];
|
|
226
|
+
if (raw === undefined || raw === null)
|
|
227
|
+
return { moves, problems };
|
|
228
|
+
const contracts = typeof raw === "object" && !Array.isArray(raw) ? raw["contracts"] : undefined;
|
|
229
|
+
const extra = typeof raw === "object" && raw !== null && !Array.isArray(raw) ? Object.keys(raw).filter((k) => k !== "contracts") : [];
|
|
230
|
+
if (!Array.isArray(contracts) || extra.length > 0) {
|
|
231
|
+
problems.push(`moved must be {contracts: [{from: <old contract id>, to: <new contract id>, from_path?: <old spec path>}]}`);
|
|
232
|
+
return { moves, problems };
|
|
233
|
+
}
|
|
234
|
+
for (const [i, m] of contracts.entries()) {
|
|
235
|
+
const e = (m ?? {});
|
|
236
|
+
const from = typeof e["from"] === "string" ? e["from"].trim() : "";
|
|
237
|
+
const to = typeof e["to"] === "string" ? e["to"].trim() : "";
|
|
238
|
+
const fromPath = typeof e["from_path"] === "string" ? e["from_path"].trim() : undefined;
|
|
239
|
+
if (from === "" || to === "" || from === to)
|
|
240
|
+
problems.push(`moved.contracts[${i}] needs a from and a to that differ`);
|
|
241
|
+
else
|
|
242
|
+
moves.push({ from, to, ...(fromPath ? { fromPath } : {}) });
|
|
243
|
+
}
|
|
244
|
+
return { moves, problems };
|
|
198
245
|
}
|
|
199
246
|
/** Whether a Surfaces entry names a catalogue contract: its id, or a path suffix of its spec_path on a segment boundary. */
|
|
200
247
|
export function surfaceMatches(entry, contractId, specPath) {
|
|
@@ -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
|
|
@@ -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 = [];
|
|
@@ -24,9 +24,18 @@
|
|
|
24
24
|
//
|
|
25
25
|
// Dedup ordering matters: walker (1) runs first (capability features),
|
|
26
26
|
// then walker (2) (standalone specs), then walker (3) (bullets). When
|
|
27
|
-
// a bullet's id is already
|
|
28
|
-
//
|
|
29
|
-
//
|
|
27
|
+
// a bullet's id is already claimed by a standalone app spec (2), the two
|
|
28
|
+
// are one feature and make one row (sdm-mbhi): the feature tree is the home
|
|
29
|
+
// of its placement and inventory facts (parent_group_id, maturity, runway,
|
|
30
|
+
// description), the spec of its lifecycle (status, kind), and source stays
|
|
31
|
+
// feature-spec, since a spec exists. A spec does not restate the tree's
|
|
32
|
+
// fields, and its H1 title is a name, not a description. A bullet whose id a
|
|
33
|
+
// capability feature (1) already claims is still a divergence.
|
|
34
|
+
//
|
|
35
|
+
// A standalone spec's stem is the feature's slug, without the product
|
|
36
|
+
// prefix: features/<group>/alerts.md is <productId>-alerts. A stem that
|
|
37
|
+
// already starts with "<productId>-" would double the prefix, so it is
|
|
38
|
+
// refused with a convention finding naming the stem to use.
|
|
30
39
|
import { readdir, readFile } from "node:fs/promises";
|
|
31
40
|
import { basename, join, relative, sep } from "node:path";
|
|
32
41
|
import { featureGroupId, findFeatureTreeFile, walkFeatureTree, } from "./feature-tree-walker.js";
|
|
@@ -103,10 +112,15 @@ export async function buildFeatures(inputs) {
|
|
|
103
112
|
// These features are owned by the SDD's product (not by a capability) —
|
|
104
113
|
// capability_id is left empty; product_id carries the link.
|
|
105
114
|
if (inputs.profile === "application-product") {
|
|
115
|
+
const appSpecRow = new Map();
|
|
106
116
|
const appFeatures = await collectAppFeatureSpecs(inputs.sddPath);
|
|
107
117
|
for (const path of appFeatures) {
|
|
108
118
|
const relPath = relative(inputs.sddPath, path);
|
|
109
119
|
const stem = basename(relPath).replace(/\.md$/i, "");
|
|
120
|
+
if (stem.startsWith(`${inputs.productId}-`)) {
|
|
121
|
+
inputs.findings.add("convention", `${relPath}: feature spec stem "${stem}" repeats the product prefix, so its id would read "${inputs.productId}-${stem}"; name the file after the feature's slug ("${stem.slice(inputs.productId.length + 1)}.md"), the slug feature-tree.md uses`);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
110
124
|
const id = `${inputs.productId}-${stem}`;
|
|
111
125
|
if (seen.has(id)) {
|
|
112
126
|
inputs.findings.add("divergence", `${relPath}: feature id "${id}" already emitted by an earlier file`);
|
|
@@ -133,6 +147,7 @@ export async function buildFeatures(inputs) {
|
|
|
133
147
|
idByPath.set(relPath, id);
|
|
134
148
|
if (!idBySlug.has(stem))
|
|
135
149
|
idBySlug.set(stem, id);
|
|
150
|
+
appSpecRow.set(id, rows.length - 1);
|
|
136
151
|
}
|
|
137
152
|
// Bullet branch — feature-tree.md.
|
|
138
153
|
await emitBulletRows({
|
|
@@ -143,6 +158,7 @@ export async function buildFeatures(inputs) {
|
|
|
143
158
|
seen,
|
|
144
159
|
rows,
|
|
145
160
|
idBySlug,
|
|
161
|
+
appSpecRow,
|
|
146
162
|
});
|
|
147
163
|
}
|
|
148
164
|
return { rows, idByPath, idBySlug };
|
|
@@ -178,7 +194,8 @@ async function emitBulletRows(ctx) {
|
|
|
178
194
|
continue;
|
|
179
195
|
bulletCount++;
|
|
180
196
|
const id = `${ctx.productId}-${ev.slug}`;
|
|
181
|
-
|
|
197
|
+
const specIndex = ctx.appSpecRow.get(id);
|
|
198
|
+
if (ctx.seen.has(id) && specIndex === undefined) {
|
|
182
199
|
ctx.findings.add("divergence", `${relPath}: feature-tree.md bullet "${ev.slug}" superseded by an earlier feature spec with id "${id}"`);
|
|
183
200
|
continue;
|
|
184
201
|
}
|
|
@@ -196,6 +213,19 @@ async function emitBulletRows(ctx) {
|
|
|
196
213
|
const description = ev.description.length > DESCRIPTION_MAX
|
|
197
214
|
? ev.description.slice(0, DESCRIPTION_MAX)
|
|
198
215
|
: ev.description;
|
|
216
|
+
if (specIndex !== undefined) {
|
|
217
|
+
// One feature, one row: the tree's placement and inventory facts, the spec's lifecycle.
|
|
218
|
+
ctx.rows[specIndex] = {
|
|
219
|
+
...ctx.rows[specIndex],
|
|
220
|
+
parent_group_id: parentGroupId,
|
|
221
|
+
description,
|
|
222
|
+
maturity: maturityFromRunway(ev.runway),
|
|
223
|
+
runway: ev.runway,
|
|
224
|
+
};
|
|
225
|
+
if (!ctx.idBySlug.has(ev.slug))
|
|
226
|
+
ctx.idBySlug.set(ev.slug, id);
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
199
229
|
ctx.rows.push({
|
|
200
230
|
id,
|
|
201
231
|
name: ev.slug,
|
|
@@ -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({
|
|
@@ -276,7 +276,7 @@ export async function buildRequirements(inputs) {
|
|
|
276
276
|
structureEffects.push(effectRow(scoped(dir), "structure", e.element, e.operation, e.base ?? "", e.target ?? ""));
|
|
277
277
|
if (evidence !== null) {
|
|
278
278
|
const repos = observedIn(evidence);
|
|
279
|
-
if (repos.length === 0) {
|
|
279
|
+
if (repos.length === 0 && delta.length > 0) {
|
|
280
280
|
findings.add("convention", `${rel(sddPath, join(changeDir, "baseline-evidence.md"))}: no "**Observed in:**" line, so its requirements carry no observed status`);
|
|
281
281
|
}
|
|
282
282
|
for (const repo of repos)
|
|
@@ -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
|