fix(cli): close the extra-field gap in AssertAssignable, add tsc-based enforcement tests

AssertAssignable was one-directional ([Actual] extends [Expected]), so a
block declaring a field the contract doesn't accept passed silently
(TypeScript's excess-property check only applies to fresh object
literals, not conditional-type extends). Add a key-exactness check
(Exclude<keyof Actual, keyof Expected> extends never) alongside the
assignability check. Guard it with 'unknown extends Expected' so
untyped (no defineEndpoint contract) routes still only warn, per the
existing behaviour, instead of being forced to fail on every declared
field.

Add packages/cli/test/api-block-types.test.ts cases that regenerate a
fixture and run the real TypeScript compiler (via bunx tsc) over the
generated output, asserting on its diagnostics rather than on emitted
text: matching fields compile clean; a wrong-typed field, an extra
field (the Finding-A regression guard), and a missing required field
all fail, each pointing at the offending block's __wrn_api_check_*
line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 16:14:13 +05:30
co-authored by Claude Opus 5
parent 785e8a65bd
commit 04be24ddd8
3 changed files with 164 additions and 2 deletions
+7 -1
View File
@@ -57,7 +57,13 @@ declare namespace WRNexusGenerated {
"welcome-email": QueuePayload<(typeof import("../queues/welcome-email.ts"))["default"]>;
}
type ApplicationConfig = (typeof import("../../wrnexus.config.ts"))["default"];
type AssertAssignable<Actual, Expected> = [Actual] extends [Expected] ? true : false;
type AssertAssignable<Actual, Expected> = unknown extends Expected
? true
: [Actual] extends [Expected]
? [Exclude<keyof Actual, keyof Expected>] extends [never]
? true
: false
: false;
type __wrn_expect_true<T extends true> = T;
type ApiInput<P extends ApiRoute, M> = ApiContracts[P][M]["input"];
type ApiOutput<P extends ApiRoute, M> = ApiContracts[P][M]["output"];
+7 -1
View File
@@ -258,7 +258,13 @@ declare namespace WRNexusGenerated {
${generatedContractMap("RealtimeMessages", realtimeContracts)}
${generatedContractMap("QueuePayloads", queueContracts)}
type ApplicationConfig = ${configFile ? `(typeof import(${typeImport(typeDir, configFile)}))["default"]` : "Record<string, never>"};
type AssertAssignable<Actual, Expected> = [Actual] extends [Expected] ? true : false;
type AssertAssignable<Actual, Expected> = unknown extends Expected
? true
: [Actual] extends [Expected]
? [Exclude<keyof Actual, keyof Expected>] extends [never]
? true
: false
: false;
type __wrn_expect_true<T extends true> = T;
type ApiInput<P extends ApiRoute, M> = ApiContracts[P][M]["input"];
type ApiOutput<P extends ApiRoute, M> = ApiContracts[P][M]["output"];
+150
View File
@@ -84,3 +84,153 @@ test("the api-checks file has no runtime code and is a module", () => {
expect(checks).toContain("AUTO-GENERATED");
expect(checks.trim().endsWith("export {};")).toBe(true);
});
// --- Real-compiler enforcement tests ---------------------------------------------
//
// Everything above only asserts on the emitted *text*. That proves nothing about
// whether the assertions actually make `tsc` fail — a build that reverted to the
// original inert `never`-based design, or one where `AssertAssignable` is merely
// one-directional (so it misses an *extra* declared field), would pass every test
// above unchanged. These tests instead run the real TypeScript compiler over the
// generated output and assert on its diagnostics.
//
// The fixture endpoint takes a second (body) parameter so `ApiContract`'s fallback
// branch infers a real input type (`{ name: string; email: string }`) instead of
// `unknown` — with `unknown`, `AssertAssignable`'s untyped-route bypass means nothing
// could ever fail, which would make these tests meaningless.
function typedFixture(block: string): string {
const root = mkdtempSync(join(tmpdir(), "wrnexus-api-types-tsc-"));
roots.push(root);
mkdirSync(join(root, "app/pages"), { recursive: true });
mkdirSync(join(root, "app/api"), { recursive: true });
writeFileSync(
join(root, "app/api/users.ts"),
`export const POST = async (ctx: unknown, body: { name: string; email: string }) => Response.json(body);\n`,
);
writeFileSync(
join(root, "app/pages/search.wrn"),
`page Search {\n client {\n${block}\n }\n\n view { <main>x</main> }\n}\n`,
);
return root;
}
/**
* Compiles the two generated files (and whatever they reference on disk) with the
* real TypeScript compiler and returns its stdout plus whether it reported any
* diagnostics.
*/
function typecheckGenerated(root: string): { ok: boolean; output: string } {
const dts = join(root, "app/types/wrnexus.generated.d.ts");
const checks = join(root, "app/types/wrnexus.generated.api-checks.ts");
const result = Bun.spawnSync(
[
"bunx",
"tsc",
"--noEmit",
"--strict",
"--skipLibCheck",
"--moduleResolution",
"bundler",
"--target",
"ES2022",
"--module",
"ESNext",
dts,
checks,
],
{ cwd: root, stdout: "pipe", stderr: "pipe" },
);
const output = `${result.stdout?.toString() ?? ""}${result.stderr?.toString() ?? ""}`;
return { ok: result.exitCode === 0, output };
}
const MATCHING_BLOCK = ` api searchUsers POST /api/users {
request {
body {
name: string
email: string
}
}
response {
return data
}
}`;
test("tsc: a block whose fields match the contract has no diagnostics", () => {
const root = typedFixture(MATCHING_BLOCK);
generateApplicationTypes(root);
const { ok, output } = typecheckGenerated(root);
expect(output.trim()).toBe("");
expect(ok).toBe(true);
});
test("tsc: a field with the wrong type fails, naming the block's assertion", () => {
const root = typedFixture(` api searchUsers POST /api/users {
request {
body {
name: number
email: string
}
}
response {
return data
}
}`);
generateApplicationTypes(root);
const { ok, output } = typecheckGenerated(root);
expect(ok).toBe(false);
expect(output).toContain("wrnexus.generated.api-checks.ts");
const checks = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8");
const failingLine = Number(output.match(/api-checks\.ts\((\d+),\d+\)/)?.[1]);
expect(checks.split(/\r?\n/)[failingLine - 1]).toContain("__wrn_api_check_searchUsers");
});
test("tsc: an extra field the contract does not accept fails (Finding A regression guard)", () => {
const root = typedFixture(` api searchUsers POST /api/users {
request {
body {
name: string
email: string
extra: string
}
}
response {
return data
}
}`);
generateApplicationTypes(root);
const { ok, output } = typecheckGenerated(root);
expect(ok).toBe(false);
expect(output).toContain("wrnexus.generated.api-checks.ts");
const checks = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8");
const failingLine = Number(output.match(/api-checks\.ts\((\d+),\d+\)/)?.[1]);
expect(checks.split(/\r?\n/)[failingLine - 1]).toContain("__wrn_api_check_searchUsers");
});
test("tsc: a missing required field fails", () => {
const root = typedFixture(` api searchUsers POST /api/users {
request {
body {
name: string
}
}
response {
return data
}
}`);
generateApplicationTypes(root);
const { ok, output } = typecheckGenerated(root);
expect(ok).toBe(false);
expect(output).toContain("wrnexus.generated.api-checks.ts");
const checks = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8");
const failingLine = Number(output.match(/api-checks\.ts\((\d+),\d+\)/)?.[1]);
expect(checks.split(/\r?\n/)[failingLine - 1]).toContain("__wrn_api_check_searchUsers");
});