refactor(mobile): fence the raw RPC request port behind an inventoried boundary

The raw sender takes an unchecked method string and returns an envelope whose
result is `unknown`; 153 non-test files still reach it and each re-decides
acceptance and decoding for itself. The type system cannot close that today —
`RpcClient` structurally carries `sendRequest` and ~190 files hold a client — so
move the port's declaration into its own module, name it unvalidated, and hold
the boundary as a ratcheted inventory instead.

`SendRequestOptions` is re-exported from rpc-client.ts so the move touches no
call site, and rpc-operation.ts now asks for the port rather than the whole
client: it is the one module allowed to cross it.

Two ratchets, both AST-based:
- the port inventory fails on an unlisted file, a stale entry, and a listed file
  whose reference count went up, so the list only shrinks;
- the cast fence bans `as`, `any` and `@ts-` suppressions in the operation
  region, which is computed from the imports rather than listed, so step 4's
  operation modules land inside it automatically.

Zero runtime change: no wire change, no call site touched.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-09-11 01:11:48 -04:00
parent 38311ed000
commit 32f4f99b69
6 changed files with 792 additions and 24 deletions
+8 -19
View File
@@ -1,19 +1,11 @@
import type { BrowserScreencastFrame } from './browser-screencast-protocol'
import { DirectRpcClient } from './direct-rpc-client'
import type {
ConnectionLogSink,
ConnectionState,
ForegroundNudgeReason,
RpcResponse
} from './types'
import type { ConnectionLogSink, ConnectionState, ForegroundNudgeReason } from './types'
import type { UnvalidatedRpcRequestPort } from './unvalidated-rpc-request-port'
export type SendRequestOptions = {
timeoutMs?: number
/** Include the connect wait in the caller's timeout budget. */
budgetSpansConnect?: boolean
/** Reject instead of replaying the request after reconnect. */
failWhenDisconnected?: boolean
}
// Re-export shim: the options type moved to the port module with the sender it belongs to,
// and re-exporting is what keeps that move from touching every importer.
export type { SendRequestOptions } from './unvalidated-rpc-request-port'
type SubscribeOptions = {
onBinaryFrame?: (frame: BrowserScreencastFrame) => void
@@ -21,12 +13,9 @@ type SubscribeOptions = {
type StreamingListener = (result: unknown) => void
export type RpcClient = {
sendRequest: (
method: string,
params?: unknown,
options?: SendRequestOptions
) => Promise<RpcResponse>
// Still structurally carries the raw sender, so holding a client is still holding the port —
// which is why the boundary is inventoried rather than merely declared.
export type RpcClient = UnvalidatedRpcRequestPort & {
subscribe: (
method: string,
params: unknown,
@@ -0,0 +1,255 @@
import { readFileSync, readdirSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { extname, join, relative, resolve } from 'node:path'
import ts from 'typescript'
import { describe, expect, it } from 'vitest'
/**
* Bans the escapes that would make the typed boundary decorative.
*
* An operation's whole claim is that a reply arrives as a declared type because a reader
* decoded it. `as`, `any` and a `@ts-` suppression each produce the same declared type without
* the decode, so one of them anywhere in an operation implementation buys back exactly the
* drift the contract removed — and it buys it silently, since the code still compiles and the
* types still read as validated.
*
* The fenced region is computed, not listed: a non-test file is in it if it imports the
* operation API, the operation contract or the result-reader factory, or if it re-exports a
* file that is (transitively). Step 4's operation modules therefore land inside the fence the
* moment they are written, with nothing to remember.
*
* What this does NOT catch, all accepted:
* - A lying reader. `z.unknown()` or a schema looser than the reply decodes anything, and no
* syntax check can tell a permissive schema from a wrong one.
* - Structural laundering: a helper in an unfenced module that returns the wrong type
* honestly, which the operation then consumes without a cast.
* - `!` non-null assertions, and the widening that an untyped intermediate variable gives
* you for free.
* - A screen. Screens are outside the region by design until they hold an operation; the
* raw-port inventory is what governs them.
*/
const mobileRoot = fileURLToPath(new URL('../..', import.meta.url))
const scannedRoots = ['app', 'src'].map((directory) => join(mobileRoot, directory))
const sourceExtensions = new Set(['.js', '.jsx', '.ts', '.tsx'])
const transportRoot = join(mobileRoot, 'src', 'transport')
/** Importing any of these is what makes a file an operation implementation. */
const REGION_SEEDS = new Set(
['rpc-operation', 'rpc-operation-contract', 'rpc-operation-result-reader'].map((name) =>
join(transportRoot, name)
)
)
export type RpcOperationEscape = 'assertion' | 'any' | 'suppression'
type CastFenceException = {
readonly file: string
readonly allows: readonly RpcOperationEscape[]
}
/**
* The modules that own the `unknown` → declared-type transition, so the erasure has to land
* somewhere. Held as data, per escape kind, so an exception cannot quietly widen into the
* others. Every entry is also checked for staleness.
*/
const CAST_FENCE_EXCEPTIONS: readonly CastFenceException[] = [
// The interpreter. Its casts re-apply type parameters that `AnyRpcOperation` erased on the
// way in; none of them invents a shape the reader did not already produce.
{ file: 'src/transport/rpc-operation.ts', allows: ['assertion'] },
// The reader factory. `safeParse` returns the schema's own output type as `unknown`.
{ file: 'src/transport/rpc-operation-result-reader.ts', allows: ['assertion'] },
// Nothing but suppressions: every directive in it is an assertion that tsc still rejects
// the thing above it, which is the compile fence's entire mechanism.
{ file: 'src/transport/rpc-operation-compile-fence.ts', allows: ['suppression'] }
]
// Text, not AST: a suppression is a comment, and comments are not nodes. A directive spelled
// inside a string literal therefore reads as one — which fails closed.
const SUPPRESSION = /@ts-(?:expect-error|ignore|nocheck)\b/
function sourceFiles(directory: string): string[] {
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const path = join(directory, entry.name)
if (entry.isDirectory()) {
return entry.name === 'node_modules' ? [] : sourceFiles(path)
}
return [path]
})
}
function parse(path: string, source: string): ts.SourceFile {
const extension = extname(path)
return ts.createSourceFile(
path,
source,
ts.ScriptTarget.Latest,
true,
extension === '.tsx' || extension === '.jsx' ? ts.ScriptKind.TSX : ts.ScriptKind.TS
)
}
function resolvedSpecifier(path: string, node: ts.Node | undefined): string | null {
if (!node || !ts.isStringLiteral(node) || !node.text.startsWith('.')) {
return null
}
return resolve(path, '..', node.text)
}
/** `as const` narrows a literal; it declares nothing the value was not already. */
function isConstAssertion(node: ts.AsExpression): boolean {
return (
ts.isTypeReferenceNode(node.type) &&
ts.isIdentifier(node.type.typeName) &&
node.type.typeName.text === 'const'
)
}
export function rpcOperationEscapes(path: string, source: string): RpcOperationEscape[] {
const found: RpcOperationEscape[] = []
const visit = (node: ts.Node): void => {
if (
(ts.isAsExpression(node) && !isConstAssertion(node)) ||
ts.isTypeAssertionExpression(node)
) {
found.push('assertion')
}
if (node.kind === ts.SyntaxKind.AnyKeyword) {
found.push('any')
}
ts.forEachChild(node, visit)
}
visit(parse(path, source))
if (SUPPRESSION.test(source)) {
found.push('suppression')
}
return [...new Set(found)].sort()
}
/** Imports and re-exports that make the importer part of the operation region. */
function moduleEdges(path: string, source: string): { imports: string[]; reExports: string[] } {
const imports: string[] = []
const reExports: string[] = []
for (const statement of parse(path, source).statements) {
if (ts.isImportDeclaration(statement)) {
const target = resolvedSpecifier(path, statement.moduleSpecifier)
if (target) {
imports.push(target)
}
continue
}
if (ts.isExportDeclaration(statement) && statement.moduleSpecifier) {
const target = resolvedSpecifier(path, statement.moduleSpecifier)
if (target) {
imports.push(target)
reExports.push(target)
}
}
}
return { imports, reExports }
}
const scanned = scannedRoots
.flatMap(sourceFiles)
.filter((path) => sourceExtensions.has(extname(path)))
.filter((path) => !/\.test\.tsx?$/.test(path))
const sources = new Map(scanned.map((path) => [path, readFileSync(path, 'utf8')] as const))
const edges = new Map([...sources].map(([path, source]) => [path, moduleEdges(path, source)]))
/** Modules are keyed without their extension, the way a relative specifier resolves. */
function moduleKey(path: string): string {
return path.replace(/\.[jt]sx?$/, '')
}
const region = new Set<string>()
for (const [path, { imports }] of edges) {
if (imports.some((target) => REGION_SEEDS.has(target))) {
region.add(path)
}
}
// Fixpoint over re-export edges: a barrel that re-exports an operation module is in the fence
// too, which is where a cast would otherwise sit unwatched between definition and screen.
for (let changed = true; changed;) {
changed = false
const members = new Set([...region].map(moduleKey))
for (const [path, { reExports }] of edges) {
if (!region.has(path) && reExports.some((target) => members.has(target))) {
region.add(path)
changed = true
}
}
}
const relativeRegion = [...region].map((path) =>
relative(mobileRoot, path).split(/[/\\]/).join('/')
)
describe('RPC operation cast fence', () => {
const probe = join(mobileRoot, 'src', 'transport', 'probe.ts')
it('recognizes each escape and leaves honest code alone', () => {
expect(rpcOperationEscapes(probe, 'const v = raw as WorkspaceRows')).toEqual(['assertion'])
expect(rpcOperationEscapes(probe, 'const v = raw as unknown as WorkspaceRows')).toEqual([
'assertion'
])
expect(rpcOperationEscapes(probe, 'const v: any = raw')).toEqual(['any'])
expect(rpcOperationEscapes(probe, 'function f(raw: any) {}')).toEqual(['any'])
expect(rpcOperationEscapes(probe, 'const v = raw as any')).toEqual(['any', 'assertion'])
expect(rpcOperationEscapes(probe, '// @ts-expect-error\nconst v = raw')).toEqual([
'suppression'
])
expect(rpcOperationEscapes(probe, '// @ts-ignore\nconst v = raw')).toEqual(['suppression'])
expect(rpcOperationEscapes(probe, "const v = ['a'] as const")).toEqual([])
expect(rpcOperationEscapes(probe, 'const v = read(raw)')).toEqual([])
expect(rpcOperationEscapes(probe, 'const v = raw satisfies WorkspaceRows')).toEqual([])
expect(rpcOperationEscapes(probe, 'const v = value!')).toEqual([])
})
it('puts every operation module in the fenced region', () => {
for (const file of [
'src/transport/rpc-operation.ts',
'src/transport/rpc-operation-test-families.ts',
'src/transport/rpc-operation-compile-fence.ts',
'src/transport/rpc-operation-result-reader.ts',
'src/transport/rpc-incompatible-reply-error.ts'
]) {
expect(relativeRegion, `${file} must be fenced`).toContain(file)
}
// A screen that only holds a client is governed by the raw-port inventory, not by this.
expect(relativeRegion).not.toContain('src/transport/rpc-client.ts')
})
it('has no operation module casting, widening or suppressing its way to a type', () => {
const allowed = new Map(CAST_FENCE_EXCEPTIONS.map((entry) => [entry.file, entry.allows]))
const offenders = [...region]
.map((path) => {
const file = relative(mobileRoot, path).split(/[/\\]/).join('/')
const escapes = rpcOperationEscapes(path, sources.get(path) ?? '')
const permitted = allowed.get(file) ?? []
return { file, escapes: escapes.filter((escape) => !permitted.includes(escape)) }
})
.filter((entry) => entry.escapes.length > 0)
.map((entry) => `${entry.file}: ${entry.escapes.join(', ')}`)
.sort()
expect(
offenders,
'Decode the reply with a reader instead. An operation that asserts its own result type is not typed.'
).toEqual([])
})
it('has no stale cast-fence exception', () => {
const stale = CAST_FENCE_EXCEPTIONS.flatMap((entry) => {
const path = join(mobileRoot, entry.file)
if (!region.has(path)) {
return [`${entry.file}: no longer in the fenced region`]
}
const escapes = rpcOperationEscapes(path, sources.get(path) ?? '')
return entry.allows
.filter((escape) => !escapes.includes(escape))
.map((escape) => `${entry.file}: no longer uses '${escape}'`)
})
expect(stale, 'Narrow or delete the exception in rpc-operation-cast-fence.test.ts.').toEqual([])
})
})
+7 -5
View File
@@ -1,5 +1,5 @@
import type { RpcClient, SendRequestOptions } from './rpc-client'
import type { RpcMethodName, RpcParams } from './rpc-params-contract'
import type { SendRequestOptions, UnvalidatedRpcRequestPort } from './unvalidated-rpc-request-port'
import type { RpcResponse } from './types'
import {
isMethodNotFoundRefusal,
@@ -76,9 +76,11 @@ export function defineRpcOperation(definition: RpcOperationDefinitionInput): Any
})
}
// Takes the raw port, not RpcClient: this is the one module allowed to cross it, and asking
// for the whole client would hide that dependency behind a type every screen already holds.
/** Sends the operation without interpreting it; transport rejection stays on the promise. */
async function request(
client: RpcClient,
client: UnvalidatedRpcRequestPort,
operation: AnyRpcOperation,
params: unknown,
options?: SendRequestOptions
@@ -208,7 +210,7 @@ export async function runRpcOperation<
Variant extends string,
Value
>(
client: RpcClient,
client: UnvalidatedRpcRequestPort,
operation: RpcOperation<Method, Acceptance, Variant, Value, 'on-settle'>,
params: RpcParams<Method>,
options?: SendRequestOptions
@@ -226,7 +228,7 @@ export async function captureRpcOperationSettlement<
Value,
Barrier extends RpcInterpretationBarrier
>(
client: RpcClient,
client: UnvalidatedRpcRequestPort,
operation: RpcOperation<Method, Acceptance, Variant, Value, Barrier>,
params: RpcParams<Method>,
options?: SendRequestOptions
@@ -251,7 +253,7 @@ export function startRpcOperation<
Variant extends string,
Value
>(
client: RpcClient,
client: UnvalidatedRpcRequestPort,
operation: RpcOperation<Method, Acceptance, Variant, Value, 'after-all-requests'>,
params: RpcParams<Method>,
options?: SendRequestOptions
@@ -0,0 +1,267 @@
import { readFileSync, readdirSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { extname, join, relative, resolve } from 'node:path'
import ts from 'typescript'
import { describe, expect, it } from 'vitest'
import {
UNVALIDATED_RPC_REQUEST_PORT_OWNERS,
UNVALIDATED_RPC_REQUEST_PORT_PENDING,
type UnvalidatedRpcRequestPortEntry
} from './unvalidated-rpc-request-port-inventory'
/**
* Ratchet for the raw RPC request port.
*
* `sendRequest` takes an unchecked method string and returns an envelope whose `result` is
* `unknown`. Every screen that reaches it re-decides acceptance and decoding for itself, which
* is the drift the RpcOperation contract exists to end. The port cannot be made unreachable by
* the type system today: `RpcClient` structurally carries it, and ~190 files hold a client. So
* the boundary is held as an inventory instead, and this test is what makes the inventory bind.
*
* Three failures, all of which mean "edit the list":
* - a file reaches the port and is on neither list,
* - a listed file no longer reaches it (stale entry — how allow-lists rot),
* - a listed file's reference count went up.
*
* What this does NOT catch, all accepted:
* - Reach laundered through a function type. A listed file can hand `client.sendRequest` to an
* unlisted one as a bare `(method: string) => Promise<RpcResponse>` and the receiver never
* names the port. Only two senders are named here; a third wrapper needs adding by hand.
* - Computed access — `client['send' + 'Request']` is not a literal in the AST.
* - Which method a listed file sends, or what it does with the reply. The count is a ceiling
* on how many times it reaches, nothing more.
* - Test files. `*.test.ts(x)` is not scanned: faking the port is how these suites work, and a
* test does not ship. A non-test file that fakes it (tsconfig excludes tests, so some do) is
* scanned and listed.
* A compile-time fence would catch the first two. That needs `RpcClient` to stop carrying the
* port, which needs the call sites migrated first — the thing this list is counting down.
*/
const mobileRoot = fileURLToPath(new URL('../..', import.meta.url))
const scannedRoots = ['app', 'src'].map((directory) => join(mobileRoot, directory))
const sourceExtensions = new Set(['.js', '.jsx', '.ts', '.tsx'])
const portModule = join(mobileRoot, 'src', 'transport', 'unvalidated-rpc-request-port')
/** The port and its own inventory are not offenders; the ratchet does not police itself. */
const SELF_FILES = new Set([
'src/transport/unvalidated-rpc-request-port.ts',
'src/transport/unvalidated-rpc-request-port-inventory.ts'
])
/** The coalescing second sender: same unchecked string in, same unread envelope out. */
const SECOND_SENDER = 'sendSingleFlightRequest'
function sourceFiles(directory: string): string[] {
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const path = join(directory, entry.name)
if (entry.isDirectory()) {
return entry.name === 'node_modules' ? [] : sourceFiles(path)
}
return [path]
})
}
function parse(path: string, source: string): ts.SourceFile {
const extension = extname(path)
return ts.createSourceFile(
path,
source,
ts.ScriptTarget.Latest,
true,
extension === '.tsx' || extension === '.jsx' ? ts.ScriptKind.TSX : ts.ScriptKind.TS
)
}
function targetsPortModule(path: string, node: ts.Node | undefined): boolean {
if (!node || !ts.isStringLiteral(node) || !node.text.startsWith('.')) {
return false
}
return resolve(path, '..', node.text) === portModule
}
/** `client['sendRequest']` is one reach, not two: the element access already counted it. */
function isCountedElementAccessArgument(node: ts.Node): boolean {
const parent: ts.Node | undefined = node.parent
return (
parent !== undefined &&
ts.isElementAccessExpression(parent) &&
parent.argumentExpression === node
)
}
function declaresPortMember(node: ts.Node): boolean {
if (
!ts.isPropertySignature(node) &&
!ts.isMethodSignature(node) &&
!ts.isMethodDeclaration(node) &&
!ts.isPropertyDeclaration(node) &&
!ts.isPropertyAssignment(node) &&
!ts.isShorthandPropertyAssignment(node)
) {
return false
}
const name = node.name
return (ts.isIdentifier(name) || ts.isStringLiteral(name)) && name.text === 'sendRequest'
}
/** How many times this file reaches the raw port directly. Comments never count: this is AST. */
export function rawRequestPortReferences(path: string, source: string): number {
let references = 0
const visit = (node: ts.Node): void => {
if (
(ts.isPropertyAccessExpression(node) && node.name.text === 'sendRequest') ||
(ts.isElementAccessExpression(node) &&
ts.isStringLiteral(node.argumentExpression) &&
node.argumentExpression.text === 'sendRequest') ||
declaresPortMember(node) ||
(ts.isStringLiteral(node) &&
node.text === 'sendRequest' &&
!isCountedElementAccessArgument(node)) ||
(ts.isIdentifier(node) && node.text === SECOND_SENDER)
) {
references += 1
}
if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) {
references += targetsPortModule(path, node.moduleSpecifier) ? 1 : 0
}
if (
ts.isCallExpression(node) &&
(node.expression.kind === ts.SyntaxKind.ImportKeyword ||
(ts.isIdentifier(node.expression) && node.expression.text === 'require')) &&
targetsPortModule(path, node.arguments[0])
) {
references += 1
}
ts.forEachChild(node, visit)
}
visit(parse(path, source))
return references
}
const inventory: readonly UnvalidatedRpcRequestPortEntry[] = [
...UNVALIDATED_RPC_REQUEST_PORT_OWNERS,
...UNVALIDATED_RPC_REQUEST_PORT_PENDING
]
const scanned = scannedRoots
.flatMap(sourceFiles)
.filter((path) => sourceExtensions.has(extname(path)))
.filter((path) => !/\.test\.tsx?$/.test(path))
.map((path) => relative(mobileRoot, path).split(/[/\\]/).join('/'))
.filter((file) => !SELF_FILES.has(file))
const observed = new Map(
scanned
.map(
(file) =>
[
file,
rawRequestPortReferences(
join(mobileRoot, file),
readFileSync(join(mobileRoot, file), 'utf8')
)
] as const
)
.filter(([, references]) => references > 0)
)
describe('unvalidated RPC request port boundary', () => {
const probe = join(mobileRoot, 'src', 'transport', 'probe.ts')
it('counts every shape that reaches the port', () => {
expect(rawRequestPortReferences(probe, 'await client.sendRequest("worktree.ps", {})')).toBe(1)
expect(rawRequestPortReferences(probe, 'const send = client.sendRequest')).toBe(1)
expect(rawRequestPortReferences(probe, 'client["sendRequest"]("x")')).toBe(1)
expect(rawRequestPortReferences(probe, "type A = Pick<RpcClient, 'sendRequest'>")).toBe(1)
expect(rawRequestPortReferences(probe, "type A = RpcClient['sendRequest']")).toBe(1)
expect(rawRequestPortReferences(probe, 'const c = { sendRequest: async () => reply }')).toBe(1)
expect(rawRequestPortReferences(probe, 'interface C { sendRequest(m: string): void }')).toBe(1)
expect(rawRequestPortReferences(probe, "if (name === 'sendRequest') { }")).toBe(1)
expect(
rawRequestPortReferences(probe, 'await sendSingleFlightRequest(c, h, "worktree.ps")')
).toBe(1)
expect(
rawRequestPortReferences(
probe,
"import { sendSingleFlightRequest } from './request-single-flight'"
)
).toBe(1)
expect(
rawRequestPortReferences(
probe,
"import type { UnvalidatedRpcRequestPort } from './unvalidated-rpc-request-port'"
)
).toBe(1)
expect(
rawRequestPortReferences(
probe,
"export type { SendRequestOptions } from './unvalidated-rpc-request-port'"
)
).toBe(1)
expect(
rawRequestPortReferences(probe, "const m = await import('./unvalidated-rpc-request-port')")
).toBe(1)
expect(rawRequestPortReferences(probe, 'a.sendRequest(1); b.sendRequest(2)')).toBe(2)
})
it('does not count prose or an unrelated sender', () => {
expect(rawRequestPortReferences(probe, '// calls sendRequest under the hood')).toBe(0)
expect(rawRequestPortReferences(probe, '/* sendRequest */ export const x = 1')).toBe(0)
expect(rawRequestPortReferences(probe, 'await client.subscribe("terminal.stream", {})')).toBe(0)
expect(rawRequestPortReferences(probe, "import type { RpcClient } from './rpc-client'")).toBe(0)
expect(rawRequestPortReferences(probe, 'await runRpcOperation(client, op, {})')).toBe(0)
})
it('scans a plausible number of files', () => {
// A broken root or extension filter would make every check below vacuously pass.
expect(scanned.length).toBeGreaterThan(400)
expect(observed.size).toBeGreaterThan(50)
})
it('lists each file once', () => {
const seen = inventory.map((entry) => entry.file)
expect(seen.filter((file, index) => seen.indexOf(file) !== index)).toEqual([])
})
it('has no unlisted file reaching the raw request port', () => {
const listed = new Set(inventory.map((entry) => entry.file))
const unlisted = [...observed.keys()].filter((file) => !listed.has(file))
expect(
unlisted,
'New code must send through an RpcOperation. Nothing may be added to unvalidated-rpc-request-port-inventory.ts.'
).toEqual([])
})
it('has no stale inventory entry', () => {
const stale = inventory.filter((entry) => !observed.has(entry.file))
expect(
stale.map((entry) => entry.file),
'File no longer reaches the raw port — delete its line from unvalidated-rpc-request-port-inventory.ts.'
).toEqual([])
})
it('has no inventory entry whose file gained references', () => {
const grown = inventory
.filter((entry) => (observed.get(entry.file) ?? 0) > entry.references)
.map(
(entry) => `${entry.file}: listed ${entry.references}, found ${observed.get(entry.file)}`
)
expect(grown, 'The counts are a ceiling. Send the new call through an RpcOperation.').toEqual(
[]
)
})
it('reports a count that has fallen so the entry can be lowered', () => {
const overstated = inventory
.filter(
(entry) => observed.has(entry.file) && (observed.get(entry.file) ?? 0) < entry.references
)
.map(
(entry) => `${entry.file}: listed ${entry.references}, found ${observed.get(entry.file)}`
)
expect(
overstated,
'Fewer references than listed — lower the count so the ratchet holds.'
).toEqual([])
})
})
@@ -0,0 +1,224 @@
/**
* Every file that still reaches mobile's raw RPC request port, held as data.
*
* A reference is any direct reach for the port: a `.sendRequest` access or declaration, a
* `'sendRequest'` selector such as `Pick<RpcClient, 'sendRequest'>`, a call to the coalescing
* second sender `sendSingleFlightRequest`, or an import of unvalidated-rpc-request-port.ts.
* The count is per file and is a ceiling, not a target: unvalidated-rpc-request-port-boundary.test.ts
* fails on a file that is not listed, on a listed file that no longer reaches the port, and on a
* listed file whose count went up. Both lists only shrink.
*
* The owners are permanent — they implement, route or validate the port. The pending list is the
* step-4 migration backlog and shares one reason, stated once here instead of 144 times:
* the call site predates the typed contract and still picks its own method string, its own
* acceptance rule and its own decoding. Replacing one with an RpcOperation deletes its line.
*/
export type UnvalidatedRpcRequestPortEntry = {
readonly file: string
readonly references: number
}
/** Modules whose job is the port. These do not shrink to zero. */
export const UNVALIDATED_RPC_REQUEST_PORT_OWNERS: readonly UnvalidatedRpcRequestPortEntry[] = [
// Implements the port over the device-to-host websocket.
{ file: 'src/transport/direct-rpc-client.ts', references: 3 },
// Fakes the port for the supervisor suites; a non-test file only because tsconfig excludes tests.
{ file: 'src/transport/mobile-endpoint-supervisor-test-fakes.ts', references: 2 },
// Implements the port over a relay channel.
{ file: 'src/transport/mobile-relay-physical-client.ts', references: 2 },
// Supplies the port for one relay session.
{ file: 'src/transport/mobile-relay-rpc-session.ts', references: 1 },
// A second raw sender: string method in, unread envelope out. Its callers are fenced too.
{ file: 'src/transport/request-single-flight.ts', references: 3 },
// Owns connect-wait, timeout and replay bookkeeping for every raw request.
{ file: 'src/transport/rpc-client-request-tracker.ts', references: 1 },
// Composes the port into RpcClient, which is why every holder of a client still carries it.
{ file: 'src/transport/rpc-client.ts', references: 2 },
// The typed boundary itself — the one module that turns a reply into a declared type.
{ file: 'src/transport/rpc-operation.ts', references: 2 },
// Forwards the port across a physical-client cutover.
{ file: 'src/transport/stable-logical-rpc-client.ts', references: 2 }
]
/** Call sites awaiting migration to a typed operation. Grouped by the feature area that owns them. */
export const UNVALIDATED_RPC_REQUEST_PORT_PENDING: readonly UnvalidatedRpcRequestPortEntry[] = [
// app/h/[hostId]/ — Expo route screens
{ file: 'app/h/[hostId]/accounts.tsx', references: 2 },
// app/ — Expo route screens
{ file: 'app/terminal-settings.tsx', references: 3 },
// src/agent-history/ — agent history loads
{ file: 'src/agent-history/MobileAgentSessionHistoryPanel.tsx', references: 7 },
{ file: 'src/agent-history/use-mobile-agent-history-state.ts', references: 2 },
// src/browser/ — hosted browser control
{ file: 'src/browser/use-mobile-browser-commands.ts', references: 5 },
{ file: 'src/browser/use-mobile-browser-request.ts', references: 1 },
// src/components/ — shared widgets that fetch their own data
{ file: 'src/components/codex-reset-credit-capability.ts', references: 2 },
{ file: 'src/components/codex-reset-credit.ts', references: 3 },
{ file: 'src/components/use-new-workspace-create-submit.ts', references: 1 },
{ file: 'src/components/use-new-workspace-execution-target.ts', references: 4 },
{ file: 'src/components/use-new-workspace-repositories.ts', references: 1 },
{ file: 'src/components/use-new-workspace-runtime-context.ts', references: 4 },
{ file: 'src/components/use-new-workspace-setup-script.ts', references: 1 },
// src/dictation/ — dictation session control
{ file: 'src/dictation/mobile-dictation-setup.ts', references: 10 },
// src/files/ — file read, write and preview
{ file: 'src/files/mobile-file-mutation-ownership.ts', references: 3 },
{ file: 'src/files/mobile-file-preview-request.ts', references: 6 },
{ file: 'src/files/mobile-file-tab-doc.ts', references: 4 },
{ file: 'src/files/mobile-terminal-artifact-grant-refresh.ts', references: 2 },
{ file: 'src/files/MobileFileExplorerPanel.tsx', references: 2 },
// src/home/ — home screen host reads
{ file: 'src/home/mobile-home-host-requests.ts', references: 6 },
// src/hooks/ — cross-screen data hooks
{ file: 'src/hooks/mobile-dictation-audio-chunk.ts', references: 1 },
{ file: 'src/hooks/mobile-dictation-desktop-start.ts', references: 4 },
{ file: 'src/hooks/use-mobile-dictation.ts', references: 4 },
// src/host-screen/ — host screen catalog and actions
{ file: 'src/host-screen/host-screen-overlays.tsx', references: 1 },
{ file: 'src/host-screen/use-host-repo-metadata.ts', references: 2 },
{ file: 'src/host-screen/use-host-view-settings.ts', references: 2 },
{ file: 'src/host-screen/use-host-worktree-actions.ts', references: 3 },
// src/notifications/ — push registration and delivery
{ file: 'src/notifications/mobile-notifications.ts', references: 2 },
// src/session/ — session screen: chat, diff review, PR actions, tabs
{ file: 'src/session/ai-vault-resume-launch.ts', references: 3 },
{ file: 'src/session/ai-vault-resume-preparation.ts', references: 2 },
{ file: 'src/session/github-pr-mutations.ts', references: 16 },
{ file: 'src/session/github-pr-rpc.ts', references: 9 },
{ file: 'src/session/mobile-clipboard-image.ts', references: 7 },
{ file: 'src/session/mobile-diff-review-loaders.ts', references: 5 },
{ file: 'src/session/mobile-file-tap-open.ts', references: 3 },
{ file: 'src/session/mobile-image-attachment.ts', references: 2 },
{ file: 'src/session/mobile-native-chat-image-attachment.ts', references: 1 },
{ file: 'src/session/mobile-native-chat-image-send.ts', references: 2 },
{ file: 'src/session/mobile-native-chat-send.ts', references: 2 },
{ file: 'src/session/mobile-native-chat-session-option-persistence.ts', references: 1 },
{ file: 'src/session/mobile-native-chat-stale-input.ts', references: 1 },
{ file: 'src/session/mobile-new-tab-agent-loader.ts', references: 5 },
{ file: 'src/session/mobile-session-tab-activation.ts', references: 3 },
{ file: 'src/session/mobile-session-tabs-stream-health.ts', references: 1 },
{ file: 'src/session/mobile-structured-agent-session-launch.ts', references: 3 },
{ file: 'src/session/mobile-structured-agent-session-rpc.ts', references: 1 },
{ file: 'src/session/pr-ai-triage-launch.ts', references: 3 },
{ file: 'src/session/use-live-worktree-name.ts', references: 1 },
{ file: 'src/session/use-mobile-diff-review-comment-actions.ts', references: 1 },
{ file: 'src/session/use-mobile-diff-review-git-actions.ts', references: 2 },
{ file: 'src/session/use-mobile-diff-review-interactions.ts', references: 1 },
{ file: 'src/session/use-mobile-diff-review-send-actions.ts', references: 3 },
{ file: 'src/session/use-mobile-file-tap-handlers.ts', references: 1 },
{ file: 'src/session/use-mobile-native-chat-file-search.ts', references: 2 },
{ file: 'src/session/use-mobile-native-chat-readability.ts', references: 1 },
{ file: 'src/session/use-mobile-native-chat-session.ts', references: 1 },
{ file: 'src/session/use-mobile-native-chat-stop.ts', references: 1 },
{ file: 'src/session/use-mobile-pr-actions.ts', references: 1 },
{ file: 'src/session/use-mobile-pr-branch-context.ts', references: 2 },
{ file: 'src/session/use-mobile-pr-comment-actions.ts', references: 1 },
{ file: 'src/session/use-mobile-pr-title-action.ts', references: 1 },
{ file: 'src/session/use-mobile-session-accessory-selection.ts', references: 1 },
{ file: 'src/session/use-mobile-session-close-actions.ts', references: 3 },
{ file: 'src/session/use-mobile-session-content-create-actions.ts', references: 4 },
{ file: 'src/session/use-mobile-session-diff-comments.ts', references: 2 },
{ file: 'src/session/use-mobile-session-document-readers.ts', references: 2 },
{ file: 'src/session/use-mobile-session-markdown-actions.ts', references: 1 },
{ file: 'src/session/use-mobile-session-startup.ts', references: 2 },
{ file: 'src/session/use-mobile-session-terminal-create-actions.ts', references: 2 },
{ file: 'src/session/use-mobile-session-terminal-input.ts', references: 2 },
{ file: 'src/session/use-mobile-session-terminal-list.ts', references: 1 },
{ file: 'src/session/use-mobile-session-terminal-send-actions.ts', references: 2 },
{ file: 'src/session/use-mobile-session-terminal-stream-display.ts', references: 1 },
{ file: 'src/session/use-mobile-terminal-paste.ts', references: 1 },
{ file: 'src/session/use-pr-bot-author-overrides.ts', references: 1 },
{ file: 'src/session/use-quick-commands.ts', references: 2 },
// src/settings/ — settings screen actions
{ file: 'src/settings/native-voice-settings-operations.ts', references: 1 },
// src/source-control/ — source control: review, commit, branch
{ file: 'src/source-control/mobile-branch-base-ref.ts', references: 3 },
{ file: 'src/source-control/mobile-commit-message-ai.ts', references: 4 },
{ file: 'src/source-control/mobile-git-history.ts', references: 2 },
{ file: 'src/source-control/mobile-hosted-review-create-intent-runner.ts', references: 1 },
{ file: 'src/source-control/mobile-hosted-review-create-intent.ts', references: 3 },
{ file: 'src/source-control/mobile-hosted-review-git-preparation.ts', references: 6 },
{ file: 'src/source-control/mobile-hosted-review-remote-prerequisite.ts', references: 1 },
{ file: 'src/source-control/mobile-hosted-review-service.ts', references: 8 },
{ file: 'src/source-control/mobile-pr-link.ts', references: 8 },
{ file: 'src/source-control/MobileGitHistoryList.tsx', references: 1 },
{ file: 'src/source-control/reveal-mobile-source-control-session-diff.ts', references: 2 },
{ file: 'src/source-control/use-mobile-git-requests.ts', references: 1 },
{ file: 'src/source-control/use-mobile-source-control-loaders.ts', references: 2 },
{ file: 'src/source-control/use-mobile-source-control-openers.ts', references: 3 },
// src/tasks/ — task lists, filters and mutations
{ file: 'src/tasks/composer-source-base-resolve.ts', references: 2 },
{ file: 'src/tasks/mobile-tasks-filter-pickers.tsx', references: 1 },
{ file: 'src/tasks/mobile-tasks-source-family.test-support.ts', references: 1 },
{ file: 'src/tasks/setup-hook-trust.ts', references: 1 },
{ file: 'src/tasks/smart-source-paste-intent.ts', references: 4 },
{ file: 'src/tasks/smart-source-search-requests.ts', references: 5 },
{ file: 'src/tasks/use-mobile-tasks-client-settings-actions.tsx', references: 6 },
{ file: 'src/tasks/use-mobile-tasks-github-check-file-actions.tsx', references: 5 },
{ file: 'src/tasks/use-mobile-tasks-github-reply-merge-actions.tsx', references: 5 },
{ file: 'src/tasks/use-mobile-tasks-gitlab-github-status-actions.tsx', references: 3 },
{ file: 'src/tasks/use-mobile-tasks-hosted-comment-review-actions.tsx', references: 4 },
{ file: 'src/tasks/use-mobile-tasks-hosted-metadata-actions.tsx', references: 2 },
{ file: 'src/tasks/use-mobile-tasks-item-detail-loading.tsx', references: 4 },
{ file: 'src/tasks/use-mobile-tasks-item-detail-metadata-effects.tsx', references: 2 },
{ file: 'src/tasks/use-mobile-tasks-linear-item-actions.tsx', references: 3 },
{ file: 'src/tasks/use-mobile-tasks-list-and-detail-effects.tsx', references: 2 },
{ file: 'src/tasks/use-mobile-tasks-project-detail-loading.tsx', references: 1 },
{ file: 'src/tasks/use-mobile-tasks-project-file-merge-actions.tsx', references: 4 },
{ file: 'src/tasks/use-mobile-tasks-project-loading-actions.tsx', references: 4 },
{ file: 'src/tasks/use-mobile-tasks-project-metadata-actions.tsx', references: 3 },
{ file: 'src/tasks/use-mobile-tasks-project-metadata-loading.tsx', references: 3 },
{ file: 'src/tasks/use-mobile-tasks-project-repository-resolution.tsx', references: 1 },
{ file: 'src/tasks/use-mobile-tasks-project-review-check-actions.tsx', references: 4 },
{ file: 'src/tasks/use-mobile-tasks-project-thread-reply-actions.tsx', references: 4 },
{ file: 'src/tasks/use-mobile-tasks-project-workspace-comment-actions.tsx', references: 3 },
{ file: 'src/tasks/use-mobile-tasks-provider-load-actions.tsx', references: 5 },
{ file: 'src/tasks/use-mobile-tasks-route-and-item-state.tsx', references: 1 },
{ file: 'src/tasks/use-mobile-tasks-runtime-hydration.tsx', references: 5 },
{ file: 'src/tasks/use-mobile-tasks-task-create-actions.tsx', references: 3 },
{ file: 'src/tasks/use-mobile-tasks-task-list-loading.tsx', references: 4 },
{ file: 'src/tasks/use-mobile-tasks-task-pagination-actions.tsx', references: 1 },
{ file: 'src/tasks/use-mobile-tasks-workspace-create-actions.tsx', references: 4 },
{ file: 'src/tasks/use-mobile-tasks-workspace-source-effects.tsx', references: 2 },
{ file: 'src/tasks/use-mobile-tasks-workspace-sparse-actions.tsx', references: 2 },
{ file: 'src/tasks/use-mobile-tasks-workspace-ssh-state.tsx', references: 5 },
{ file: 'src/tasks/worktree-create-capability.ts', references: 1 },
{ file: 'src/tasks/worktree-create-retry.ts', references: 1 },
// src/terminal/ — terminal input, viewport and queries
{ file: 'src/terminal/mobile-terminal-query-reply.ts', references: 2 },
{ file: 'src/terminal/terminal-live-accessory-raw-send.ts', references: 2 },
{ file: 'src/terminal/terminal-viewport-refit.ts', references: 1 },
{ file: 'src/terminal/worker-terminal-takeover-report.ts', references: 2 },
// src/transport/ — pairing, endpoint probing and capability reads
{ file: 'src/transport/host-status-gates.ts', references: 1 },
{ file: 'src/transport/mobile-relay-credential-rotation.ts', references: 2 },
{ file: 'src/transport/mobile-relay-direct-upgrade.ts', references: 2 },
{ file: 'src/transport/mobile-relay-pairing-recovery.ts', references: 2 },
{ file: 'src/transport/mobile-runtime-capability-negotiation.ts', references: 2 },
{ file: 'src/transport/pairing-candidate-race.ts', references: 1 },
{ file: 'src/transport/pairing-relay-candidate.ts', references: 4 },
{ file: 'src/transport/pre-profile-pairing-coordinator.ts', references: 2 },
{ file: 'src/transport/runtime-capability-probe.ts', references: 1 },
// src/worktree/ — worktree activation and resume
{ file: 'src/worktree/home-host-worktree-fetch.ts', references: 2 },
{ file: 'src/worktree/use-retired-worktree-names.ts', references: 1 },
{ file: 'src/worktree/worktree-catalog-snapshot-client.ts', references: 1 }
]
@@ -0,0 +1,31 @@
import type { RpcResponse } from './types'
// The raw request port, kept in its own module so that reaching it is a visible act.
//
// Nothing on this path is checked against the host contract: `method` is an unconstrained
// string, `params` is `unknown`, and the reply's `result` stays `unknown`. A value that came
// back through here has been parsed as JSON and nothing more, so it is NOT validated and must
// not be annotated as though it were. The typed boundary — defineRpcOperation and the send
// helpers in rpc-operation.ts — is the only path that turns a reply into a declared type, and
// rpc-operation.ts is the only module here that should be importing this one for that purpose.
//
// Every other file that still reaches this port is inventoried in
// unvalidated-rpc-request-port-inventory.ts and fenced by
// unvalidated-rpc-request-port-boundary.test.ts. That list only shrinks.
export type SendRequestOptions = {
timeoutMs?: number
/** Include the connect wait in the caller's timeout budget. */
budgetSpansConnect?: boolean
/** Reject instead of replaying the request after reconnect. */
failWhenDisconnected?: boolean
}
/** Unvalidated: an arbitrary method name in, an unread envelope out. */
export type UnvalidatedRpcRequestPort = {
sendRequest: (
method: string,
params?: unknown,
options?: SendRequestOptions
) => Promise<RpcResponse>
}