test(rpc): add a compile-time params catalog parity gate (#20281)

* 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
This commit is contained in:
Jinwoo Hong
2026-09-13 16:08:53 -04:00
committed by GitHub
parent fdf16fff70
commit bc5e67606f
2 changed files with 50 additions and 1 deletions
@@ -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)
}
}
@@ -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<T> = 0 extends 1 & T ? true : false
type ParamsMatch<Host, Catalog> =
IsAny<Host> extends true
? false
: IsAny<Catalog> 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 extends RpcAnyMethodDeclaration
? Method['name'] extends RpcMethodName
? ParamsMatch<Parameters<Method['handler']>[0], RpcParams<Method['name']>> extends true
? never
: Method['name']
: Exclude<Method['name'], UncataloguedMethod>
: never
type AssertNever<T extends never> = T
// Type-only gates belong in the node typecheck; runtime parsing is a separate contract.
export type RpcParamsTypeParity = AssertNever<MismatchedMethod<RegisteredMethod>>
export type RpcParamsUncataloguedMethods = AssertNever<
Exclude<UncataloguedMethod, Exclude<RegisteredMethod['name'], RpcMethodName>>
>