From bc5e67606f2230d50cf9fcc8909b177952b62425 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 13 Sep 2026 16:08:53 -0400 Subject: [PATCH] test(rpc): add a compile-time params catalog parity gate (#20281) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add compile-time RPC params catalog parity gate Check each registered handler against its catalog params type in both directions, with explicit exceptions for the three uncatalogued schemas. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(rpc): keep the params generator off its own output The parity gate imports the generated catalog for types, and it lives under RPC_DIR, which indexableModules() scans for shared imports. That re-added OUTPUT_PATH after line 46 removed it, so the generator bundled and require()d the committed catalog. A catalog referencing a renamed or deleted shared export then crashed regeneration — in exactly the state that requires regenerating. Reproduced before and after: with a dangling reference injected into the catalog, `generate:rpc-params-catalog` threw; it now rewrites the file. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- .../scripts/generate-rpc-params-catalog.mjs | 9 +++- .../runtime/rpc/rpc-params-type-parity.ts | 42 +++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 src/main/runtime/rpc/rpc-params-type-parity.ts diff --git a/config/scripts/generate-rpc-params-catalog.mjs b/config/scripts/generate-rpc-params-catalog.mjs index e42ea39beb1..9810f952e77 100644 --- a/config/scripts/generate-rpc-params-catalog.mjs +++ b/config/scripts/generate-rpc-params-catalog.mjs @@ -51,7 +51,14 @@ function indexableModules() { const source = readFileSync(path.join(RPC_DIR, file), 'utf8') for (const [, specifier] of source.matchAll(/from\s+'(\.[^']+)'/g)) { const resolved = `${path.resolve(path.dirname(path.join(RPC_DIR, file)), specifier)}.ts` - if (resolved.startsWith(`${SHARED_DIR}${path.sep}`) && existsSync(resolved)) { + // Never re-add the generator's own output: a module under RPC_DIR may import the + // catalog for a type-only contract, and bundling a stale catalog makes regeneration + // crash in exactly the state that requires regenerating. + if ( + resolved !== OUTPUT_PATH && + resolved.startsWith(`${SHARED_DIR}${path.sep}`) && + existsSync(resolved) + ) { modules.add(resolved) } } diff --git a/src/main/runtime/rpc/rpc-params-type-parity.ts b/src/main/runtime/rpc/rpc-params-type-parity.ts new file mode 100644 index 00000000000..263a7fe38cd --- /dev/null +++ b/src/main/runtime/rpc/rpc-params-type-parity.ts @@ -0,0 +1,42 @@ +import type { + RpcMethodName, + RpcParams +} from '../../../shared/rpc-contract/rpc-params-catalog.generated' +import type { RpcAnyMethodDeclaration } from './core' +import type { ALL_RPC_METHODS } from './methods' + +type RegisteredMethod = (typeof ALL_RPC_METHODS)[number] + +// These schemas reach into src/main and have no shared catalog entry. +type UncataloguedMethod = 'emulator.install' | 'orchestration.send' | 'orchestration.taskUpdate' + +type IsAny = 0 extends 1 & T ? true : false + +type ParamsMatch = + IsAny extends true + ? false + : IsAny extends true + ? false + : [Host] extends [Catalog] + ? [Catalog] extends [Host] + ? true + : false + : false + +// Distribute over declarations so each handler is checked, including streaming handlers. +type MismatchedMethod = + Method extends RpcAnyMethodDeclaration + ? Method['name'] extends RpcMethodName + ? ParamsMatch[0], RpcParams> extends true + ? never + : Method['name'] + : Exclude + : never + +type AssertNever = T + +// Type-only gates belong in the node typecheck; runtime parsing is a separate contract. +export type RpcParamsTypeParity = AssertNever> +export type RpcParamsUncataloguedMethods = AssertNever< + Exclude> +>