@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,97 @@
|
|
|
1
|
+
// CSV reader for catalogue files.
|
|
2
|
+
//
|
|
3
|
+
// RFC-4180-aware: handles `"` quoting (including doubled `""` for literal
|
|
4
|
+
// quotes), commas inside quoted cells, and CR/LF/CRLF line breaks. We
|
|
5
|
+
// don't try to be a general CSV library — the canonical writer's output
|
|
6
|
+
// is the input shape we support, plus tolerance for byte-identical
|
|
7
|
+
// catalogues authored by other conforming aggregator implementations.
|
|
8
|
+
//
|
|
9
|
+
// Headers come from the file's first line; column order is preserved
|
|
10
|
+
// for traceability but row access is by column name.
|
|
11
|
+
import { readFile } from "node:fs/promises";
|
|
12
|
+
export async function readCatalogueCsv(path) {
|
|
13
|
+
const text = await readFile(path, "utf-8");
|
|
14
|
+
return parseCatalogueCsv(text);
|
|
15
|
+
}
|
|
16
|
+
export function parseCatalogueCsv(text) {
|
|
17
|
+
const records = parseRecords(text);
|
|
18
|
+
if (records.length === 0)
|
|
19
|
+
return { columns: [], rows: [] };
|
|
20
|
+
const columns = records[0] ?? [];
|
|
21
|
+
const rows = [];
|
|
22
|
+
for (let r = 1; r < records.length; r++) {
|
|
23
|
+
const record = records[r] ?? [];
|
|
24
|
+
if (record.length === 1 && record[0] === "")
|
|
25
|
+
continue; // empty trailing line
|
|
26
|
+
const row = {};
|
|
27
|
+
for (let c = 0; c < columns.length; c++) {
|
|
28
|
+
row[columns[c] ?? ""] = record[c] ?? "";
|
|
29
|
+
}
|
|
30
|
+
rows.push(row);
|
|
31
|
+
}
|
|
32
|
+
return { columns, rows };
|
|
33
|
+
}
|
|
34
|
+
function parseRecords(text) {
|
|
35
|
+
const records = [];
|
|
36
|
+
let current = [];
|
|
37
|
+
let cell = "";
|
|
38
|
+
let i = 0;
|
|
39
|
+
let inQuotes = false;
|
|
40
|
+
while (i < text.length) {
|
|
41
|
+
const ch = text[i] ?? "";
|
|
42
|
+
if (inQuotes) {
|
|
43
|
+
if (ch === '"') {
|
|
44
|
+
if (text[i + 1] === '"') {
|
|
45
|
+
// Escaped double-quote inside a quoted cell.
|
|
46
|
+
cell += '"';
|
|
47
|
+
i += 2;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
inQuotes = false;
|
|
51
|
+
i++;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
cell += ch;
|
|
55
|
+
i++;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (ch === '"') {
|
|
59
|
+
inQuotes = true;
|
|
60
|
+
i++;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (ch === ",") {
|
|
64
|
+
current.push(cell);
|
|
65
|
+
cell = "";
|
|
66
|
+
i++;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (ch === "\r") {
|
|
70
|
+
if (text[i + 1] === "\n")
|
|
71
|
+
i++;
|
|
72
|
+
current.push(cell);
|
|
73
|
+
records.push(current);
|
|
74
|
+
current = [];
|
|
75
|
+
cell = "";
|
|
76
|
+
i++;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (ch === "\n") {
|
|
80
|
+
current.push(cell);
|
|
81
|
+
records.push(current);
|
|
82
|
+
current = [];
|
|
83
|
+
cell = "";
|
|
84
|
+
i++;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
cell += ch;
|
|
88
|
+
i++;
|
|
89
|
+
}
|
|
90
|
+
// Trailing cell + record.
|
|
91
|
+
if (cell !== "" || current.length > 0) {
|
|
92
|
+
current.push(cell);
|
|
93
|
+
records.push(current);
|
|
94
|
+
}
|
|
95
|
+
return records;
|
|
96
|
+
}
|
|
97
|
+
//# sourceMappingURL=csv-reader.js.map
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic CSV writer for catalogues.
|
|
3
|
+
*
|
|
4
|
+
* Emits headers + sorted rows per ADR 0134 builder contract:
|
|
5
|
+
* - column order matches the canonical schema exactly,
|
|
6
|
+
* - rows sorted by primary-key value,
|
|
7
|
+
* - cells with embedded comma/quote/newline are RFC-4180 quoted,
|
|
8
|
+
* - empty trailing newline (POSIX convention).
|
|
9
|
+
*
|
|
10
|
+
* Headers-only emission is supported — pass an empty rows array to write
|
|
11
|
+
* a CSV that downstream consumers can rely on the file existence of.
|
|
12
|
+
*
|
|
13
|
+
* ⚠ BUT A HEADERS-ONLY WRITE NEVER TRUNCATES A CATALOGUE THAT HAS ROWS.
|
|
14
|
+
* `conformance_finding` is written by a DIFFERENT VERB — `sdd-cli conformance
|
|
15
|
+
* --emit-finding` calls writeFindings() into the same `catalogues/` directory —
|
|
16
|
+
* and nothing in the `catalogue build` row-collection path ever sets it. So
|
|
17
|
+
* build emitted a bare header over it, unconditionally, and one verb destroyed
|
|
18
|
+
* another verb's output. Measured across one estate: every SDD that had
|
|
19
|
+
* authored a conformance finding lost it, while the gate registries went on
|
|
20
|
+
* citing those rows as CURRENT evidence for a hard-gated score.
|
|
21
|
+
*
|
|
22
|
+
* ⚠ The first cut of this comment said `c4_system_entity` was a second
|
|
23
|
+
* producerless catalogue. It is not — `build.ts` sets it directly rather than
|
|
24
|
+
* via `builders/`, and the sweep that "found" it only searched `builders/`.
|
|
25
|
+
* An adopter disproved it by counterfactual, installing the defective version
|
|
26
|
+
* and measuring that its rows survived. Stated here because the wrong version
|
|
27
|
+
* shipped in 1.29.0 and a reader deserves the correction beside the claim.
|
|
28
|
+
*
|
|
29
|
+
* The guard is keyed on the CONDITION, not on those two names, so it also
|
|
30
|
+
* catches the case nobody has hit yet: a builder that regresses to zero rows
|
|
31
|
+
* silently deleting a populated catalogue.
|
|
32
|
+
*/
|
|
33
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
34
|
+
import { dirname, join } from "node:path";
|
|
35
|
+
/** Data-row count of an existing catalogue file, or 0 when absent/unreadable/header-only. */
|
|
36
|
+
async function existingDataRows(filePath) {
|
|
37
|
+
let text;
|
|
38
|
+
try {
|
|
39
|
+
text = await readFile(filePath, "utf-8");
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return 0;
|
|
43
|
+
}
|
|
44
|
+
const lines = text.split("\n").filter((l) => l.trim() !== "");
|
|
45
|
+
return lines.length > 1 ? lines.length - 1 : 0;
|
|
46
|
+
}
|
|
47
|
+
export async function writeCatalogue(opts) {
|
|
48
|
+
await mkdir(opts.outDir, { recursive: true });
|
|
49
|
+
const filePath = join(opts.outDir, `${opts.catalogue.name}.csv`);
|
|
50
|
+
// Refuse to replace rows with a bare header. Emitting one is correct when the
|
|
51
|
+
// file is absent or already empty — that is the "consumers can rely on the
|
|
52
|
+
// file existing" case — and is data loss when it is not.
|
|
53
|
+
if (opts.rows.length === 0) {
|
|
54
|
+
const onDisk = await existingDataRows(filePath);
|
|
55
|
+
if (onDisk > 0) {
|
|
56
|
+
return { rowCount: onDisk, path: filePath, preserved: true, preservedRows: onDisk };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const text = renderCatalogueCsv(opts.catalogue, opts.rows);
|
|
60
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
61
|
+
await writeFile(filePath, text, "utf-8");
|
|
62
|
+
return { rowCount: opts.rows.length, path: filePath };
|
|
63
|
+
}
|
|
64
|
+
export function renderCatalogueCsv(catalogue, rows) {
|
|
65
|
+
const columns = catalogue.columns.map((c) => c.name);
|
|
66
|
+
const lines = [];
|
|
67
|
+
lines.push(columns.map(escapeCell).join(","));
|
|
68
|
+
const sorted = [...rows].sort((a, b) => compareCell(a[catalogue.primaryKey], b[catalogue.primaryKey]));
|
|
69
|
+
for (const row of sorted) {
|
|
70
|
+
const cells = columns.map((col) => escapeCell(formatCell(row[col])));
|
|
71
|
+
lines.push(cells.join(","));
|
|
72
|
+
}
|
|
73
|
+
// Trailing newline per POSIX convention.
|
|
74
|
+
return `${lines.join("\n")}\n`;
|
|
75
|
+
}
|
|
76
|
+
function formatCell(value) {
|
|
77
|
+
if (value === null || value === undefined)
|
|
78
|
+
return "";
|
|
79
|
+
if (typeof value === "boolean")
|
|
80
|
+
return value ? "true" : "false";
|
|
81
|
+
return String(value);
|
|
82
|
+
}
|
|
83
|
+
function escapeCell(value) {
|
|
84
|
+
if (value === "")
|
|
85
|
+
return "";
|
|
86
|
+
if (/[",\n\r]/.test(value)) {
|
|
87
|
+
return `"${value.replace(/"/g, '""')}"`;
|
|
88
|
+
}
|
|
89
|
+
return value;
|
|
90
|
+
}
|
|
91
|
+
function compareCell(a, b) {
|
|
92
|
+
const sa = a === undefined || a === null ? "" : String(a);
|
|
93
|
+
const sb = b === undefined || b === null ? "" : String(b);
|
|
94
|
+
return sa < sb ? -1 : sa > sb ? 1 : 0;
|
|
95
|
+
}
|
|
96
|
+
//# sourceMappingURL=csv-writer.js.map
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema→DDL generator — M-ADR 0205 §4.3 (engine-evolution Phase A1).
|
|
3
|
+
*
|
|
4
|
+
* Emits, from the loaded canonical schema:
|
|
5
|
+
* (a) CREATE TABLE DDL for every declared catalogue, typed per the
|
|
6
|
+
* schema's column types; and
|
|
7
|
+
* (b) a deterministic list of constraint-violation queries (fk | pk |
|
|
8
|
+
* enum) whose row semantics mirror fk-validator.ts and
|
|
9
|
+
* enum-validator.ts EXACTLY — the Phase A2 dual-run comparison
|
|
10
|
+
* (old path vs engine path, count AND list) depends on that
|
|
11
|
+
* equivalence, so any semantic change here must change there too:
|
|
12
|
+
* - fk: a required (non-nullable, non-external) FK fails when
|
|
13
|
+
* empty; a non-empty value must resolve against the target
|
|
14
|
+
* catalogue's primary keys unless the FK is effective-external
|
|
15
|
+
* (declared `external`, or external for the build's profile via
|
|
16
|
+
* `externalForProfiles`) — mirroring FkValidationOptions.
|
|
17
|
+
* - pk: duplicate non-empty primary-key values.
|
|
18
|
+
* - enum: a non-empty (trimmed) bound-column value outside the
|
|
19
|
+
* bound enum's declared set. Emptiness is nullability's concern,
|
|
20
|
+
* not the enum's, exactly as in enum-validator.ts.
|
|
21
|
+
*
|
|
22
|
+
* Determinism per M-ADR 0205 §4.2: emission order is the schema's own
|
|
23
|
+
* declaration order; every query carries a total ORDER BY.
|
|
24
|
+
*
|
|
25
|
+
* Digest coupling per §4.3 is inherited, not re-implemented: this module
|
|
26
|
+
* reads the schema through loadCanonicalSchema, and the vendored copy it
|
|
27
|
+
* loads is guarded by canonical-schema-drift.test.ts.
|
|
28
|
+
*
|
|
29
|
+
* There is NO hand-maintained DDL anywhere: an unknown column type or a
|
|
30
|
+
* binding to an unknown enum throws at generation time — loud, never a
|
|
31
|
+
* silent fallback (the known*Ids.has() lesson).
|
|
32
|
+
*/
|
|
33
|
+
import { PLATFORM_STAMP_COLUMN, isPlatformScopedFk } from "./fk-validator.js";
|
|
34
|
+
/** Closed map from schema column types to engine column types. */
|
|
35
|
+
const TYPE_MAP = {
|
|
36
|
+
string: "VARCHAR",
|
|
37
|
+
int: "INTEGER",
|
|
38
|
+
integer: "INTEGER",
|
|
39
|
+
number: "DOUBLE",
|
|
40
|
+
date: "DATE",
|
|
41
|
+
boolean: "BOOLEAN",
|
|
42
|
+
};
|
|
43
|
+
/** Engine type for a schema column type; throws on an unmapped type. */
|
|
44
|
+
export function sqlType(catalogue, column, type) {
|
|
45
|
+
const mapped = TYPE_MAP[type];
|
|
46
|
+
if (!mapped) {
|
|
47
|
+
throw new Error(`ddl-generator: ${catalogue}.${column} declares unmapped column type "${type}" — extend TYPE_MAP deliberately, never default`);
|
|
48
|
+
}
|
|
49
|
+
return mapped;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The one definition of effective-external, shared with the engine
|
|
53
|
+
* validator so the three consumers (fk-validator.ts, the query generator,
|
|
54
|
+
* engine-validator.ts) cannot drift: declared `external`, or external for
|
|
55
|
+
* the build's profile via `externalForProfiles`.
|
|
56
|
+
*/
|
|
57
|
+
export function isEffectiveExternal(fk, options) {
|
|
58
|
+
return (fk.external ||
|
|
59
|
+
(options.profile !== undefined &&
|
|
60
|
+
(fk.externalForProfiles ?? []).includes(options.profile)));
|
|
61
|
+
}
|
|
62
|
+
function quote(identifier) {
|
|
63
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(identifier)) {
|
|
64
|
+
throw new Error(`ddl-generator: identifier "${identifier}" is outside the schema's naming form — refusing to interpolate it into SQL`);
|
|
65
|
+
}
|
|
66
|
+
return `"${identifier}"`;
|
|
67
|
+
}
|
|
68
|
+
function sqlStringLiteral(value) {
|
|
69
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* One CREATE TABLE statement per catalogue, in schema declaration order.
|
|
73
|
+
* Plain typed columns only: enforcement of PK/FK/enum semantics lives in
|
|
74
|
+
* the violation queries, whose findings-shaped output (never a load
|
|
75
|
+
* abort) is what the validators' exit contracts need.
|
|
76
|
+
*/
|
|
77
|
+
export function generateTableDdl(schema) {
|
|
78
|
+
return schema.catalogues.map((cat) => {
|
|
79
|
+
const cols = cat.columns
|
|
80
|
+
.map((c) => ` ${quote(c.name)} ${sqlType(cat.name, c.name, c.type)}`)
|
|
81
|
+
.join(",\n");
|
|
82
|
+
return `CREATE TABLE ${quote(cat.name)} (\n${cols}\n);`;
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* The violation-query set for a schema, mirroring the in-memory
|
|
87
|
+
* validators' semantics under the same options they take.
|
|
88
|
+
*/
|
|
89
|
+
export function generateConstraintQueries(schema, options = {}) {
|
|
90
|
+
const catalogueNames = new Set(schema.catalogues.map((c) => c.name));
|
|
91
|
+
const queries = [];
|
|
92
|
+
const catByName = new Map(schema.catalogues.map((c) => [c.name, c]));
|
|
93
|
+
for (const cat of schema.catalogues) {
|
|
94
|
+
queries.push(pkQuery(cat, options));
|
|
95
|
+
for (const fk of cat.foreignKeys) {
|
|
96
|
+
const [targetCat, targetCol] = fk.references.split(".");
|
|
97
|
+
if (!targetCat || !targetCol) {
|
|
98
|
+
throw new Error(`ddl-generator: malformed FK reference "${fk.references}" on ${cat.name}.${fk.column}`);
|
|
99
|
+
}
|
|
100
|
+
if (!catalogueNames.has(targetCat)) {
|
|
101
|
+
throw new Error(`ddl-generator: FK ${cat.name}.${fk.column} references unknown catalogue "${targetCat}"`);
|
|
102
|
+
}
|
|
103
|
+
if (!cat.columns.some((c) => c.name === fk.column)) {
|
|
104
|
+
throw new Error(`ddl-generator: FK column ${cat.name}.${fk.column} is not a declared column — the schema 3.1.3 vacuous-binding class`);
|
|
105
|
+
}
|
|
106
|
+
const effectiveExternal = isEffectiveExternal(fk, options);
|
|
107
|
+
// Mirrors fk-validator.ts exactly: the required-empty clause is keyed
|
|
108
|
+
// on the DECLARED external flag; the dangling clause is keyed on
|
|
109
|
+
// effective-external (unless the aggregator asks for internal
|
|
110
|
+
// treatment).
|
|
111
|
+
const checkEmpty = !fk.nullable && !fk.external;
|
|
112
|
+
const checkDangling = !effectiveExternal || options.treatExternalAsInternal === true;
|
|
113
|
+
if (!checkEmpty && !checkDangling)
|
|
114
|
+
continue;
|
|
115
|
+
const t = quote(cat.name);
|
|
116
|
+
const col = quote(fk.column);
|
|
117
|
+
const pk = quote(cat.primaryKey);
|
|
118
|
+
const clauses = [];
|
|
119
|
+
if (checkEmpty) {
|
|
120
|
+
clauses.push(`(t.${col} IS NULL OR t.${col} = '')`);
|
|
121
|
+
}
|
|
122
|
+
if (checkDangling) {
|
|
123
|
+
// M-ADR 0208 §4.4 (F-D): a platform-scoped target resolves on
|
|
124
|
+
// (platform_sdd_id, pk) — equal stamps, or either side unstamped.
|
|
125
|
+
// Mirrors fk-validator.ts's `resolved` predicate exactly.
|
|
126
|
+
const stamp = quote(PLATFORM_STAMP_COLUMN);
|
|
127
|
+
const scope = isPlatformScopedFk(cat, catByName.get(targetCat), options)
|
|
128
|
+
? ` AND (COALESCE(t.${stamp}, '') = '' OR COALESCE(r.${stamp}, '') = '' OR r.${stamp} = t.${stamp})`
|
|
129
|
+
: "";
|
|
130
|
+
clauses.push(`(t.${col} IS NOT NULL AND t.${col} <> '' AND NOT EXISTS (` +
|
|
131
|
+
`SELECT 1 FROM ${quote(targetCat)} r WHERE r.${quote(targetCol)} = t.${col}${scope}))`);
|
|
132
|
+
}
|
|
133
|
+
queries.push({
|
|
134
|
+
catalogue: cat.name,
|
|
135
|
+
kind: "fk",
|
|
136
|
+
column: fk.column,
|
|
137
|
+
target: fk.references,
|
|
138
|
+
sql: `SELECT t.${pk} AS row_pk, COALESCE(t.${col}, '') AS value FROM ${t} t ` +
|
|
139
|
+
`WHERE ${clauses.join(" OR ")} ORDER BY row_pk, value;`,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
for (const [column, enumRef] of Object.entries(cat.columnEnums)) {
|
|
143
|
+
const enumName = enumRef.replace(/^enums\./, "");
|
|
144
|
+
const enumDef = schema.enums[enumName];
|
|
145
|
+
if (!enumDef) {
|
|
146
|
+
throw new Error(`ddl-generator: ${cat.name}.${column} binds unknown enum "${enumRef}" — a binding that names no enum must not validate as vacuously true`);
|
|
147
|
+
}
|
|
148
|
+
if (!cat.columns.some((c) => c.name === column)) {
|
|
149
|
+
throw new Error(`ddl-generator: ${cat.name}.${column} binds a column the catalogue does not declare — the schema 3.1.3 vacuous-binding class`);
|
|
150
|
+
}
|
|
151
|
+
const allowed = enumDef.values
|
|
152
|
+
.map((v) => sqlStringLiteral(v.value))
|
|
153
|
+
.join(", ");
|
|
154
|
+
const t = quote(cat.name);
|
|
155
|
+
const col = quote(column);
|
|
156
|
+
const pk = quote(cat.primaryKey);
|
|
157
|
+
queries.push({
|
|
158
|
+
catalogue: cat.name,
|
|
159
|
+
kind: "enum",
|
|
160
|
+
column,
|
|
161
|
+
target: enumName,
|
|
162
|
+
sql:
|
|
163
|
+
// TRIM in the projection too: enum-validator.ts records the
|
|
164
|
+
// TRIMMED value in its violation objects, and A2's count-and-list
|
|
165
|
+
// parity compares those objects field-for-field.
|
|
166
|
+
`SELECT t.${pk} AS row_pk, TRIM(t.${col}) AS value FROM ${t} t ` +
|
|
167
|
+
`WHERE t.${col} IS NOT NULL AND TRIM(t.${col}) <> '' ` +
|
|
168
|
+
`AND TRIM(t.${col}) NOT IN (${allowed}) ORDER BY row_pk, value;`,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return queries;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Dangling-value queries for the FKs the main set SKIPS as
|
|
176
|
+
* effective-external — one query per skipped FK, returning the rows the
|
|
177
|
+
* in-memory path logs as `[finding] external` (unresolved non-empty
|
|
178
|
+
* values; resolution happens at aggregation). Empty when
|
|
179
|
+
* treatExternalAsInternal is set, exactly as the main set then skips
|
|
180
|
+
* nothing. Kept separate from generateConstraintQueries because these
|
|
181
|
+
* rows are FINDINGS, not failures — the exit contracts differ.
|
|
182
|
+
*/
|
|
183
|
+
export function generateExternalFindingQueries(schema, options = {}) {
|
|
184
|
+
if (options.treatExternalAsInternal === true)
|
|
185
|
+
return [];
|
|
186
|
+
const queries = [];
|
|
187
|
+
for (const cat of schema.catalogues) {
|
|
188
|
+
for (const fk of cat.foreignKeys) {
|
|
189
|
+
if (!isEffectiveExternal(fk, options))
|
|
190
|
+
continue;
|
|
191
|
+
const [targetCat, targetCol] = fk.references.split(".");
|
|
192
|
+
if (!targetCat || !targetCol) {
|
|
193
|
+
throw new Error(`ddl-generator: malformed FK reference "${fk.references}" on ${cat.name}.${fk.column}`);
|
|
194
|
+
}
|
|
195
|
+
const t = quote(cat.name);
|
|
196
|
+
const col = quote(fk.column);
|
|
197
|
+
const pk = quote(cat.primaryKey);
|
|
198
|
+
queries.push({
|
|
199
|
+
catalogue: cat.name,
|
|
200
|
+
kind: "fk",
|
|
201
|
+
column: fk.column,
|
|
202
|
+
target: fk.references,
|
|
203
|
+
sql: `SELECT t.${pk} AS row_pk, t.${col} AS value FROM ${t} t ` +
|
|
204
|
+
`WHERE t.${col} IS NOT NULL AND t.${col} <> '' AND NOT EXISTS (` +
|
|
205
|
+
`SELECT 1 FROM ${quote(targetCat)} r WHERE r.${quote(targetCol)} = t.${col}) ` +
|
|
206
|
+
`ORDER BY row_pk, value;`,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return queries;
|
|
211
|
+
}
|
|
212
|
+
function pkQuery(cat, options = {}) {
|
|
213
|
+
const t = quote(cat.name);
|
|
214
|
+
const pk = quote(cat.primaryKey);
|
|
215
|
+
// M-ADR 0208 §4.4 (F-D): for a platform-scoped catalogue the key is
|
|
216
|
+
// (platform_sdd_id, pk). n rows sharing a pk are distinct iff every one
|
|
217
|
+
// carries a stamp and the stamps are all different — an EMPTY stamp is
|
|
218
|
+
// unresolved, never a key, so it collides with everything. Mirrors
|
|
219
|
+
// aggregate.ts findDuplicatePrimaryKeys.
|
|
220
|
+
const scoped = isPlatformScopedFk(cat, cat, options);
|
|
221
|
+
const having = scoped
|
|
222
|
+
? `HAVING COUNT(*) > COUNT(DISTINCT NULLIF(${quote(PLATFORM_STAMP_COLUMN)}, ''))`
|
|
223
|
+
: `HAVING COUNT(*) > 1`;
|
|
224
|
+
return {
|
|
225
|
+
catalogue: cat.name,
|
|
226
|
+
kind: "pk",
|
|
227
|
+
column: cat.primaryKey,
|
|
228
|
+
sql: `SELECT ${pk} AS row_pk, COUNT(*) AS n FROM ${t} ` +
|
|
229
|
+
`WHERE ${pk} IS NOT NULL AND ${pk} <> '' ` +
|
|
230
|
+
`GROUP BY ${pk} ${having} ORDER BY row_pk;`,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
//# sourceMappingURL=ddl-generator.js.map
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Engine-path FK/PK/enum validation — M-ADR 0205 §4.4 (Phase A2).
|
|
3
|
+
*
|
|
4
|
+
* Runs the ddl-generator's queries on the embedded engine and returns
|
|
5
|
+
* results in the SAME shapes as fk-validator.ts / enum-validator.ts, so
|
|
6
|
+
* the parity harness can compare count AND list, field for field. The
|
|
7
|
+
* input is the same rowsByCatalogue map the in-memory validators take:
|
|
8
|
+
* A2 compares the two VALIDATION implementations on identical parsed
|
|
9
|
+
* input — CSV-read equivalence is deliberately out of this comparison's
|
|
10
|
+
* scope and belongs to the cutover evaluation.
|
|
11
|
+
*
|
|
12
|
+
* The engine package is a devDependency until cutover (M-ADR 0205 §4.1's
|
|
13
|
+
* no-fallback rule governs how it eventually ships). It is imported
|
|
14
|
+
* dynamically inside the entry function so merely loading this module
|
|
15
|
+
* never requires it: no production path imports the engine today, and an
|
|
16
|
+
* adopter install without the package only fails if something actually
|
|
17
|
+
* calls it — loudly.
|
|
18
|
+
*
|
|
19
|
+
* PK-duplicate detection has NO in-memory counterpart at validate time
|
|
20
|
+
* (builders dedupe at emission instead), so pkDuplicates is engine-only
|
|
21
|
+
* information: reported, never part of the parity claim — you cannot
|
|
22
|
+
* claim agreement with an instrument that does not exist.
|
|
23
|
+
*/
|
|
24
|
+
import { generateConstraintQueries, generateExternalFindingQueries, generateTableDdl, sqlType, } from "./ddl-generator.js";
|
|
25
|
+
const INSERT_CHUNK = 500;
|
|
26
|
+
function sqlStringLiteral(value) {
|
|
27
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* One loaded engine: schema DDL applied, every provided catalogue's rows
|
|
31
|
+
* inserted. Shared by the FK/PK/enum path and the coverage predicates so
|
|
32
|
+
* there is exactly one load semantics to reason about.
|
|
33
|
+
*/
|
|
34
|
+
export async function createEngineSession(schema, rowsByCatalogue) {
|
|
35
|
+
const { DuckDBInstance } = await import("@duckdb/node-api");
|
|
36
|
+
const instance = await DuckDBInstance.create(":memory:");
|
|
37
|
+
const conn = await instance.connect();
|
|
38
|
+
for (const ddl of generateTableDdl(schema)) {
|
|
39
|
+
await conn.run(ddl);
|
|
40
|
+
}
|
|
41
|
+
await loadRows(conn, schema, rowsByCatalogue);
|
|
42
|
+
return { conn, dispose: () => conn.disconnectSync() };
|
|
43
|
+
}
|
|
44
|
+
// @rule engine-fk-validation
|
|
45
|
+
export async function validateWithEngine(schema, rowsByCatalogue, options = {}) {
|
|
46
|
+
const session = await createEngineSession(schema, rowsByCatalogue);
|
|
47
|
+
const conn = session.conn;
|
|
48
|
+
try {
|
|
49
|
+
const typeNonconformances = await findTypeNonconformances(conn, schema, rowsByCatalogue);
|
|
50
|
+
const failures = [];
|
|
51
|
+
const enumViolations = [];
|
|
52
|
+
const pkDuplicates = [];
|
|
53
|
+
for (const q of generateConstraintQueries(schema, options)) {
|
|
54
|
+
const reader = await conn.runAndReadAll(q.sql);
|
|
55
|
+
for (const row of reader.getRows()) {
|
|
56
|
+
if (q.kind === "fk") {
|
|
57
|
+
failures.push({
|
|
58
|
+
catalogue: q.catalogue,
|
|
59
|
+
column: q.column,
|
|
60
|
+
referencingRow: String(row[0] ?? ""),
|
|
61
|
+
value: String(row[1] ?? ""),
|
|
62
|
+
target: q.target,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
else if (q.kind === "enum") {
|
|
66
|
+
const enumDef = schema.enums[q.target];
|
|
67
|
+
enumViolations.push({
|
|
68
|
+
catalogue: q.catalogue,
|
|
69
|
+
column: q.column,
|
|
70
|
+
rowPk: row[0] === null || row[0] === undefined ? "<no-pk>" : String(row[0]),
|
|
71
|
+
value: String(row[1] ?? ""),
|
|
72
|
+
allowed: enumDef.values.map((v) => v.value).join(" | "),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
pkDuplicates.push({
|
|
77
|
+
catalogue: q.catalogue,
|
|
78
|
+
rowPk: String(row[0] ?? ""),
|
|
79
|
+
count: Number(row[1]),
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const externalFindingDetails = [];
|
|
85
|
+
for (const q of generateExternalFindingQueries(schema, options)) {
|
|
86
|
+
const reader = await conn.runAndReadAll(q.sql);
|
|
87
|
+
for (const row of reader.getRows()) {
|
|
88
|
+
externalFindingDetails.push({
|
|
89
|
+
catalogue: q.catalogue,
|
|
90
|
+
column: q.column,
|
|
91
|
+
target: q.target,
|
|
92
|
+
rowPk: row[0] === null || row[0] === undefined ? "?" : String(row[0]),
|
|
93
|
+
value: String(row[1] ?? ""),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
fk: {
|
|
99
|
+
internalPass: failures.length === 0,
|
|
100
|
+
internalFailures: failures,
|
|
101
|
+
externalFindings: externalFindingDetails.length,
|
|
102
|
+
},
|
|
103
|
+
enums: { pass: enumViolations.length === 0, violations: enumViolations },
|
|
104
|
+
externalFindingDetails,
|
|
105
|
+
pkDuplicates,
|
|
106
|
+
typeNonconformances,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
finally {
|
|
110
|
+
session.dispose();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* The A4 cutover surface: runs the engine validation and writes findings
|
|
115
|
+
* into the caller's FindingsLog with BYTE-IDENTICAL text to what
|
|
116
|
+
* fk-validator.ts / enum-validator.ts wrote — adopters' committed
|
|
117
|
+
* findings baselines hold those literal lines, so the text is a
|
|
118
|
+
* compatibility contract, not a style choice. Line ORDER within a
|
|
119
|
+
* category follows the engine's total ORDER BY (M-ADR 0205 §4.2) and may
|
|
120
|
+
* differ from the old file-order rendering; baselines compare by
|
|
121
|
+
* membership, so ordering is free — declared in the change-set anyway.
|
|
122
|
+
*/
|
|
123
|
+
export async function validateWithEngineToLog(schema, rowsByCatalogue, findings, options = {}, extras = {}) {
|
|
124
|
+
const logEnumFindings = extras.logEnumFindings !== false;
|
|
125
|
+
const res = await validateWithEngine(schema, rowsByCatalogue, options);
|
|
126
|
+
for (const d of res.externalFindingDetails) {
|
|
127
|
+
findings.add("external", `${d.catalogue}.${d.column} → ${d.target}=${d.value} ` +
|
|
128
|
+
`(referencing row pk=${d.rowPk}; resolves at aggregation)`);
|
|
129
|
+
}
|
|
130
|
+
if (logEnumFindings) {
|
|
131
|
+
const enumNameByCatalogueColumn = new Map();
|
|
132
|
+
for (const cat of schema.catalogues) {
|
|
133
|
+
for (const [column, enumRef] of Object.entries(cat.columnEnums)) {
|
|
134
|
+
enumNameByCatalogueColumn.set(`${cat.name}.${column}`, enumRef.replace(/^enums\./, ""));
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
for (const v of res.enums.violations) {
|
|
138
|
+
const enumName = enumNameByCatalogueColumn.get(`${v.catalogue}.${v.column}`);
|
|
139
|
+
findings.add("divergence", `${v.catalogue}.${v.column}: row ${v.rowPk} value "${v.value}" is outside the bound enum ${enumName} (${v.allowed})`, "error");
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return { fk: res.fk, enums: res.enums };
|
|
143
|
+
}
|
|
144
|
+
async function loadRows(conn, schema, rowsByCatalogue) {
|
|
145
|
+
for (const cat of schema.catalogues) {
|
|
146
|
+
const rows = rowsByCatalogue.get(cat.name) ?? [];
|
|
147
|
+
if (rows.length === 0)
|
|
148
|
+
continue;
|
|
149
|
+
for (let i = 0; i < rows.length; i += INSERT_CHUNK) {
|
|
150
|
+
const chunk = rows.slice(i, i + INSERT_CHUNK);
|
|
151
|
+
const tuples = chunk
|
|
152
|
+
.map((row) => `(${cat.columns
|
|
153
|
+
.map((c) => literal(row[c.name], cat.name, c.name, c.type))
|
|
154
|
+
.join(", ")})`)
|
|
155
|
+
.join(",\n");
|
|
156
|
+
await conn.run(`INSERT INTO "${cat.name}" VALUES\n${tuples};`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Renders one cell. String columns keep the empty string AS an empty
|
|
162
|
+
* string — the in-memory validators' emptiness predicates see '' from the
|
|
163
|
+
* CSV reader, and the generated queries test both NULL and ''. Non-string
|
|
164
|
+
* columns TRY_CAST with '' becoming NULL: one uncastable cell must not
|
|
165
|
+
* abort a whole run (measured on a real estate: a prose value in a
|
|
166
|
+
* date-typed column), and the miss is not smoothed either —
|
|
167
|
+
* findTypeNonconformances re-derives every such cell with the engine's
|
|
168
|
+
* own cast semantics and reports it.
|
|
169
|
+
*/
|
|
170
|
+
function literal(value, catalogue, column, type) {
|
|
171
|
+
const s = value === null || value === undefined ? "" : String(value);
|
|
172
|
+
if (type === "string")
|
|
173
|
+
return sqlStringLiteral(s);
|
|
174
|
+
if (s === "")
|
|
175
|
+
return "NULL";
|
|
176
|
+
return `TRY_CAST(${sqlStringLiteral(s)} AS ${sqlType(catalogue, column, type)})`;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Exact engine-semantics detection of the cells TRY_CAST nulled: the
|
|
180
|
+
* distinct non-empty values of every typed column are re-checked with the
|
|
181
|
+
* SAME cast the load used — a JS re-implementation of the engine's parsers
|
|
182
|
+
* would be a second opinion pretending to be the first.
|
|
183
|
+
*/
|
|
184
|
+
async function findTypeNonconformances(conn, schema, rowsByCatalogue) {
|
|
185
|
+
const out = [];
|
|
186
|
+
for (const cat of schema.catalogues) {
|
|
187
|
+
const rows = rowsByCatalogue.get(cat.name) ?? [];
|
|
188
|
+
if (rows.length === 0)
|
|
189
|
+
continue;
|
|
190
|
+
for (const col of cat.columns) {
|
|
191
|
+
if (col.type === "string")
|
|
192
|
+
continue;
|
|
193
|
+
const counts = new Map();
|
|
194
|
+
for (const row of rows) {
|
|
195
|
+
const v = row[col.name];
|
|
196
|
+
const s = v === null || v === undefined ? "" : String(v);
|
|
197
|
+
if (s === "")
|
|
198
|
+
continue;
|
|
199
|
+
counts.set(s, (counts.get(s) ?? 0) + 1);
|
|
200
|
+
}
|
|
201
|
+
if (counts.size === 0)
|
|
202
|
+
continue;
|
|
203
|
+
const engineType = sqlType(cat.name, col.name, col.type);
|
|
204
|
+
const values = [...counts.keys()].sort();
|
|
205
|
+
const tuples = values.map((v) => `(${sqlStringLiteral(v)})`).join(", ");
|
|
206
|
+
const reader = await conn.runAndReadAll(`SELECT v FROM (VALUES ${tuples}) t(v) ` +
|
|
207
|
+
`WHERE TRY_CAST(v AS ${engineType}) IS NULL ORDER BY v;`);
|
|
208
|
+
for (const row of reader.getRows()) {
|
|
209
|
+
const value = String(row[0]);
|
|
210
|
+
out.push({
|
|
211
|
+
catalogue: cat.name,
|
|
212
|
+
column: col.name,
|
|
213
|
+
declaredType: col.type,
|
|
214
|
+
value,
|
|
215
|
+
rows: counts.get(value) ?? 0,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return out;
|
|
221
|
+
}
|
|
222
|
+
//# sourceMappingURL=engine-validator.js.map
|