@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,338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase-0 gate checks as SQL — M-ADR 0205 §4.4 (engine-evolution Phase A3,
|
|
3
|
+
* final tranche). The nine small catalogue-integrity-gate steps
|
|
4
|
+
* re-expressed on the engine: emptiness-vs-populated-inputs signals
|
|
5
|
+
* (repo-edges 0157, data-model-entities 0159, capability-event 0160,
|
|
6
|
+
* feature-event 0161, platform-dependencies 0162, glossary-xref 0158),
|
|
7
|
+
* the per-domain event-catalogue count compare (0156), and the two
|
|
8
|
+
* features.csv Phase-0 checks (0177 F1 promotion, F2 near-duplicate
|
|
9
|
+
* surfaces — the classified borderline: engine for access, the token
|
|
10
|
+
* Jaccard stays host-language).
|
|
11
|
+
*
|
|
12
|
+
* The shell scripts remain the REFERENCE implementations until A4. Known,
|
|
13
|
+
* deliberate divergence class: several references read CSV columns
|
|
14
|
+
* POSITIONALLY with a naive comma split (`awk -F, $5`) and count PHYSICAL
|
|
15
|
+
* lines rather than CSV records — this port reads by column name over
|
|
16
|
+
* parsed records. Where real data makes those disagree, the dual-run
|
|
17
|
+
* driver surfaces it and the adjudication is a defect in the shell side
|
|
18
|
+
* (the exact matcher-error class M-ADR 0205 §1 exists to kill), never a
|
|
19
|
+
* smoothing of this side.
|
|
20
|
+
*
|
|
21
|
+
* File and YAML ingestion stay host-side, mirroring each reference's
|
|
22
|
+
* refusal semantics (a missing key, an unparseable document, an absent
|
|
23
|
+
* tool each behave exactly as the reference behaves).
|
|
24
|
+
*/
|
|
25
|
+
import { readFileSync, readdirSync, existsSync } from "node:fs";
|
|
26
|
+
import { join } from "node:path";
|
|
27
|
+
import { parse as parseYaml } from "yaml";
|
|
28
|
+
async function scalar(session, sql) {
|
|
29
|
+
const reader = await session.conn.runAndReadAll(sql);
|
|
30
|
+
return Number(reader.getRows()[0][0]);
|
|
31
|
+
}
|
|
32
|
+
const count = (table, where = "") => `SELECT COUNT(*) FROM "${table}"${where ? ` WHERE ${where}` : ""};`;
|
|
33
|
+
// @rule gate-event-catalogue
|
|
34
|
+
export async function runEventCatalogueCheck(session, options) {
|
|
35
|
+
if (!options.eventsPresent)
|
|
36
|
+
return { skipped: true, gaps: [] };
|
|
37
|
+
const domainsDir = join(options.sddRoot, "docs", "domains");
|
|
38
|
+
if (!existsSync(domainsDir))
|
|
39
|
+
return { skipped: false, gaps: [] };
|
|
40
|
+
const gaps = [];
|
|
41
|
+
for (const domain of readdirSync(domainsDir).sort()) {
|
|
42
|
+
const catalogPath = join(domainsDir, domain, "contracts", "events", "event-catalog.yaml");
|
|
43
|
+
if (!existsSync(catalogPath))
|
|
44
|
+
continue;
|
|
45
|
+
// Mirror of `yq '.events | length' || echo 0`: missing key or null → 0,
|
|
46
|
+
// an array → its length, a mapping → its key count, unparseable → 0.
|
|
47
|
+
let declared = 0;
|
|
48
|
+
try {
|
|
49
|
+
const doc = parseYaml(readFileSync(catalogPath, "utf-8"));
|
|
50
|
+
const events = doc?.["events"];
|
|
51
|
+
if (Array.isArray(events))
|
|
52
|
+
declared = events.length;
|
|
53
|
+
else if (events !== null && events !== undefined && typeof events === "object")
|
|
54
|
+
declared = Object.keys(events).length;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
declared = 0;
|
|
58
|
+
}
|
|
59
|
+
if (declared <= 0)
|
|
60
|
+
continue;
|
|
61
|
+
const emitted = await scalar(session, count("events", `COALESCE("domain_id", '') = '${domain.replace(/'/g, "''")}'`));
|
|
62
|
+
if (emitted < declared)
|
|
63
|
+
gaps.push({ domain, declared, emitted });
|
|
64
|
+
}
|
|
65
|
+
return { skipped: false, gaps };
|
|
66
|
+
}
|
|
67
|
+
// @rule gate-repo-edges
|
|
68
|
+
export async function runRepoEdgesCheck(session, options) {
|
|
69
|
+
const repoCaps = await scalar(session, count("repo_capability"));
|
|
70
|
+
const capSvcs = await scalar(session, count("capability_service"));
|
|
71
|
+
const svcsWithRepo = await scalar(session, count("services", `COALESCE("primary_repo_id", '') <> ''`));
|
|
72
|
+
const repoContracts = await scalar(session, count("repo_contract"));
|
|
73
|
+
const contracts = await scalar(session, count("contracts"));
|
|
74
|
+
const svcContracts = await scalar(session, count("service_contract"));
|
|
75
|
+
// Mirror of the reference's two glob patterns (bash without globstar:
|
|
76
|
+
// `**` collapses to one level) — direct files AND one directory deep,
|
|
77
|
+
// schema.yaml deliberately NOT excluded because the reference scans it.
|
|
78
|
+
let repoConfigsWithContracts = 0;
|
|
79
|
+
const cfgDir = join(options.sddRoot, "orchestration", "repo-config");
|
|
80
|
+
if (existsSync(cfgDir)) {
|
|
81
|
+
const candidates = [];
|
|
82
|
+
for (const entry of readdirSync(cfgDir, { withFileTypes: true })) {
|
|
83
|
+
const p = join(cfgDir, entry.name);
|
|
84
|
+
if (entry.isFile() && entry.name.endsWith(".yaml"))
|
|
85
|
+
candidates.push(p);
|
|
86
|
+
else if (entry.isDirectory()) {
|
|
87
|
+
for (const f of readdirSync(p)) {
|
|
88
|
+
if (f.endsWith(".yaml"))
|
|
89
|
+
candidates.push(join(p, f));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
for (const p of candidates) {
|
|
94
|
+
if (/^contracts:/m.test(readFileSync(p, "utf-8")))
|
|
95
|
+
repoConfigsWithContracts++;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
...(repoCaps === 0 && capSvcs > 0 && svcsWithRepo > 0
|
|
100
|
+
? { repoCapabilityGap: { capabilityService: capSvcs, servicesWithRepo: svcsWithRepo } }
|
|
101
|
+
: {}),
|
|
102
|
+
...(repoContracts === 0 && contracts > 0 && repoConfigsWithContracts > 0
|
|
103
|
+
? { repoContractForwardGap: { contracts, repoConfigsWithContracts } }
|
|
104
|
+
: {}),
|
|
105
|
+
...(repoContracts === 0 && svcContracts > 0 && svcsWithRepo > 0
|
|
106
|
+
? { repoContractPathXGap: { serviceContracts: svcContracts, servicesWithRepo: svcsWithRepo } }
|
|
107
|
+
: {}),
|
|
108
|
+
...(repoContracts === 0 &&
|
|
109
|
+
contracts > 0 &&
|
|
110
|
+
repoConfigsWithContracts === 0 &&
|
|
111
|
+
svcContracts === 0
|
|
112
|
+
? { repoContractReverseGap: { contracts } }
|
|
113
|
+
: {}),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
// ─── 0158: glossary-xref ingestion gap ───────────────────────────────────
|
|
117
|
+
// @rule gate-glossary-xref
|
|
118
|
+
export async function runGlossaryXrefCheck(session, options) {
|
|
119
|
+
const manifest = join(options.sddRoot, "docs", "platform", "reference", "glossary-alignment-manifest.yaml");
|
|
120
|
+
if (!existsSync(manifest) || !options.xrefPresent)
|
|
121
|
+
return { finding: false };
|
|
122
|
+
const rows = await scalar(session, count("glossary_xref"));
|
|
123
|
+
return { finding: rows === 0 };
|
|
124
|
+
}
|
|
125
|
+
// @rule gate-data-model-entities
|
|
126
|
+
export async function runDataModelEntitiesCheck(session) {
|
|
127
|
+
const domainModels = await scalar(session, count("domain_models"));
|
|
128
|
+
const entities = await scalar(session, count("entities"));
|
|
129
|
+
const services = await scalar(session, count("services"));
|
|
130
|
+
const serviceEntity = await scalar(session, count("service_entity"));
|
|
131
|
+
const containerEntity = await scalar(session, count("c4_container_entity"));
|
|
132
|
+
const containersWithService = await scalar(session, count("c4_containers", `COALESCE("service_id", '') <> ''`));
|
|
133
|
+
return {
|
|
134
|
+
...(domainModels > 0 && entities === 0 ? { entitiesGap: { domainModels } } : {}),
|
|
135
|
+
...(entities > 0 && services > 0 && serviceEntity === 0
|
|
136
|
+
? { serviceEntityGap: { entities, services } }
|
|
137
|
+
: {}),
|
|
138
|
+
...(serviceEntity > 0 && containersWithService > 0 && containerEntity === 0
|
|
139
|
+
? { containerEntityGap: { containersWithService, serviceEntity } }
|
|
140
|
+
: {}),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
// @rule gate-capability-event
|
|
144
|
+
export async function runCapabilityEventCheck(session) {
|
|
145
|
+
const serviceEvent = await scalar(session, count("service_event"));
|
|
146
|
+
const capabilityService = await scalar(session, count("capability_service"));
|
|
147
|
+
const capabilityEvent = await scalar(session, count("capability_event"));
|
|
148
|
+
let authoredGap;
|
|
149
|
+
if (capabilityEvent > 0) {
|
|
150
|
+
const authored = await scalar(session, count("capability_event", `COALESCE("source", '') = 'authored'`));
|
|
151
|
+
const derived = await scalar(session, count("capability_event", `COALESCE("source", '') = 'derived'`));
|
|
152
|
+
if (authored === 0 && derived > 0)
|
|
153
|
+
authoredGap = { derived };
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
...(serviceEvent > 0 && capabilityService > 0 && capabilityEvent === 0
|
|
157
|
+
? { derivableGap: { serviceEvent, capabilityService } }
|
|
158
|
+
: {}),
|
|
159
|
+
...(authoredGap !== undefined ? { authoredGap } : {}),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
// ─── 0161: feature_event derivation gap ──────────────────────────────────
|
|
163
|
+
// @rule gate-feature-event
|
|
164
|
+
export async function runFeatureEventCheck(session) {
|
|
165
|
+
const serviceEvent = await scalar(session, count("service_event"));
|
|
166
|
+
const featureService = await scalar(session, count("feature_service"));
|
|
167
|
+
const featureEvent = await scalar(session, count("feature_event"));
|
|
168
|
+
return serviceEvent > 0 && featureService > 0 && featureEvent === 0
|
|
169
|
+
? { gap: { serviceEvent, featureService } }
|
|
170
|
+
: {};
|
|
171
|
+
}
|
|
172
|
+
// ─── 0162: forward platform-dependencies parity gap ──────────────────────
|
|
173
|
+
// @rule gate-platform-dependencies
|
|
174
|
+
export async function runPlatformDependenciesCheck(session, options) {
|
|
175
|
+
const forwardFile = join(options.sddRoot, "app-platform-dependencies.yaml");
|
|
176
|
+
const forwardAuthored = existsSync(forwardFile) &&
|
|
177
|
+
/^\s*platform_dependencies:/m.test(readFileSync(forwardFile, "utf-8"));
|
|
178
|
+
if (!forwardAuthored)
|
|
179
|
+
return { finding: false };
|
|
180
|
+
const ppd = await scalar(session, count("product_platform_dependencies"));
|
|
181
|
+
return { finding: ppd === 0 };
|
|
182
|
+
}
|
|
183
|
+
// ─── 0177 F1: feature-spec promotion ─────────────────────────────────────
|
|
184
|
+
/** Python's round(): half to even — the reference computes pct with it. */
|
|
185
|
+
function pythonRound(x) {
|
|
186
|
+
const floor = Math.floor(x);
|
|
187
|
+
const diff = x - floor;
|
|
188
|
+
if (diff > 0.5)
|
|
189
|
+
return floor + 1;
|
|
190
|
+
if (diff < 0.5)
|
|
191
|
+
return floor;
|
|
192
|
+
return floor % 2 === 0 ? floor : floor + 1;
|
|
193
|
+
}
|
|
194
|
+
// @rule gate-feature-spec-promotion
|
|
195
|
+
export async function runFeatureSpecPromotionCheck(session) {
|
|
196
|
+
const reader = await session.conn.runAndReadAll(`SELECT COALESCE("source", '') FROM "features";`);
|
|
197
|
+
let total = 0;
|
|
198
|
+
let resolvable = 0;
|
|
199
|
+
let intermediate = 0;
|
|
200
|
+
for (const r of reader.getRows()) {
|
|
201
|
+
const src = String(r[0] ?? "").trim();
|
|
202
|
+
total++;
|
|
203
|
+
if (src === "feature-spec" || src === "feature-tree")
|
|
204
|
+
resolvable++;
|
|
205
|
+
else if (src === "capability-spec" || src === "feature-grounding")
|
|
206
|
+
intermediate++;
|
|
207
|
+
}
|
|
208
|
+
if (total === 0 || intermediate === 0)
|
|
209
|
+
return {};
|
|
210
|
+
return {
|
|
211
|
+
finding: {
|
|
212
|
+
intermediate,
|
|
213
|
+
total,
|
|
214
|
+
pctResolvable: pythonRound((100 * resolvable) / total),
|
|
215
|
+
note: intermediate >= resolvable ? "predominantly un-promoted" : "partially promoted",
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
// ─── 0177 F2: near-duplicate feature surfaces (the borderline: engine for
|
|
220
|
+
// access, the token Jaccard host-language) ───────────────────────────
|
|
221
|
+
function tokens(slug) {
|
|
222
|
+
return new Set(slug.split("-").filter((t) => t !== ""));
|
|
223
|
+
}
|
|
224
|
+
function near(a, b) {
|
|
225
|
+
const ta = tokens(a);
|
|
226
|
+
const tb = tokens(b);
|
|
227
|
+
if (ta.size === 0 || tb.size === 0)
|
|
228
|
+
return false;
|
|
229
|
+
const inter = [...ta].filter((t) => tb.has(t)).length;
|
|
230
|
+
if (inter === ta.size || inter === tb.size)
|
|
231
|
+
return true; // subset either way
|
|
232
|
+
const union = ta.size + tb.size - inter;
|
|
233
|
+
return union > 0 && inter / union >= 0.5;
|
|
234
|
+
}
|
|
235
|
+
// @rule gate-feature-surface-duplication
|
|
236
|
+
export async function runFeatureSurfaceDuplicationCheck(session) {
|
|
237
|
+
const reader = await session.conn.runAndReadAll(`SELECT COALESCE("capability_id", ''), COALESCE("name", ''), COALESCE("source", '') FROM "features";`);
|
|
238
|
+
const rollup = new Map();
|
|
239
|
+
const detailed = new Map();
|
|
240
|
+
for (const r of reader.getRows()) {
|
|
241
|
+
const cap = String(r[0] ?? "").trim();
|
|
242
|
+
const slug = String(r[1] ?? "").trim();
|
|
243
|
+
const src = String(r[2] ?? "").trim();
|
|
244
|
+
if (cap === "" || slug === "")
|
|
245
|
+
continue;
|
|
246
|
+
if (src === "capability-spec") {
|
|
247
|
+
rollup.set(cap, [...(rollup.get(cap) ?? []), slug]);
|
|
248
|
+
}
|
|
249
|
+
else if (src === "feature-grounding") {
|
|
250
|
+
detailed.set(cap, [...(detailed.get(cap) ?? []), slug]);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
let pairs = 0;
|
|
254
|
+
const caps = new Set();
|
|
255
|
+
for (const [cap, rollupSlugs] of rollup) {
|
|
256
|
+
const detailedSlugs = detailed.get(cap);
|
|
257
|
+
if (!detailedSlugs)
|
|
258
|
+
continue;
|
|
259
|
+
for (const r of rollupSlugs) {
|
|
260
|
+
for (const d of detailedSlugs) {
|
|
261
|
+
if (r !== d && near(r, d)) {
|
|
262
|
+
pairs++;
|
|
263
|
+
caps.add(cap);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return pairs > 0 ? { finding: { pairs, capabilities: caps.size } } : {};
|
|
269
|
+
}
|
|
270
|
+
// @rule gate-relationship-coherence
|
|
271
|
+
export async function runRelationshipCoherenceCheck(session) {
|
|
272
|
+
const ownedUntraced = [];
|
|
273
|
+
let reader = await session.conn.runAndReadAll(`SELECT f."id", f."capability_id" FROM "features" f
|
|
274
|
+
WHERE COALESCE(f."capability_id", '') <> ''
|
|
275
|
+
AND NOT EXISTS (SELECT 1 FROM "capability_feature" cf
|
|
276
|
+
WHERE cf."capability_id" = f."capability_id"
|
|
277
|
+
AND cf."feature_id" = f."id")
|
|
278
|
+
ORDER BY f."id";`);
|
|
279
|
+
for (const r of reader.getRows()) {
|
|
280
|
+
ownedUntraced.push({ featureId: String(r[0]), capabilityId: String(r[1]) });
|
|
281
|
+
}
|
|
282
|
+
const publisherDrift = [];
|
|
283
|
+
reader = await session.conn.runAndReadAll(`SELECT se."service_id", se."event_id" FROM "service_event" se
|
|
284
|
+
WHERE se."role" = 'publishes'
|
|
285
|
+
AND NOT EXISTS (SELECT 1 FROM "events" e
|
|
286
|
+
WHERE e."id" = se."event_id"
|
|
287
|
+
AND COALESCE(e."publisher_service_id", '') = se."service_id")
|
|
288
|
+
ORDER BY 1, 2;`);
|
|
289
|
+
for (const r of reader.getRows()) {
|
|
290
|
+
publisherDrift.push({
|
|
291
|
+
serviceId: String(r[0]),
|
|
292
|
+
eventId: String(r[1]),
|
|
293
|
+
missing: "publisher-fk",
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
reader = await session.conn.runAndReadAll(`SELECT e."publisher_service_id", e."id" FROM "events" e
|
|
297
|
+
WHERE COALESCE(e."publisher_service_id", '') <> ''
|
|
298
|
+
AND NOT EXISTS (SELECT 1 FROM "service_event" se
|
|
299
|
+
WHERE se."role" = 'publishes'
|
|
300
|
+
AND se."event_id" = e."id"
|
|
301
|
+
AND se."service_id" = e."publisher_service_id")
|
|
302
|
+
ORDER BY 1, 2;`);
|
|
303
|
+
for (const r of reader.getRows()) {
|
|
304
|
+
publisherDrift.push({
|
|
305
|
+
serviceId: String(r[0]),
|
|
306
|
+
eventId: String(r[1]),
|
|
307
|
+
missing: "publishes-edge",
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
const supersessionDrift = [];
|
|
311
|
+
reader = await session.conn.runAndReadAll(`SELECT s."superseding_adr_id", s."superseded_adr_id" FROM "adr_supersedes" s
|
|
312
|
+
WHERE NOT EXISTS (SELECT 1 FROM "adrs" a
|
|
313
|
+
WHERE a."id" = s."superseded_adr_id"
|
|
314
|
+
AND COALESCE(a."superseded_by_id", '') = s."superseding_adr_id")
|
|
315
|
+
ORDER BY 1, 2;`);
|
|
316
|
+
for (const r of reader.getRows()) {
|
|
317
|
+
supersessionDrift.push({
|
|
318
|
+
supersedingId: String(r[0]),
|
|
319
|
+
supersededId: String(r[1]),
|
|
320
|
+
missing: "fk-backfill",
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
reader = await session.conn.runAndReadAll(`SELECT a."superseded_by_id", a."id" FROM "adrs" a
|
|
324
|
+
WHERE COALESCE(a."superseded_by_id", '') <> ''
|
|
325
|
+
AND NOT EXISTS (SELECT 1 FROM "adr_supersedes" s
|
|
326
|
+
WHERE s."superseded_adr_id" = a."id"
|
|
327
|
+
AND s."superseding_adr_id" = a."superseded_by_id")
|
|
328
|
+
ORDER BY 1, 2;`);
|
|
329
|
+
for (const r of reader.getRows()) {
|
|
330
|
+
supersessionDrift.push({
|
|
331
|
+
supersedingId: String(r[0]),
|
|
332
|
+
supersededId: String(r[1]),
|
|
333
|
+
missing: "edge-row",
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
return { ownedUntraced, publisherDrift, supersessionDrift };
|
|
337
|
+
}
|
|
338
|
+
//# sourceMappingURL=gate-checks.js.map
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Graph projection — `sdd-cli catalogue graph` (M-ADR 0205 §4.6, Phase C0b).
|
|
3
|
+
*
|
|
4
|
+
* Derives a typed node/edge list (JSON Lines) from built catalogues. The
|
|
5
|
+
* projection is DERIVED, never authored, never a second source of truth:
|
|
6
|
+
* regenerated on every run, digest-stamped, not committed. The derivation
|
|
7
|
+
* rule is the C0a rule table (catalogue-engine-evolution plan), keyed on
|
|
8
|
+
* the rubric-coverage-map's `kind` × the schema's FK arity:
|
|
9
|
+
*
|
|
10
|
+
* entity (less `applications`) → nodes, id = "<catalogue>/<pk>"
|
|
11
|
+
* entity FK columns → edges typed "<catalogue>.<column>"
|
|
12
|
+
* edge, 2 FKs → edges, declaration order = source→target;
|
|
13
|
+
* a row with an empty nullable endpoint
|
|
14
|
+
* degrades to a property record, never an edge
|
|
15
|
+
* edge, 3 FKs → edges with declared endpoint pairs
|
|
16
|
+
* (THREE_FK_ENDPOINTS below)
|
|
17
|
+
* edge, 1 FK → property records on the FK owner
|
|
18
|
+
* `applications` → excluded (C0a ruling 2026-08-06: filtered
|
|
19
|
+
* view of products — its rows ARE product
|
|
20
|
+
* rows; emitting it creates edge-less
|
|
21
|
+
* shadow nodes beside the products nodes)
|
|
22
|
+
* finding / enum kinds → excluded (diagnostics / vocabularies)
|
|
23
|
+
*
|
|
24
|
+
* Emission reads the PARSED CSV ROWS, deliberately not an engine readback:
|
|
25
|
+
* the engine's load TRY_CASTs typed columns and silently nulls unparseable
|
|
26
|
+
* cells (a real, measured case: prose in a date-typed column). A projection
|
|
27
|
+
* that inherits cast smoothing is no longer a faithful derivation of the
|
|
28
|
+
* CSV canon. C1's impact queries run on the engine; this emission does not.
|
|
29
|
+
*
|
|
30
|
+
* Every edge endpoint and property owner MUST resolve to a node record in
|
|
31
|
+
* the same export — a dangling reference here is a defect in the projection,
|
|
32
|
+
* not a finding about the estate (the FK layer already reports those). A
|
|
33
|
+
* candidate whose endpoint does not resolve is OMITTED and COUNTED, loudly,
|
|
34
|
+
* in the header and summary: `omitted_external` for external /
|
|
35
|
+
* external-for-profiles FKs (expected at member tier, resolving at
|
|
36
|
+
* aggregate), `omitted_unresolved` for the rest (mirrors an FK-layer
|
|
37
|
+
* finding). Stub nodes are never emitted — inventing nodes is the failure
|
|
38
|
+
* mode the C0a rule exists to prevent.
|
|
39
|
+
*/
|
|
40
|
+
import { createHash } from "node:crypto";
|
|
41
|
+
/** Node-kind catalogues excluded from projection by C0a ruling. */
|
|
42
|
+
const EXCLUDED_NODE_CATALOGUES = new Set(["applications"]);
|
|
43
|
+
/**
|
|
44
|
+
* Declared endpoint pairs for the three 3-FK edge catalogues (C0a).
|
|
45
|
+
* `product_platform_dependencies` is SERVICE-FIRST — the aggregator already
|
|
46
|
+
* derives product→capability (cross_sdd_platform_capability) by joining
|
|
47
|
+
* target_service_id, so a capability-first rule would emit the same
|
|
48
|
+
* relationship twice; its resolution is per-row in projectRow below.
|
|
49
|
+
*/
|
|
50
|
+
const THREE_FK_ENDPOINTS = {
|
|
51
|
+
system_external_dependency: ["c4_system_id", "external_system_id"],
|
|
52
|
+
cross_sdd_platform_capability: [
|
|
53
|
+
"consuming_product_id",
|
|
54
|
+
"platform_capability_id",
|
|
55
|
+
],
|
|
56
|
+
product_platform_dependencies: null, // per-row: target_service_id, else target_capability_id
|
|
57
|
+
// schema 3.9.0 (M-ADR 0211 §4.1): the third FK, contract_id, is a PROPERTY of the
|
|
58
|
+
// capability→service dependency — the contract it is against — not an endpoint. The
|
|
59
|
+
// edge stays capability→service; the contract rides as a property.
|
|
60
|
+
capability_service: ["capability_id", "service_id"],
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* Kinds must cover every schema catalogue — a catalogue without a kind
|
|
64
|
+
* cannot be classified and the projection must not guess (the map is
|
|
65
|
+
* guarded method-side by the entity-scoring-coverage correspondence
|
|
66
|
+
* instance; this is the consumer-side half of that guarantee).
|
|
67
|
+
*/
|
|
68
|
+
export function assertKindCoverage(schema, kinds) {
|
|
69
|
+
const missing = schema.catalogues
|
|
70
|
+
.map((c) => c.name)
|
|
71
|
+
.filter((n) => !kinds.has(n));
|
|
72
|
+
if (missing.length > 0) {
|
|
73
|
+
throw new Error(`rubric-coverage-map has no kind for: ${missing.join(", ")} — ` +
|
|
74
|
+
`sync the vendored map (it must cover every schema catalogue).`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
export function projectGraph(schema, kinds, rowsByCatalogue, tier,
|
|
78
|
+
/**
|
|
79
|
+
* When provided (M-ADR 0206 §4.6), every edge record is stamped with its
|
|
80
|
+
* declared `class` and RESOLVED `impact_flow` (role-conditional flows
|
|
81
|
+
* resolve per row), so consumers need no map lookup. Additive fields; the
|
|
82
|
+
* export regenerates per run, so the format change costs one restated
|
|
83
|
+
* determinism proof.
|
|
84
|
+
*/
|
|
85
|
+
semantics) {
|
|
86
|
+
assertKindCoverage(schema, kinds);
|
|
87
|
+
const stampFor = (type, props) => {
|
|
88
|
+
if (semantics === undefined)
|
|
89
|
+
return {};
|
|
90
|
+
const decl = type.includes(".")
|
|
91
|
+
? semantics.entityFks.get(type)
|
|
92
|
+
: semantics.edges.get(type);
|
|
93
|
+
if (decl === undefined) {
|
|
94
|
+
throw new Error(`graph: relationship ${type} has no declaration in the ` +
|
|
95
|
+
`rubric-coverage-map (M-ADR 0206) — sync the vendored map.`);
|
|
96
|
+
}
|
|
97
|
+
const flow = decl.impactFlow ??
|
|
98
|
+
decl.impactFlowBy.values[(props[decl.impactFlowBy.column] ?? "").trim()];
|
|
99
|
+
if (flow === undefined) {
|
|
100
|
+
throw new Error(`graph: ${type} resolves flow by column '${decl.impactFlowBy.column}' ` +
|
|
101
|
+
`but the row value is not in the declared map.`);
|
|
102
|
+
}
|
|
103
|
+
return { class: decl.class, impact_flow: flow };
|
|
104
|
+
};
|
|
105
|
+
const nodeCats = schema.catalogues.filter((c) => kinds.get(c.name) === "entity" && !EXCLUDED_NODE_CATALOGUES.has(c.name));
|
|
106
|
+
// `annotation` (M-ADR 0133 §4.2b; kinds corrected at schema 3.9.0): a single-FK assertion
|
|
107
|
+
// ABOUT one entity. It takes the same path as a 1-FK edge — a property record on the FK
|
|
108
|
+
// owner — so it joins the edge set here rather than vanishing from the projection.
|
|
109
|
+
const edgeCats = schema.catalogues.filter((c) => kinds.get(c.name) === "edge" || kinds.get(c.name) === "annotation");
|
|
110
|
+
// Pass 1 — the node set. Endpoint resolution below is against THIS
|
|
111
|
+
// export's nodes, which is exactly the per-export invariant.
|
|
112
|
+
const nodeIds = new Set();
|
|
113
|
+
const nodeLines = [];
|
|
114
|
+
for (const cat of nodeCats) {
|
|
115
|
+
for (const row of rowsByCatalogue.get(cat.name) ?? []) {
|
|
116
|
+
const key = cell(row, cat.primaryKey);
|
|
117
|
+
const id = `${cat.name}/${key}`;
|
|
118
|
+
nodeIds.add(id);
|
|
119
|
+
nodeLines.push(jsonLine({
|
|
120
|
+
t: "node",
|
|
121
|
+
id,
|
|
122
|
+
catalogue: cat.name,
|
|
123
|
+
key,
|
|
124
|
+
properties: propsOf(cat, row, new Set([cat.primaryKey])),
|
|
125
|
+
}));
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const resolveTarget = (fk, value) => {
|
|
129
|
+
const [refCat, refCol] = fk.references.split(".");
|
|
130
|
+
// The rule resolves FK values against node PKs; a reference to a
|
|
131
|
+
// non-PK column would resolve against the wrong namespace. All 127
|
|
132
|
+
// parsed FKs reference `.id` today — guard it rather than assume it.
|
|
133
|
+
const refDef = schema.catalogues.find((c) => c.name === refCat);
|
|
134
|
+
if (refDef && refDef.primaryKey !== refCol) {
|
|
135
|
+
throw new Error(`FK ${fk.column} references non-PK column ${fk.references} — ` +
|
|
136
|
+
`the projection cannot resolve non-PK references.`);
|
|
137
|
+
}
|
|
138
|
+
const target = `${refCat}/${value}`;
|
|
139
|
+
return nodeIds.has(target) ? target : null;
|
|
140
|
+
};
|
|
141
|
+
const isExternal = (fk) => fk.external || (fk.externalForProfiles ?? []).length > 0;
|
|
142
|
+
const edgeLines = [];
|
|
143
|
+
const propertyLines = [];
|
|
144
|
+
const omissions = { external: 0, unresolved: 0 };
|
|
145
|
+
const emitEdge = (cat, row, type, sourceFk, targetFk) => {
|
|
146
|
+
const sv = cell(row, sourceFk.column);
|
|
147
|
+
const tv = cell(row, targetFk.column);
|
|
148
|
+
const source = resolveTarget(sourceFk, sv);
|
|
149
|
+
const target = resolveTarget(targetFk, tv);
|
|
150
|
+
if (source === null || target === null) {
|
|
151
|
+
const failedFk = source === null ? sourceFk : targetFk;
|
|
152
|
+
if (isExternal(failedFk))
|
|
153
|
+
omissions.external += 1;
|
|
154
|
+
else
|
|
155
|
+
omissions.unresolved += 1;
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const props = propsOf(cat, row, new Set([sourceFk.column, targetFk.column]));
|
|
159
|
+
edgeLines.push(jsonLine({
|
|
160
|
+
t: "edge",
|
|
161
|
+
type,
|
|
162
|
+
...stampFor(type, props),
|
|
163
|
+
source,
|
|
164
|
+
target,
|
|
165
|
+
properties: props,
|
|
166
|
+
}));
|
|
167
|
+
};
|
|
168
|
+
const emitProperty = (cat, row, ownerFk) => {
|
|
169
|
+
const ov = cell(row, ownerFk.column);
|
|
170
|
+
const owner = ov === "" ? null : resolveTarget(ownerFk, ov);
|
|
171
|
+
if (owner === null) {
|
|
172
|
+
if (ov !== "" && isExternal(ownerFk))
|
|
173
|
+
omissions.external += 1;
|
|
174
|
+
else
|
|
175
|
+
omissions.unresolved += 1;
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
propertyLines.push(jsonLine({
|
|
179
|
+
t: "property",
|
|
180
|
+
owner,
|
|
181
|
+
catalogue: cat.name,
|
|
182
|
+
via: ownerFk.column,
|
|
183
|
+
properties: propsOf(cat, row, new Set([ownerFk.column])),
|
|
184
|
+
}));
|
|
185
|
+
};
|
|
186
|
+
// Pass 2a — edges from edge catalogues, schema declaration order.
|
|
187
|
+
for (const cat of edgeCats) {
|
|
188
|
+
const fks = cat.foreignKeys;
|
|
189
|
+
for (const row of rowsByCatalogue.get(cat.name) ?? []) {
|
|
190
|
+
if (fks.length === 1) {
|
|
191
|
+
// Attribute table the metamodel calls an edge: the other "end" is
|
|
192
|
+
// an enum/key/name — a property record, never an edge.
|
|
193
|
+
emitProperty(cat, row, fks[0]);
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
let sourceFk;
|
|
197
|
+
let targetFk;
|
|
198
|
+
if (fks.length === 2) {
|
|
199
|
+
[sourceFk, targetFk] = fks;
|
|
200
|
+
}
|
|
201
|
+
else if (cat.name === "product_platform_dependencies") {
|
|
202
|
+
sourceFk = fks.find((f) => f.column === "product_id");
|
|
203
|
+
const svc = cell(row, "target_service_id");
|
|
204
|
+
const cap = cell(row, "target_capability_id");
|
|
205
|
+
const targetCol = svc !== ""
|
|
206
|
+
? "target_service_id"
|
|
207
|
+
: cap !== ""
|
|
208
|
+
? "target_capability_id"
|
|
209
|
+
: null;
|
|
210
|
+
if (targetCol === null) {
|
|
211
|
+
// Neither target declared: property-only row on the product.
|
|
212
|
+
emitProperty(cat, row, sourceFk);
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
targetFk = fks.find((f) => f.column === targetCol);
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
const pair = THREE_FK_ENDPOINTS[cat.name];
|
|
219
|
+
if (pair === undefined || pair === null) {
|
|
220
|
+
throw new Error(`edge catalogue ${cat.name} has ${fks.length} FKs and no declared ` +
|
|
221
|
+
`endpoint pair — extend THREE_FK_ENDPOINTS (a C0a-level decision, ` +
|
|
222
|
+
`not a default).`);
|
|
223
|
+
}
|
|
224
|
+
sourceFk = fks.find((f) => f.column === pair[0]);
|
|
225
|
+
targetFk = fks.find((f) => f.column === pair[1]);
|
|
226
|
+
}
|
|
227
|
+
if (sourceFk === undefined || targetFk === undefined) {
|
|
228
|
+
throw new Error(`edge catalogue ${cat.name}: declared endpoint column missing from ` +
|
|
229
|
+
`schema foreign_keys — schema and rule table disagree.`);
|
|
230
|
+
}
|
|
231
|
+
const sv = cell(row, sourceFk.column);
|
|
232
|
+
const tv = cell(row, targetFk.column);
|
|
233
|
+
if (sv === "" || tv === "") {
|
|
234
|
+
// Empty nullable endpoint: degrade to a property record on the
|
|
235
|
+
// present endpoint (screen_action is the live case — measured
|
|
236
|
+
// 2026-08-06: all 85+1,652 rows on both estates have no event_id).
|
|
237
|
+
const present = sv !== "" ? sourceFk : tv !== "" ? targetFk : null;
|
|
238
|
+
if (present === null) {
|
|
239
|
+
omissions.unresolved += 1; // no endpoint at all — counted, never guessed
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
emitProperty(cat, row, present);
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
emitEdge(cat, row, cat.name, sourceFk, targetFk);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
// Pass 2b — edges carried on node rows (41 FK columns on 22 catalogues;
|
|
249
|
+
// 34–40% of all edges on the measured estates — the third of the graph
|
|
250
|
+
// the naive "edge catalogues = edges" rule drops).
|
|
251
|
+
for (const cat of nodeCats) {
|
|
252
|
+
for (const fk of cat.foreignKeys) {
|
|
253
|
+
for (const row of rowsByCatalogue.get(cat.name) ?? []) {
|
|
254
|
+
const value = cell(row, fk.column);
|
|
255
|
+
if (value === "")
|
|
256
|
+
continue;
|
|
257
|
+
const source = `${cat.name}/${cell(row, cat.primaryKey)}`;
|
|
258
|
+
const target = resolveTarget(fk, value);
|
|
259
|
+
if (target === null) {
|
|
260
|
+
if (isExternal(fk))
|
|
261
|
+
omissions.external += 1;
|
|
262
|
+
else
|
|
263
|
+
omissions.unresolved += 1;
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
edgeLines.push(jsonLine({
|
|
267
|
+
t: "edge",
|
|
268
|
+
type: `${cat.name}.${fk.column}`,
|
|
269
|
+
...stampFor(`${cat.name}.${fk.column}`, {}),
|
|
270
|
+
source,
|
|
271
|
+
target,
|
|
272
|
+
properties: {},
|
|
273
|
+
}));
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
const body = [...nodeLines, ...edgeLines, ...propertyLines];
|
|
278
|
+
const bodyText = body.map((l) => `${l}\n`).join("");
|
|
279
|
+
const digest = createHash("sha256").update(bodyText, "utf8").digest("hex");
|
|
280
|
+
const counts = {
|
|
281
|
+
nodes: nodeLines.length,
|
|
282
|
+
edges: edgeLines.length,
|
|
283
|
+
properties: propertyLines.length,
|
|
284
|
+
omittedExternal: omissions.external,
|
|
285
|
+
omittedUnresolved: omissions.unresolved,
|
|
286
|
+
};
|
|
287
|
+
const header = jsonLine({
|
|
288
|
+
t: "header",
|
|
289
|
+
format: "sdd-graph/1",
|
|
290
|
+
schema_version: schema.schemaVersion,
|
|
291
|
+
tier,
|
|
292
|
+
nodes: counts.nodes,
|
|
293
|
+
edges: counts.edges,
|
|
294
|
+
properties: counts.properties,
|
|
295
|
+
omitted_external: counts.omittedExternal,
|
|
296
|
+
omitted_unresolved: counts.omittedUnresolved,
|
|
297
|
+
digest: `sha256:${digest}`,
|
|
298
|
+
});
|
|
299
|
+
return { text: `${header}\n${bodyText}`, counts, digest };
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Row columns as a properties object: schema column order (deterministic),
|
|
303
|
+
* empty string omitted (the schema's own convention — empty means absent),
|
|
304
|
+
* excluded columns (PK / endpoint / owner) carried structurally instead.
|
|
305
|
+
* Values stay the strings the CSV canon holds — no cast, no smoothing.
|
|
306
|
+
*/
|
|
307
|
+
function propsOf(cat, row, exclude) {
|
|
308
|
+
const out = {};
|
|
309
|
+
for (const col of cat.columns) {
|
|
310
|
+
if (exclude.has(col.name))
|
|
311
|
+
continue;
|
|
312
|
+
const v = row[col.name];
|
|
313
|
+
const s = v === null || v === undefined ? "" : String(v);
|
|
314
|
+
if (s === "")
|
|
315
|
+
continue;
|
|
316
|
+
out[col.name] = s;
|
|
317
|
+
}
|
|
318
|
+
return out;
|
|
319
|
+
}
|
|
320
|
+
/** JSON.stringify with stable insertion-order keys — the whole line contract. */
|
|
321
|
+
function jsonLine(obj) {
|
|
322
|
+
return JSON.stringify(obj);
|
|
323
|
+
}
|
|
324
|
+
/** One cell as the trimmed string the CSV canon holds ('' = absent). */
|
|
325
|
+
function cell(row, column) {
|
|
326
|
+
const v = row[column];
|
|
327
|
+
return v === null || v === undefined ? "" : String(v).trim();
|
|
328
|
+
}
|
|
329
|
+
//# sourceMappingURL=graph-projection.js.map
|