|
| 1 | +// CODEGEN-COMPILE CONFORMANCE (TypeScript lane) |
| 2 | +// |
| 3 | +// Generate from the SHARED cross-port corpus — fixtures/persistence-conformance/ |
| 4 | +// canonical/meta.fitness.json — and compile every emitted module with the real TS |
| 5 | +// compiler. Zero diagnostics or the lane is red. |
| 6 | +// |
| 7 | +// WHY THIS EXISTS. Four defects shipped in 1.0.4 that every existing gate was blind to, |
| 8 | +// because each one produced output that PARSES and GENERATES cleanly and only fails when |
| 9 | +// somebody builds it: |
| 10 | +// |
| 11 | +// - a view over an int-backed enum imported a codec drizzle does not export (TS); |
| 12 | +// - a renamed projection field selected a column that does not exist (TS); |
| 13 | +// - a DbContext named FK config through `nameof` on a member that is not there (C#); |
| 14 | +// - an extract mapper did not compile for most scalar subtypes (Java). |
| 15 | +// |
| 16 | +// `meta gen` exits 0 in all four cases. The metamodel, render, persistence, api-contract |
| 17 | +// and registry corpora all stay green — they gate BEHAVIOR, and none of them asks whether |
| 18 | +// the emitted code builds. The adopter's build is the first thing that does, which makes |
| 19 | +// the adopter the gate. This closes that. |
| 20 | +// |
| 21 | +// WHY THE FITNESS CORPUS rather than a fixture of its own: it is already the shared |
| 22 | +// cross-port model (16 entities, 2 view-backed projections, 2 value objects, two M:N |
| 23 | +// through-junctions incl. a self-join, a 4-entity TPH hierarchy, jsonb storage, isArray, |
| 24 | +// field.currency, field.decimal, and an AllTypes entity carrying every persistable field |
| 25 | +// subtype incl. @intValueMap). A second kitchen sink would drift from the first, and the |
| 26 | +// coverage this gate has is exactly the coverage the other corpora already maintain. |
| 27 | +// |
| 28 | +// The peer lanes are the same test in each port. If one port drops out, that port keeps |
| 29 | +// precisely the bug class this exists to catch — so a skip here is never "just this lane". |
| 30 | + |
| 31 | +import { describe, test, expect } from "bun:test"; |
| 32 | +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; |
| 33 | +import { join, resolve, dirname } from "node:path"; |
| 34 | +import { fileURLToPath, pathToFileURL } from "node:url"; |
| 35 | +import ts from "typescript"; |
| 36 | +import { loadUris, type MetaRoot, type MetaObject } from "@metaobjectsdev/metadata"; |
| 37 | +import { entityFile } from "../src/generators/entity-file.js"; |
| 38 | +import { namesFile } from "../src/generators/names-file.js"; |
| 39 | +import { queriesFile } from "../src/generators/queries-file.js"; |
| 40 | +import { barrel } from "../src/generators/barrel.js"; |
| 41 | +import { makeRenderContext } from "../src/render-context.js"; |
| 42 | +import { buildPkMap } from "../src/pk-resolver.js"; |
| 43 | +import { buildRelationMap } from "../src/relation-resolver.js"; |
| 44 | +import type { GenContext } from "../src/generator.js"; |
| 45 | + |
| 46 | +// test → codegen-ts → packages → typescript → server → repo root |
| 47 | +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "..", ".."); |
| 48 | +const CORPUS = resolve( |
| 49 | + REPO_ROOT, |
| 50 | + "fixtures", |
| 51 | + "persistence-conformance", |
| 52 | + "canonical", |
| 53 | + "meta.fitness.json", |
| 54 | +); |
| 55 | + |
| 56 | +async function loadCorpus(): Promise<MetaRoot> { |
| 57 | + const result = await loadUris([pathToFileURL(CORPUS).href]); |
| 58 | + if (result.errors.length > 0) { |
| 59 | + throw new Error( |
| 60 | + `${CORPUS} did not load cleanly:\n` + |
| 61 | + result.errors.map((e) => ` ${(e as { code?: string }).code ?? "ERROR"}: ${e.message}`).join("\n"), |
| 62 | + ); |
| 63 | + } |
| 64 | + return result.root; |
| 65 | +} |
| 66 | + |
| 67 | +describe("codegen-compile conformance — the shared fitness corpus", () => { |
| 68 | + for (const dialect of ["postgres", "sqlite"] as const) { |
| 69 | + test(`${dialect}: every generated module compiles with zero diagnostics`, async () => { |
| 70 | + const root = await loadCorpus(); |
| 71 | + const dir = mkdtempSync(join(import.meta.dir, `tmp-codegen-compile-${dialect}-`)); |
| 72 | + try { |
| 73 | + const renderContext = makeRenderContext({ |
| 74 | + dialect, |
| 75 | + loadedRoot: root, |
| 76 | + outDir: dir, |
| 77 | + dbImport: "~/db", |
| 78 | + pkMap: buildPkMap(root), |
| 79 | + relationMap: buildRelationMap(root), |
| 80 | + }); |
| 81 | + // `matches` is composed from each generator's OWN filter, exactly as runner.ts |
| 82 | + // does it (`matches: (e) => generator.filter?.(e) ?? true`). Hardcoding |
| 83 | + // `() => true` here would run every generator over every object and report the |
| 84 | + // generator's own deliberate exclusions — a value object has no table, a TPH |
| 85 | + // subtype no standalone one — as if they were emit bugs. The gate has to run the |
| 86 | + // generators the way the runner runs them or it measures the harness. |
| 87 | + const genCtx = (generator: { filter?: (e: MetaObject) => boolean }): GenContext => ({ |
| 88 | + entities: root.objects(), |
| 89 | + loadedRoot: root, |
| 90 | + matches: (e) => generator.filter?.(e) ?? true, |
| 91 | + projectRoot: dir, |
| 92 | + config: { outDir: dir, extStyle: "none", dbImport: "~/db", dialect } as never, |
| 93 | + renderContext, |
| 94 | + warn: () => {}, |
| 95 | + }); |
| 96 | + |
| 97 | + // The model tier plus its consumers. `allowlists: false` keeps the program over |
| 98 | + // drizzle + zod alone, so a diagnostic is about OUR emit and not a runtime-ts |
| 99 | + // resolution problem; routes are excluded for the same reason C#'s compile test |
| 100 | + // excludes them — the server framework's types would drown the signal. |
| 101 | + const generators = [ |
| 102 | + entityFile({ allowlists: false }), |
| 103 | + namesFile(), |
| 104 | + queriesFile(), |
| 105 | + barrel(), |
| 106 | + ]; |
| 107 | + const files = ( |
| 108 | + await Promise.all(generators.map((g) => g.generate(genCtx(g)))) |
| 109 | + ).flat(); |
| 110 | + expect(files.length).toBeGreaterThan(0); |
| 111 | + for (const f of files) writeFileSync(join(dir, f.path), f.content); |
| 112 | + |
| 113 | + const program = ts.createProgram( |
| 114 | + files.map((f) => join(dir, f.path)), |
| 115 | + { |
| 116 | + strict: true, |
| 117 | + noEmit: true, |
| 118 | + target: ts.ScriptTarget.ES2022, |
| 119 | + module: ts.ModuleKind.ESNext, |
| 120 | + moduleResolution: ts.ModuleResolutionKind.Bundler, |
| 121 | + skipLibCheck: true, |
| 122 | + }, |
| 123 | + ); |
| 124 | + const diagnostics = ts.getPreEmitDiagnostics(program).map((d) => { |
| 125 | + const where = |
| 126 | + d.file && d.start !== undefined |
| 127 | + ? `${d.file.fileName.slice(dir.length + 1)}:${ |
| 128 | + d.file.getLineAndCharacterOfPosition(d.start).line + 1 |
| 129 | + } ` |
| 130 | + : ""; |
| 131 | + return `${where}${ts.flattenDiagnosticMessageText(d.messageText, "\n")}`; |
| 132 | + }); |
| 133 | + expect(diagnostics).toEqual([]); |
| 134 | + } finally { |
| 135 | + rmSync(dir, { recursive: true, force: true }); |
| 136 | + } |
| 137 | + }); |
| 138 | + } |
| 139 | +}); |
0 commit comments