@sdd-method/verify-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/NOTICE +17 -0
- package/README.md +7 -0
- package/dist/lib/catalogue/baseline-check.js +136 -0
- package/dist/lib/catalogue/canonical-schema.yaml +3557 -0
- package/dist/lib/catalogue/cli-version.js +10 -0
- package/dist/lib/catalogue/coverage-predicates.js +603 -0
- package/dist/lib/catalogue/coverage-render.js +522 -0
- package/dist/lib/catalogue/coverage-run.js +115 -0
- package/dist/lib/catalogue/csv-reader.js +97 -0
- package/dist/lib/catalogue/csv-writer.js +96 -0
- package/dist/lib/catalogue/ddl-generator.js +233 -0
- package/dist/lib/catalogue/engine-validator.js +222 -0
- package/dist/lib/catalogue/finding-identity.js +183 -0
- package/dist/lib/catalogue/findings.js +46 -0
- package/dist/lib/catalogue/fk-validator.js +136 -0
- package/dist/lib/catalogue/gate-checks.js +338 -0
- package/dist/lib/catalogue/graph-projection.js +329 -0
- package/dist/lib/catalogue/impact-run.js +228 -0
- package/dist/lib/catalogue/manifest-loader.js +422 -0
- package/dist/lib/catalogue/relationship-semantics.js +66 -0
- package/dist/lib/catalogue/rubric-coverage-map.yaml +764 -0
- package/dist/lib/catalogue/scaffold-delta.js +261 -0
- package/dist/lib/catalogue/schema-compat.js +52 -0
- package/dist/lib/catalogue/schema-loader.js +87 -0
- package/dist/lib/catalogue/scores-coverage.js +535 -0
- package/dist/lib/catalogue/staleness-verdicts.js +345 -0
- package/dist/lib/catalogue/validate.js +176 -0
- package/dist/lib/catalogue/version.js +18 -0
- package/dist/lib/list/discover.js +100 -0
- package/dist/lib/list/format.js +84 -0
- package/dist/lib/list/index.js +16 -0
- package/dist/lib/lock-file.js +413 -0
- package/dist/lib/pack/log.js +18 -0
- package/dist/lib/pack/schema.js +121 -0
- package/dist/lib/pack/seed.js +182 -0
- package/dist/lib/pack/validate.js +49 -0
- package/dist/lib/package-version.js +25 -0
- package/dist/lib/repo-kind.js +129 -0
- package/dist/lib/scripts.js +7 -0
- package/dist/lib/validate/aggregate.js +35 -0
- package/dist/lib/validate/bundle-coherence.js +69 -0
- package/dist/lib/validate/dispatch.js +218 -0
- package/dist/lib/validate/index.js +88 -0
- package/dist/lib/validate/runner.js +85 -0
- package/dist/verbs/catalogue-verify.js +114 -0
- package/dist/verbs/list.js +45 -0
- package/dist/verbs/pack-validate.js +17 -0
- package/dist/verbs/path-option.js +25 -0
- package/dist/verbs/validate.js +22 -0
- package/dist/verify-bin.js +8 -0
- package/dist/verify-program.js +115 -0
- package/package.json +32 -0
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transitive impact analysis — `sdd-cli catalogue impact` (M-ADR 0206 §4.6).
|
|
3
|
+
*
|
|
4
|
+
* Impact traverses the graph-projection edge set BY DECLARED SEMANTICS: every
|
|
5
|
+
* relationship carries an impact-flow token in the rubric-coverage-map
|
|
6
|
+
* (M-ADR 0206 §4.1) naming which endpoint RECEIVES impact — `toward-source`
|
|
7
|
+
* (dependency shape: a change at the target propagates to the source),
|
|
8
|
+
* `toward-target` (membership shape: a part's change reaches its whole),
|
|
9
|
+
* `both` (realisation), `none` (xref, lineage records, attributes). One
|
|
10
|
+
* relationship (`service_event`) resolves its flow per row from a named
|
|
11
|
+
* column (§4.2). The closure is the set of nodes impact can reach from the
|
|
12
|
+
* seed along declared propagation directions, computed by recursive SQL
|
|
13
|
+
* (`WITH RECURSIVE`) on the Phase A engine.
|
|
14
|
+
*
|
|
15
|
+
* THE CO-DIRECTIONALITY INVARIANT (§4.4, a condition of 0206's acceptance):
|
|
16
|
+
* the Phase C mirror-dedup heuristic is retired because under the declared
|
|
17
|
+
* tokens every true-mirror pair propagates the SAME direction. That premise
|
|
18
|
+
* is exercised, not assumed: buildImpactPropagation asserts that no node
|
|
19
|
+
* pair receives propagation in BOTH directions from two DIFFERENT
|
|
20
|
+
* single-direction relationship types, and fails loudly naming the pair and
|
|
21
|
+
* types. A single relationship declaring `both` is deliberate bidirectional
|
|
22
|
+
* coupling and is exempt; a self-referential type contributing both
|
|
23
|
+
* directions from different rows (a hierarchy cycle) is likewise exempt.
|
|
24
|
+
* A future token edit that would silently bring the undirected flood back
|
|
25
|
+
* trips this assertion instead.
|
|
26
|
+
*
|
|
27
|
+
* Cycle discipline: the recursion carries NO depth column — recursive UNION
|
|
28
|
+
* deduplicates whole rows, and a depth column makes every lap of a cycle a
|
|
29
|
+
* "new" row: the query that looks more informative is the query that never
|
|
30
|
+
* terminates. As a bare id-set the closure saturates and stops.
|
|
31
|
+
*/
|
|
32
|
+
import { existsSync } from "node:fs";
|
|
33
|
+
import { join, resolve } from "node:path";
|
|
34
|
+
import { readCatalogueCsv } from "./csv-reader.js";
|
|
35
|
+
import { loadCanonicalSchema } from "./schema-loader.js";
|
|
36
|
+
import { loadCatalogueKinds, loadRelationshipSemantics, } from "./relationship-semantics.js";
|
|
37
|
+
import { projectGraph } from "./graph-projection.js";
|
|
38
|
+
function resolveFlow(edge, semantics) {
|
|
39
|
+
const decl = edge.type.includes(".")
|
|
40
|
+
? semantics.entityFks.get(edge.type)
|
|
41
|
+
: semantics.edges.get(edge.type);
|
|
42
|
+
if (decl === undefined) {
|
|
43
|
+
throw new Error(`impact: relationship ${edge.type} has no declaration in the ` +
|
|
44
|
+
`rubric-coverage-map (M-ADR 0206) — sync the vendored map.`);
|
|
45
|
+
}
|
|
46
|
+
if (decl.impactFlow !== undefined)
|
|
47
|
+
return decl.impactFlow;
|
|
48
|
+
const by = decl.impactFlowBy;
|
|
49
|
+
const value = (edge.properties?.[by.column] ?? "").trim();
|
|
50
|
+
const flow = by.values[value];
|
|
51
|
+
if (flow === undefined) {
|
|
52
|
+
throw new Error(`impact: ${edge.type} resolves flow by column '${by.column}' but row ` +
|
|
53
|
+
`value '${value}' is not in the declared map — extend the ` +
|
|
54
|
+
`declaration or fix the row.`);
|
|
55
|
+
}
|
|
56
|
+
return flow;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Resolve every declared edge into propagation steps per its flow, and
|
|
60
|
+
* exercise the §4.4 co-directionality invariant.
|
|
61
|
+
*/
|
|
62
|
+
export function buildImpactPropagation(edges, semantics) {
|
|
63
|
+
const out = [];
|
|
64
|
+
const byFlow = new Map();
|
|
65
|
+
let flowNone = 0;
|
|
66
|
+
const pairs = new Map();
|
|
67
|
+
const notePair = (from, to, type) => {
|
|
68
|
+
const [a, b] = from < to ? [from, to] : [to, from];
|
|
69
|
+
const key = `${a}␟${b}`;
|
|
70
|
+
let rec = pairs.get(key);
|
|
71
|
+
if (rec === undefined) {
|
|
72
|
+
rec = { ab: new Set(), ba: new Set() };
|
|
73
|
+
pairs.set(key, rec);
|
|
74
|
+
}
|
|
75
|
+
(from === a ? rec.ab : rec.ba).add(type);
|
|
76
|
+
};
|
|
77
|
+
for (const e of edges) {
|
|
78
|
+
const flow = resolveFlow(e, semantics);
|
|
79
|
+
byFlow.set(flow, (byFlow.get(flow) ?? 0) + 1);
|
|
80
|
+
if (flow === "none") {
|
|
81
|
+
flowNone += 1;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (flow === "toward-source" || flow === "both") {
|
|
85
|
+
out.push({ from: e.target, to: e.source, type: e.type });
|
|
86
|
+
notePair(e.target, e.source, e.type);
|
|
87
|
+
}
|
|
88
|
+
if (flow === "toward-target" || flow === "both") {
|
|
89
|
+
out.push({ from: e.source, to: e.target, type: e.type });
|
|
90
|
+
notePair(e.source, e.target, e.type);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// §4.4 invariant, refined on measured data (2026-08-06): a pair fails only
|
|
94
|
+
// when one type contributes EXCLUSIVELY a→b against another contributing
|
|
95
|
+
// EXCLUSIVELY b→a. A type that itself spans both directions on the pair —
|
|
96
|
+
// opposing role-rows of a role-conditional relationship (a service that
|
|
97
|
+
// publishes AND consumes the same event: five real pairs on the reverse
|
|
98
|
+
// estate, one service, all session-broadcast shaped), a self-referential
|
|
99
|
+
// hierarchy cycle, or a declared `both` flow — has DECLARED that pair
|
|
100
|
+
// bidirectional, so other types aligning with either direction are
|
|
101
|
+
// consistent with declarations, not drift. The flipped-token flood shape
|
|
102
|
+
// (two single-direction declarations opposing) still fails, loudly.
|
|
103
|
+
for (const [key, rec] of pairs) {
|
|
104
|
+
const abOnly = [...rec.ab].filter((t) => !rec.ba.has(t));
|
|
105
|
+
const baOnly = [...rec.ba].filter((t) => !rec.ab.has(t));
|
|
106
|
+
if (abOnly.length > 0 && baOnly.length > 0) {
|
|
107
|
+
const [a, b] = key.split("␟");
|
|
108
|
+
throw new Error(`impact: counter-directional reciprocal pair (M-ADR 0206 §4.4): ` +
|
|
109
|
+
`${abOnly[0]} propagates ${a} -> ${b} while ${baOnly[0]} propagates ` +
|
|
110
|
+
`${b} -> ${a}, and neither declares the pair bidirectional. The ` +
|
|
111
|
+
`declared tokens have re-created an undirected pair; fix the ` +
|
|
112
|
+
`classification before trusting any closure.`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return { edges: out, flowNone, byFlow };
|
|
116
|
+
}
|
|
117
|
+
function sqlStringLiteral(value) {
|
|
118
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Closure of `seed` over resolved propagation edges, on the engine.
|
|
122
|
+
* Exported for the fixture controls; the verb goes through runCatalogueImpact.
|
|
123
|
+
*/
|
|
124
|
+
export async function computeImpactClosure(propagation, seed) {
|
|
125
|
+
const { DuckDBInstance } = await import("@duckdb/node-api");
|
|
126
|
+
const instance = await DuckDBInstance.create(":memory:");
|
|
127
|
+
const conn = await instance.connect();
|
|
128
|
+
try {
|
|
129
|
+
await conn.run(`CREATE TABLE prop (f VARCHAR, t VARCHAR);`);
|
|
130
|
+
const CHUNK = 500;
|
|
131
|
+
for (let i = 0; i < propagation.length; i += CHUNK) {
|
|
132
|
+
const tuples = propagation
|
|
133
|
+
.slice(i, i + CHUNK)
|
|
134
|
+
.map((e) => `(${sqlStringLiteral(e.from)}, ${sqlStringLiteral(e.to)})`)
|
|
135
|
+
.join(",\n");
|
|
136
|
+
await conn.run(`INSERT INTO prop VALUES\n${tuples};`);
|
|
137
|
+
}
|
|
138
|
+
const seedLit = sqlStringLiteral(seed);
|
|
139
|
+
// CYCLE GUARD: the recursion carries NO depth column, deliberately.
|
|
140
|
+
// Recursive UNION deduplicates whole rows; a depth column makes every
|
|
141
|
+
// lap around a cycle a "new" row (same id, bigger depth), so the query
|
|
142
|
+
// that looks more informative is the query that never terminates — the
|
|
143
|
+
// catalogue graph is not a DAG. As a bare id-set the closure saturates
|
|
144
|
+
// and stops.
|
|
145
|
+
const reader = await conn.runAndReadAll(`WITH RECURSIVE reach(id) AS (\n` +
|
|
146
|
+
` SELECT t FROM prop WHERE f = ${seedLit}\n` +
|
|
147
|
+
` UNION\n` +
|
|
148
|
+
` SELECT p.t FROM prop p JOIN reach r ON p.f = r.id\n` +
|
|
149
|
+
`)\n` +
|
|
150
|
+
`SELECT DISTINCT id FROM reach WHERE id <> ${seedLit} ORDER BY id;`);
|
|
151
|
+
return reader.getRows().map((row) => String(row[0]));
|
|
152
|
+
}
|
|
153
|
+
finally {
|
|
154
|
+
conn.disconnectSync();
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
export async function runCatalogueImpact(inputs, write) {
|
|
158
|
+
const sddRoot = resolve(inputs.sddPath ?? process.cwd());
|
|
159
|
+
const dir = resolve(sddRoot, inputs.cataloguesDir ?? "catalogues");
|
|
160
|
+
const tier = inputs.tier ?? "member";
|
|
161
|
+
if (!existsSync(dir)) {
|
|
162
|
+
write(`catalogues directory not found at ${dir}\n` +
|
|
163
|
+
` Run \`sdd-cli catalogue build\` (or aggregate) first.\n`);
|
|
164
|
+
return { exitCode: 2 };
|
|
165
|
+
}
|
|
166
|
+
const schema = await loadCanonicalSchema();
|
|
167
|
+
const kinds = await loadCatalogueKinds();
|
|
168
|
+
const semantics = await loadRelationshipSemantics();
|
|
169
|
+
const rowsByCatalogue = new Map();
|
|
170
|
+
for (const cat of schema.catalogues) {
|
|
171
|
+
const filePath = join(dir, `${cat.name}.csv`);
|
|
172
|
+
if (!existsSync(filePath))
|
|
173
|
+
continue;
|
|
174
|
+
rowsByCatalogue.set(cat.name, (await readCatalogueCsv(filePath)).rows);
|
|
175
|
+
}
|
|
176
|
+
const projection = projectGraph(schema, kinds, rowsByCatalogue, tier);
|
|
177
|
+
const lines = projection.text.trimEnd().split("\n");
|
|
178
|
+
const nodeIds = new Set();
|
|
179
|
+
const edges = [];
|
|
180
|
+
for (const line of lines) {
|
|
181
|
+
const o = JSON.parse(line);
|
|
182
|
+
if (o.t === "node")
|
|
183
|
+
nodeIds.add(o.id);
|
|
184
|
+
else if (o.t === "edge")
|
|
185
|
+
edges.push({
|
|
186
|
+
source: o.source,
|
|
187
|
+
target: o.target,
|
|
188
|
+
type: o.type,
|
|
189
|
+
...(o.properties !== undefined ? { properties: o.properties } : {}),
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
if (!nodeIds.has(inputs.of)) {
|
|
193
|
+
write(`node not found in the projection: ${inputs.of}\n` +
|
|
194
|
+
` Node ids have the form "<catalogue>/<id>", e.g. contracts/orders-api.\n`);
|
|
195
|
+
return { exitCode: 2 };
|
|
196
|
+
}
|
|
197
|
+
const prop = buildImpactPropagation(edges, semantics);
|
|
198
|
+
const closure = await computeImpactClosure(prop.edges, inputs.of);
|
|
199
|
+
const byCatalogue = new Map();
|
|
200
|
+
for (const id of closure) {
|
|
201
|
+
const cat = id.slice(0, id.indexOf("/"));
|
|
202
|
+
byCatalogue.set(cat, (byCatalogue.get(cat) ?? 0) + 1);
|
|
203
|
+
}
|
|
204
|
+
write(`impact closure of ${inputs.of} (${tier} tier, schema ${schema.schemaVersion})\n`);
|
|
205
|
+
// Required stated lines (M-ADR 0206 §4.6): what the number means, and what
|
|
206
|
+
// was traversed under each declared flow — visible per run, not
|
|
207
|
+
// discoverable from the code.
|
|
208
|
+
write(` semantics: change impact over classified relationships (M-ADR 0206) —\n` +
|
|
209
|
+
` each relationship traversed per its declared impact-flow; the\n` +
|
|
210
|
+
` co-directionality invariant is asserted on every run\n`);
|
|
211
|
+
const flowCounts = [...prop.byFlow.entries()]
|
|
212
|
+
.sort()
|
|
213
|
+
.map(([f, n]) => `${f}=${n}`)
|
|
214
|
+
.join(" ");
|
|
215
|
+
write(` declared edges by resolved flow: ${flowCounts}\n`);
|
|
216
|
+
write(` reachable nodes: ${closure.length}\n`);
|
|
217
|
+
for (const [cat, n] of [...byCatalogue.entries()].sort()) {
|
|
218
|
+
write(` ${cat}: ${n}\n`);
|
|
219
|
+
}
|
|
220
|
+
for (const id of closure) {
|
|
221
|
+
write(` ${id}\n`);
|
|
222
|
+
}
|
|
223
|
+
return {
|
|
224
|
+
exitCode: 0,
|
|
225
|
+
result: { seed: inputs.of, closure, byCatalogue },
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
//# sourceMappingURL=impact-run.js.map
|
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loader for the per-SDD `.sdd/sdd-manifest.yaml` per the schema at
|
|
3
|
+
* docs/method/methods/sdd-manifest-schema.yaml in sdd-method.
|
|
4
|
+
*
|
|
5
|
+
* Validates schema_version against SCHEMA_COMPAT and the
|
|
6
|
+
* profile↔category invariant from product-category-taxonomy.md.
|
|
7
|
+
*/
|
|
8
|
+
import { readFile } from "node:fs/promises";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { parse as parseYaml } from "yaml";
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
import { SCHEMA_COMPAT, formatSchemaVersionMismatch } from "./schema-compat.js";
|
|
13
|
+
export const MANIFEST_REL_PATH = ".sdd/sdd-manifest.yaml";
|
|
14
|
+
export const PROFILES = [
|
|
15
|
+
"platform-profile",
|
|
16
|
+
"application-product",
|
|
17
|
+
"integration-product",
|
|
18
|
+
"support-repo",
|
|
19
|
+
"organisation-profile",
|
|
20
|
+
];
|
|
21
|
+
export const CATEGORIES = [
|
|
22
|
+
"platform-root",
|
|
23
|
+
"platform-subsystem",
|
|
24
|
+
"application-product",
|
|
25
|
+
"integration-provider",
|
|
26
|
+
"integration-umbrella",
|
|
27
|
+
"support-repo",
|
|
28
|
+
"organisation-root",
|
|
29
|
+
];
|
|
30
|
+
const PROFILE_CATEGORY_INVARIANT = {
|
|
31
|
+
"platform-profile": ["platform-root", "platform-subsystem"],
|
|
32
|
+
"application-product": ["application-product"],
|
|
33
|
+
"integration-product": ["integration-provider", "integration-umbrella"],
|
|
34
|
+
"support-repo": ["support-repo"],
|
|
35
|
+
"organisation-profile": ["organisation-root"],
|
|
36
|
+
};
|
|
37
|
+
// M-ADR 0193 — the data-plane axis. `pattern` and `locality` are CLOSED
|
|
38
|
+
// method-tier enums; `transport`/`target`/`runtime`/`notes` are free
|
|
39
|
+
// (adopter-tier). Malformed bindings FAIL at load, consistent with every other
|
|
40
|
+
// fixed manifest enum.
|
|
41
|
+
export const DATA_PLANE_PATTERNS = [
|
|
42
|
+
// Inbound (data-sourcing) sub-set.
|
|
43
|
+
"retrieve",
|
|
44
|
+
"own-store",
|
|
45
|
+
"stream-ingest",
|
|
46
|
+
"project",
|
|
47
|
+
// Output-side sub-set (M-ADR 0193 §4.7): how the service produces or exposes
|
|
48
|
+
// data. `generate` produces a scheduled/periodic artefact (compute-bearing,
|
|
49
|
+
// carries a runtime); `publish` exposes an outward contract/API (not
|
|
50
|
+
// compute-bearing).
|
|
51
|
+
"generate",
|
|
52
|
+
"publish",
|
|
53
|
+
];
|
|
54
|
+
// Inbound data-sourcing patterns MUST name where the other end sits. `own-store`
|
|
55
|
+
// is a self-binding (no other end); the output-side patterns (`generate`/
|
|
56
|
+
// `publish`) MAY carry a locality but do not require one (M-ADR 0193 §4.7).
|
|
57
|
+
const LOCALITY_REQUIRED_PATTERNS = new Set([
|
|
58
|
+
"retrieve",
|
|
59
|
+
"stream-ingest",
|
|
60
|
+
"project",
|
|
61
|
+
]);
|
|
62
|
+
export const DATA_PLANE_LOCALITIES = [
|
|
63
|
+
"internal-platform",
|
|
64
|
+
"internal-domain",
|
|
65
|
+
"external-vendor",
|
|
66
|
+
];
|
|
67
|
+
// The OUTPUT-SIDE sub-set (M-ADR 0193 §4.7). This is the single method-tier
|
|
68
|
+
// direction mapping: every consumer that needs to branch on direction reads it
|
|
69
|
+
// from here rather than re-deriving the pattern split, so the sub-set the ADR
|
|
70
|
+
// declares has exactly one definition in the codebase.
|
|
71
|
+
const OUTPUT_SIDE_PATTERNS = new Set(["generate", "publish"]);
|
|
72
|
+
/**
|
|
73
|
+
* Direction of a data-plane pattern. DERIVED, never authored — `direction` is not
|
|
74
|
+
* a manifest field, so it cannot disagree with `pattern`. Three §4.7 rules branch
|
|
75
|
+
* on it: platform-dep reconciliation is inbound-only, the archetype-default WARN
|
|
76
|
+
* compares like with like, and the M-ADR 0187 convergence reasoning differs by
|
|
77
|
+
* direction.
|
|
78
|
+
*/
|
|
79
|
+
export function dataPlaneDirection(pattern) {
|
|
80
|
+
return OUTPUT_SIDE_PATTERNS.has(pattern) ? "output" : "inbound";
|
|
81
|
+
}
|
|
82
|
+
/** True for the output-side sub-set (`generate`/`publish`). */
|
|
83
|
+
export function isOutputSidePattern(pattern) {
|
|
84
|
+
return OUTPUT_SIDE_PATTERNS.has(pattern);
|
|
85
|
+
}
|
|
86
|
+
// Patterns that carry a backend-compute role and therefore a runtime choice
|
|
87
|
+
// (M-ADR 0193 §4.1, extended by §4.7). `stream-ingest`/`project` (inbound
|
|
88
|
+
// compute) and `generate` (scheduled-generation) carry a runtime; `retrieve`
|
|
89
|
+
// (BFF/client), `own-store` (private-api), and `publish` (an exposed contract,
|
|
90
|
+
// not compute in the sourcing sense) do not, so a `runtime` on them is malformed.
|
|
91
|
+
const COMPUTE_BEARING_PATTERNS = new Set([
|
|
92
|
+
"stream-ingest",
|
|
93
|
+
"project",
|
|
94
|
+
"generate",
|
|
95
|
+
]);
|
|
96
|
+
const DataPlaneBindingSchema = z
|
|
97
|
+
.object({
|
|
98
|
+
pattern: z.enum(DATA_PLANE_PATTERNS),
|
|
99
|
+
locality: z.enum(DATA_PLANE_LOCALITIES).optional(),
|
|
100
|
+
transport: z.string().optional(),
|
|
101
|
+
runtime: z.string().optional(),
|
|
102
|
+
target: z.string().optional(),
|
|
103
|
+
notes: z.string().optional(),
|
|
104
|
+
})
|
|
105
|
+
.refine((b) => !LOCALITY_REQUIRED_PATTERNS.has(b.pattern) || b.locality !== undefined, {
|
|
106
|
+
message: "locality is required for a data-sourcing binding (retrieve/stream-ingest/project); own-store and the output-side patterns (generate/publish) may omit it",
|
|
107
|
+
path: ["locality"],
|
|
108
|
+
})
|
|
109
|
+
.refine((b) => b.runtime === undefined || COMPUTE_BEARING_PATTERNS.has(b.pattern), {
|
|
110
|
+
message: "runtime is only valid on a compute-bearing binding (stream-ingest/project/generate); retrieve/own-store/publish carry no runtime choice",
|
|
111
|
+
path: ["runtime"],
|
|
112
|
+
});
|
|
113
|
+
// A declared departure from the archetype's default composition (M-ADR 0191
|
|
114
|
+
// §4.3 overlay). `expectation` names the archetype-default pattern the product
|
|
115
|
+
// knowingly diverges from; the rationale makes it sanctioned variance.
|
|
116
|
+
const DataPlaneDeviationSchema = z.object({
|
|
117
|
+
expectation: z.string(),
|
|
118
|
+
rationale: z.string().min(1, {
|
|
119
|
+
message: "a data_plane deviation must carry a non-empty rationale",
|
|
120
|
+
}),
|
|
121
|
+
});
|
|
122
|
+
const DataPlaneSchema = z.object({
|
|
123
|
+
bindings: z.array(DataPlaneBindingSchema),
|
|
124
|
+
deviations: z.array(DataPlaneDeviationSchema).optional(),
|
|
125
|
+
});
|
|
126
|
+
// Conformance coverage (M-ADR 0191 §4.3, second overlay element — WS4). An
|
|
127
|
+
// application-product asserts which of its archetype's conformance expectations
|
|
128
|
+
// its own conformance suite exercises (`coverage`), and declares a rationale for
|
|
129
|
+
// any expected tag it deliberately does not cover (`deviations`). The method
|
|
130
|
+
// gates the DECLARATION — it does not run the adopter's conformance tests.
|
|
131
|
+
const ConformanceSchema = z.object({
|
|
132
|
+
coverage: z.array(z.string()).optional(),
|
|
133
|
+
deviations: z
|
|
134
|
+
.array(z.object({
|
|
135
|
+
tag: z.string(),
|
|
136
|
+
rationale: z.string().min(1, {
|
|
137
|
+
message: "a conformance deviation must carry a non-empty rationale",
|
|
138
|
+
}),
|
|
139
|
+
}))
|
|
140
|
+
.optional(),
|
|
141
|
+
});
|
|
142
|
+
/**
|
|
143
|
+
* Owner-declared product lifecycle. Exported as a runtime array, not just a type,
|
|
144
|
+
* so the canonical-conformance test can compare the CANONICAL vocabulary against
|
|
145
|
+
* the IMPLEMENTATION rather than against a list duplicated in the test — a
|
|
146
|
+
* hardcoded expectation cannot detect the implementation drifting.
|
|
147
|
+
*
|
|
148
|
+
* `planned` = real and intended but does not exist yet (sdd-manifest-schema
|
|
149
|
+
* 1.9.0). Absent still reads as `active`.
|
|
150
|
+
*/
|
|
151
|
+
export const LIFECYCLES = ["planned", "active", "deprecated"];
|
|
152
|
+
const ManifestSchema = z.object({
|
|
153
|
+
schema_version: z.string(),
|
|
154
|
+
sdd_id: z.string().regex(/^[a-z][a-z0-9-]*$/, {
|
|
155
|
+
message: "sdd_id must match /^[a-z][a-z0-9-]*$/",
|
|
156
|
+
}),
|
|
157
|
+
profile: z.enum(PROFILES),
|
|
158
|
+
category: z.enum(CATEGORIES),
|
|
159
|
+
domain_id: z
|
|
160
|
+
.string()
|
|
161
|
+
.regex(/^[a-z][a-z0-9-]*$/)
|
|
162
|
+
.optional(),
|
|
163
|
+
ecosystem_root: z.string().optional(),
|
|
164
|
+
// M-ADR 0208 §4.3: the platform this member is contained by. Canonical name;
|
|
165
|
+
// `ecosystem_root` above is its deprecated alias (schema 1.10.0, removed at 2.0.0).
|
|
166
|
+
platform_sdd_id: z.string().optional(),
|
|
167
|
+
aggregation_manifest_path: z.string().optional(),
|
|
168
|
+
description: z.string().optional(),
|
|
169
|
+
// Optional canonical C4 system id. Defaults to `{sdd_id}-system`. Set this
|
|
170
|
+
// when the system was already catalogued under an established id elsewhere
|
|
171
|
+
// (e.g. a reverse-extracted system pulled into its own SDD) so the id stays
|
|
172
|
+
// stable across SDDs and aggregation.
|
|
173
|
+
c4_system_id: z
|
|
174
|
+
.string()
|
|
175
|
+
.regex(/^[a-z][a-z0-9-]*$/, {
|
|
176
|
+
message: "c4_system_id must match /^[a-z][a-z0-9-]*$/",
|
|
177
|
+
})
|
|
178
|
+
.optional(),
|
|
179
|
+
// Owner-declared product lifecycle (sdd-manifest-schema 1.1.0). Absent
|
|
180
|
+
// means "active". Unknown values are a hard load failure, consistent with
|
|
181
|
+
// every other manifest enum — a typo'd lifecycle silently defaulting to
|
|
182
|
+
// active would recreate the exact false-active bug the field exists to fix.
|
|
183
|
+
// `planned` (sdd-manifest-schema 1.9.0): the product is real and intended but
|
|
184
|
+
// does not exist yet — the normal OPENING state of a forward-authored product,
|
|
185
|
+
// where the SDD is written before the code. The absent-means-"active" default
|
|
186
|
+
// was the one state a pre-realisation estate cannot be in, so it silently
|
|
187
|
+
// asserted that every unbuilt product was running. A fact about the PRODUCT,
|
|
188
|
+
// deliberately not about the document (that is `status`).
|
|
189
|
+
lifecycle: z.enum(LIFECYCLES).optional(),
|
|
190
|
+
// Application-product surface trait (M-ADR 0167, sdd-manifest-schema 1.2.0).
|
|
191
|
+
// Fixed enum: ui (human-facing, default) | api (headless). Unknown values
|
|
192
|
+
// are a hard load failure like every other fixed manifest enum. Ignored off
|
|
193
|
+
// application-product (cleared with a convention finding in the products
|
|
194
|
+
// builder). Absent reads as "ui".
|
|
195
|
+
surface: z.enum(["ui", "api"]).optional(),
|
|
196
|
+
// Application-product packaging trait (M-ADR 0175, sdd-manifest-schema
|
|
197
|
+
// 1.7.0). Fixed enum, deliberately narrow: `vendor-saas` marks a
|
|
198
|
+
// packaged-SaaS product (vendor-operated runtime, vendor-versioned base);
|
|
199
|
+
// absent reads as self-operated, the unmarked norm. Unknown values are a hard
|
|
200
|
+
// load failure like every other fixed manifest enum. Ignored off
|
|
201
|
+
// application-product (cleared with a convention finding in the products
|
|
202
|
+
// builder). Per M-ADR 0175 §4 no builder may branch on this beyond
|
|
203
|
+
// evidence/deployment handling.
|
|
204
|
+
packaging: z.enum(["vendor-saas"]).optional(),
|
|
205
|
+
// Reference-exemplar trait (sdd-manifest-schema 1.8.0). PROFILE-AGNOSTIC,
|
|
206
|
+
// unlike the three traits it sits between — a demonstration
|
|
207
|
+
// application-product is as conceivable as the integration-products the first
|
|
208
|
+
// cohort came from — so the products builder must NOT clear it off
|
|
209
|
+
// application-product. Absent reads as a real SDD, the unmarked norm, with no
|
|
210
|
+
// finding. Only `true` is meaningful; `false` is redundant but accepted.
|
|
211
|
+
exemplar: z.boolean().optional(),
|
|
212
|
+
// Application-product archetype trait (M-ADR 0191, sdd-manifest-schema
|
|
213
|
+
// 1.2.0). RESOLVED enum, not fixed: the loader validates only the slug
|
|
214
|
+
// shape here. Whether the value resolves against the active vocabulary
|
|
215
|
+
// (method ∪ RA-bundle ∪ adopter) is checked in the build step, which has
|
|
216
|
+
// the bundle cache. Absent reads as "unclassified" (with a finding). Named
|
|
217
|
+
// application_archetype to stay distinct from the ADR 0173 domain archetype
|
|
218
|
+
// and the ADR 0169 openui archetype.
|
|
219
|
+
application_archetype: z
|
|
220
|
+
.string()
|
|
221
|
+
.regex(/^[a-z][a-z0-9-]*$/, {
|
|
222
|
+
message: "application_archetype must match /^[a-z][a-z0-9-]*$/",
|
|
223
|
+
})
|
|
224
|
+
.optional(),
|
|
225
|
+
deprecated_date: z
|
|
226
|
+
.string()
|
|
227
|
+
.regex(/^\d{4}-\d{2}-\d{2}$/, {
|
|
228
|
+
message: "deprecated_date must be a YYYY-MM-DD date",
|
|
229
|
+
})
|
|
230
|
+
.optional(),
|
|
231
|
+
successor_sdd_id: z
|
|
232
|
+
.string()
|
|
233
|
+
.regex(/^[a-z][a-z0-9-]*$/, {
|
|
234
|
+
message: "successor_sdd_id must match /^[a-z][a-z0-9-]*$/ (sdd_id vocabulary)",
|
|
235
|
+
})
|
|
236
|
+
.optional(),
|
|
237
|
+
// Application-product data-plane axis (M-ADR 0193, sdd-manifest-schema 1.3.0);
|
|
238
|
+
// `data_plane.deviations` added in 1.4.0 (M-ADR 0191 §4.3 overlay). A
|
|
239
|
+
// composition of typed bindings (malformed bindings FAIL at load) plus
|
|
240
|
+
// optional declared deviations from the archetype default, each carrying a
|
|
241
|
+
// rationale so the mismatch is sanctioned variance, not silent drift.
|
|
242
|
+
data_plane: DataPlaneSchema.optional(),
|
|
243
|
+
// Conformance coverage declaration (M-ADR 0191 §4.3 WS4, sdd-manifest-schema
|
|
244
|
+
// 1.5.0). Which archetype conformance expectations the product's suite
|
|
245
|
+
// exercises, plus rationales for any it deliberately does not.
|
|
246
|
+
conformance: ConformanceSchema.optional(),
|
|
247
|
+
});
|
|
248
|
+
/** What `products.packaging` carries when the manifest declares nothing. */
|
|
249
|
+
export const PACKAGING_DEFAULT = "self-operated";
|
|
250
|
+
/**
|
|
251
|
+
* Read and validate the `.sdd/sdd-manifest.yaml` at the given SDD repo root.
|
|
252
|
+
*
|
|
253
|
+
* Throws on:
|
|
254
|
+
* - file missing
|
|
255
|
+
* - YAML parse error
|
|
256
|
+
* - shape violation (missing fields, bad enum value, bad slug pattern)
|
|
257
|
+
* - profile↔category invariant violation
|
|
258
|
+
* - schema_version outside SCHEMA_COMPAT.sdd_manifest range
|
|
259
|
+
*/
|
|
260
|
+
/**
|
|
261
|
+
* Top-level keys this build understands. Derived from the zod shape so it cannot
|
|
262
|
+
* drift from what is actually parsed — a hand-maintained list would go stale and
|
|
263
|
+
* start reporting known fields as unknown, which is worse than saying nothing.
|
|
264
|
+
*/
|
|
265
|
+
const KNOWN_MANIFEST_KEYS = new Set(Object.keys(ManifestSchema.shape));
|
|
266
|
+
/**
|
|
267
|
+
* Top-level keys present in the file that this build does not know.
|
|
268
|
+
*
|
|
269
|
+
* Deliberately TOP-LEVEL only. Nested blocks (`data_plane`, `conformance`) are
|
|
270
|
+
* validated by their own object schemas and strip their own unknowns; walking
|
|
271
|
+
* them would need a per-schema recursion this does not yet warrant. Stated so the
|
|
272
|
+
* limit is known rather than assumed — an unknown key nested inside `data_plane`
|
|
273
|
+
* is still silently dropped today.
|
|
274
|
+
*/
|
|
275
|
+
function findUnknownTopLevelKeys(raw) {
|
|
276
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
|
|
277
|
+
return [];
|
|
278
|
+
return Object.keys(raw)
|
|
279
|
+
.filter((k) => !KNOWN_MANIFEST_KEYS.has(k))
|
|
280
|
+
.sort();
|
|
281
|
+
}
|
|
282
|
+
export async function loadSddManifest(sddPath, options) {
|
|
283
|
+
const sourcePath = join(sddPath, MANIFEST_REL_PATH);
|
|
284
|
+
let text;
|
|
285
|
+
try {
|
|
286
|
+
text = await readFile(sourcePath, "utf-8");
|
|
287
|
+
}
|
|
288
|
+
catch (err) {
|
|
289
|
+
const code = err.code;
|
|
290
|
+
if (code === "ENOENT") {
|
|
291
|
+
throw new Error(`SDD manifest not found at ${sourcePath}\n\n` +
|
|
292
|
+
`Every SDD repo MUST declare an .sdd/sdd-manifest.yaml per ADR 0134 §4.6.\n` +
|
|
293
|
+
`Schema: docs/method/methods/sdd-manifest-schema.yaml in sdd-method.\n` +
|
|
294
|
+
`Bootstrap: \`sdd-cli init\` (when migrated to support manifest authoring).`);
|
|
295
|
+
}
|
|
296
|
+
throw err;
|
|
297
|
+
}
|
|
298
|
+
let raw;
|
|
299
|
+
try {
|
|
300
|
+
raw = parseYaml(text);
|
|
301
|
+
}
|
|
302
|
+
catch (err) {
|
|
303
|
+
throw new Error(`failed to parse YAML at ${sourcePath}: ${err.message}`);
|
|
304
|
+
}
|
|
305
|
+
// Unknown top-level keys are REPORTED, not silently dropped and not fatal.
|
|
306
|
+
//
|
|
307
|
+
// zod's default is `strip`, so a key this build does not know vanished without a
|
|
308
|
+
// trace: an adopter following a schema that documents a field their sdd-cli
|
|
309
|
+
// predates got a clean validation and a silent no-op. Not hypothetical —
|
|
310
|
+
// M-ADR 0175's own version history records exactly this happening to
|
|
311
|
+
// `packaging`, where the ADR instructed adopters to declare a field the CLI had
|
|
312
|
+
// never implemented.
|
|
313
|
+
//
|
|
314
|
+
// `.strict()` is the WRONG fix: it turns the same situation into a hard failure,
|
|
315
|
+
// so an older CLI could not build an SDD whose manifest declares anything newer
|
|
316
|
+
// than itself. Newer baselines ship newer schemas by design, so that inverts the
|
|
317
|
+
// problem instead of solving it. Reporting keeps the declaration visible while
|
|
318
|
+
// leaving the build working.
|
|
319
|
+
const unknownKeys = findUnknownTopLevelKeys(raw);
|
|
320
|
+
const parsed = ManifestSchema.safeParse(raw);
|
|
321
|
+
if (!parsed.success) {
|
|
322
|
+
const issues = parsed.error.issues
|
|
323
|
+
.map((i) => ` - ${i.path.join(".") || "<root>"}: ${i.message}`)
|
|
324
|
+
.join("\n");
|
|
325
|
+
throw new Error(`invalid SDD manifest at ${sourcePath}:\n${issues}`);
|
|
326
|
+
}
|
|
327
|
+
// Compat range check.
|
|
328
|
+
if (!isInRange(parsed.data.schema_version, SCHEMA_COMPAT.sdd_manifest)) {
|
|
329
|
+
throw new Error(formatSchemaVersionMismatch({
|
|
330
|
+
schema: "sdd_manifest",
|
|
331
|
+
readVersion: parsed.data.schema_version,
|
|
332
|
+
cliVersion: options.cliVersion,
|
|
333
|
+
sourcePath,
|
|
334
|
+
}));
|
|
335
|
+
}
|
|
336
|
+
// Profile ↔ category invariant.
|
|
337
|
+
const permitted = PROFILE_CATEGORY_INVARIANT[parsed.data.profile];
|
|
338
|
+
if (!permitted.includes(parsed.data.category)) {
|
|
339
|
+
throw new Error(`invalid profile/category combination at ${sourcePath}:\n` +
|
|
340
|
+
` profile=${parsed.data.profile}, category=${parsed.data.category}\n` +
|
|
341
|
+
` permitted categories under profile=${parsed.data.profile}: ${permitted.join(", ")}\n` +
|
|
342
|
+
` per docs/method/methods/product-category-taxonomy.md`);
|
|
343
|
+
}
|
|
344
|
+
return {
|
|
345
|
+
schemaVersion: parsed.data.schema_version,
|
|
346
|
+
sddId: parsed.data.sdd_id,
|
|
347
|
+
profile: parsed.data.profile,
|
|
348
|
+
category: parsed.data.category,
|
|
349
|
+
...(parsed.data.domain_id !== undefined ? { domainId: parsed.data.domain_id } : {}),
|
|
350
|
+
...(parsed.data.c4_system_id !== undefined ? { c4SystemId: parsed.data.c4_system_id } : {}),
|
|
351
|
+
...(parsed.data.ecosystem_root !== undefined
|
|
352
|
+
? { ecosystemRoot: parsed.data.ecosystem_root }
|
|
353
|
+
: {}),
|
|
354
|
+
...((parsed.data.platform_sdd_id ?? parsed.data.ecosystem_root) !== undefined
|
|
355
|
+
? {
|
|
356
|
+
platformSddId: (parsed.data.platform_sdd_id ?? parsed.data.ecosystem_root),
|
|
357
|
+
platformFieldDeprecated: parsed.data.platform_sdd_id === undefined && parsed.data.ecosystem_root !== undefined,
|
|
358
|
+
}
|
|
359
|
+
: {}),
|
|
360
|
+
...(parsed.data.aggregation_manifest_path !== undefined
|
|
361
|
+
? { aggregationManifestPath: parsed.data.aggregation_manifest_path }
|
|
362
|
+
: {}),
|
|
363
|
+
...(parsed.data.description !== undefined ? { description: parsed.data.description } : {}),
|
|
364
|
+
...(parsed.data.lifecycle !== undefined ? { lifecycle: parsed.data.lifecycle } : {}),
|
|
365
|
+
...(parsed.data.surface !== undefined ? { surface: parsed.data.surface } : {}),
|
|
366
|
+
...(parsed.data.packaging !== undefined ? { packaging: parsed.data.packaging } : {}),
|
|
367
|
+
...(parsed.data.exemplar !== undefined ? { exemplar: parsed.data.exemplar } : {}),
|
|
368
|
+
...(parsed.data.application_archetype !== undefined
|
|
369
|
+
? { applicationArchetype: parsed.data.application_archetype }
|
|
370
|
+
: {}),
|
|
371
|
+
...(parsed.data.deprecated_date !== undefined
|
|
372
|
+
? { deprecatedDate: parsed.data.deprecated_date }
|
|
373
|
+
: {}),
|
|
374
|
+
...(parsed.data.successor_sdd_id !== undefined
|
|
375
|
+
? { successorSddId: parsed.data.successor_sdd_id }
|
|
376
|
+
: {}),
|
|
377
|
+
...(parsed.data.data_plane !== undefined ? { dataPlane: parsed.data.data_plane } : {}),
|
|
378
|
+
...(parsed.data.conformance !== undefined ? { conformance: parsed.data.conformance } : {}),
|
|
379
|
+
...(unknownKeys.length > 0 ? { unknownKeys } : {}),
|
|
380
|
+
sourcePath,
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* Caret-prefixed semver range check. Accepts X in `^1.2.3` if X.major === 1
|
|
385
|
+
* and X >= 1.2.3.
|
|
386
|
+
*
|
|
387
|
+
* Restricted to the `^X.Y.Z` shape — that's the only form SCHEMA_COMPAT uses.
|
|
388
|
+
*/
|
|
389
|
+
export function isInRange(version, range) {
|
|
390
|
+
if (!range.startsWith("^")) {
|
|
391
|
+
throw new Error(`unsupported range syntax: ${range}`);
|
|
392
|
+
}
|
|
393
|
+
const v = parseSemver(version);
|
|
394
|
+
const r = parseSemver(range.slice(1));
|
|
395
|
+
if (v === null || r === null)
|
|
396
|
+
return false;
|
|
397
|
+
if (v.major !== r.major)
|
|
398
|
+
return false;
|
|
399
|
+
if (v.major === 0) {
|
|
400
|
+
// Per semver, 0.x is unstable; ^0.x.y matches only 0.x.y itself.
|
|
401
|
+
return v.minor === r.minor && v.patch >= r.patch;
|
|
402
|
+
}
|
|
403
|
+
if (v.minor < r.minor)
|
|
404
|
+
return false;
|
|
405
|
+
if (v.minor === r.minor && v.patch < r.patch)
|
|
406
|
+
return false;
|
|
407
|
+
return true;
|
|
408
|
+
}
|
|
409
|
+
function parseSemver(s) {
|
|
410
|
+
const m = /^(\d+)\.(\d+)\.(\d+)/.exec(s);
|
|
411
|
+
if (!m)
|
|
412
|
+
return null;
|
|
413
|
+
const [, maj, min, pat] = m;
|
|
414
|
+
if (maj === undefined || min === undefined || pat === undefined)
|
|
415
|
+
return null;
|
|
416
|
+
return {
|
|
417
|
+
major: parseInt(maj, 10),
|
|
418
|
+
minor: parseInt(min, 10),
|
|
419
|
+
patch: parseInt(pat, 10),
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
//# sourceMappingURL=manifest-loader.js.map
|