mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 16:02:24 +00:00
`anti-slop/no-reflect-get` rejects every call to `Reflect.get`. The
reflective read bypasses ordinary property access and throws away the
type evidence the compiler would otherwise give you: the result is
`any`/`unknown` with no narrowing, so a typo in the key or a shape drift
in the source object is invisible until runtime. The rule's remedy is to
parse dynamic input into a named domain type (or narrow it with `in`)
and then read the field normally.
Baseline: 86 violations across 67 files. Now zero unsuppressed
violations under
`npx oxlint --config config/oxlint-anti-slop.json --ignore-pattern 'config/oxlint-plugins/anti-slop/**' src config tests mobile`.
Fix pattern
-----------
44 of the 86 were rewritten. The dominant shape was an `unknown` value
read through `Reflect.get` right after a `typeof === 'object'` guard;
those became `in`-narrowed property access, which TypeScript checks:
- Reflect.get(value, 'agents')
+ 'agents' in value ? value.agents : null
Two further shapes:
- `Reflect.get(Object(x), 'k')` on a possibly-primitive envelope became a
small named reader that boxes once and indexes a
`Record<string, unknown>` (`settingsField` in
mobile/src/transport/settings-read-operations.ts).
- Tests reaching into private state moved to TypeScript's checked
bracket-index escape hatch (`runtime['layoutQueues']`), or to a
documented read-only accessor on the owning class
(`SearchSubprocessLineAccumulator.retainedCapacityBytes()`,
`CodexSubagentExecutions.retentionSizes()`).
No type assertion was added anywhere: the diff contains zero net-new
`as` casts, `as any`, `as unknown as`, `@ts-ignore`, or
`@ts-expect-error`, so nothing was laundered into the sibling
assertion rules.
Suppressions
------------
42x `// oxlint-disable-next-line anti-slop/no-reflect-get` across 38
files. Every one is the default-forward branch of a `Proxy` `get` trap:
get(target, property, receiver) {
...
return Reflect.get(target, property, receiver)
}
`Reflect.get(target, property, receiver)` is the only construct that
forwards with correct `receiver` semantics; `target[property]` invokes
an accessor with the wrong `this` and silently breaks getters that read
sibling state. There is no typed alternative, so these are suppressed
rather than rewritten.
3x `// oxlint-disable-next-line typescript-eslint/consistent-type-definitions
-- declaration merging requires interface` in
tests/e2e/github-url-smart-input-transition.spec.ts,
tests/e2e/linear-url-workspace-entry.spec.ts, and
tests/e2e/worktree-active-delete-scroll-position.spec.ts. Replacing
`Reflect.get(window, 'x')` with typed `window.x` requires a
`declare global { interface Window }` block, and `interface` is
mandatory for declaration merging. Matches the existing convention at
tests/e2e/helpers/runtime-types.ts:63.
1x `// eslint-disable-next-line no-var -- main-process gate handle for
this spec` in tests/e2e/project-group-creation-visibility.spec.ts, for
the same reason a `var` global is needed to type the handle. Matches
tests/e2e/agent-session-log-tail-stability.spec.ts:24.
Also updates two source-text anchors in mobile's rpc-recording mutation
harness (mobile/src/test-support/rpc-recording/operation-mutations.ts
and recording-runner.test.ts), which pin the exact text of the rewritten
line in settings-read-operations.ts and would otherwise fail with
"Mutant anchor matched 0 sites, expected 1".
208 lines
7.4 KiB
TypeScript
208 lines
7.4 KiB
TypeScript
export type HostTerminalDataMeta = {
|
|
seq?: number
|
|
rawLength?: number
|
|
cwd?: string
|
|
}
|
|
|
|
/**
|
|
* The authoritative side of the journey: one terminal handle backed by a fake PTY.
|
|
* It records what the host was actually asked to do (input written, snapshots
|
|
* serialized) so the oracle can prove the journey reached the process, not just
|
|
* that frames moved.
|
|
*/
|
|
export type HostTerminalRuntimeStub = {
|
|
runtime: unknown
|
|
ptyId: string
|
|
terminalHandle: string
|
|
/** Every text the host wrote to the PTY, in order. */
|
|
writtenInput: string[]
|
|
/** Scrollback the client would see in a snapshot. */
|
|
buffer: string
|
|
/** How many times the host serialized a buffer for a snapshot. */
|
|
serializeCount: number
|
|
/** Push PTY output to every host-side data listener. */
|
|
emitOutput: (data: string, meta?: HostTerminalDataMeta) => void
|
|
/** Names of runtime methods the host called that the stub does not implement. */
|
|
missingRuntimeMethods: string[]
|
|
/** Run the host's registered teardown for one connection, as a socket close does. */
|
|
closeConnection: (connectionId: string) => void
|
|
}
|
|
|
|
export function createHostTerminalRuntimeStub(
|
|
options: {
|
|
terminalHandle?: string
|
|
ptyId?: string
|
|
cols?: number
|
|
rows?: number
|
|
initialBuffer?: string
|
|
overflowInitialSnapshots?: boolean
|
|
} = {}
|
|
): HostTerminalRuntimeStub {
|
|
const terminalHandle = options.terminalHandle ?? 'terminal-journey'
|
|
const ptyId = options.ptyId ?? 'pty-journey'
|
|
const cols = options.cols ?? 120
|
|
const rows = options.rows ?? 40
|
|
const dataListeners = new Set<(data: string, meta?: HostTerminalDataMeta) => void>()
|
|
const cleanups = new Map<string, { connectionId: string | undefined; run: () => void }>()
|
|
const stub: HostTerminalRuntimeStub = {
|
|
runtime: null,
|
|
ptyId,
|
|
terminalHandle,
|
|
writtenInput: [],
|
|
buffer: options.initialBuffer ?? '',
|
|
serializeCount: 0,
|
|
emitOutput: () => {},
|
|
missingRuntimeMethods: [],
|
|
closeConnection: () => {}
|
|
}
|
|
|
|
stub.closeConnection = (connectionId) => {
|
|
const pending: (() => void)[] = []
|
|
for (const [id, entry] of cleanups) {
|
|
if (entry.connectionId === connectionId) {
|
|
cleanups.delete(id)
|
|
pending.push(entry.run)
|
|
}
|
|
}
|
|
for (const run of pending) {
|
|
run()
|
|
}
|
|
}
|
|
|
|
let outputSequence = 0
|
|
stub.emitOutput = (data, meta) => {
|
|
stub.buffer += data
|
|
outputSequence += data.length
|
|
const resolved: HostTerminalDataMeta = {
|
|
seq: outputSequence,
|
|
rawLength: data.length,
|
|
...meta
|
|
}
|
|
// Snapshot: a listener may unsubscribe while the host fans this out.
|
|
for (const listener of Array.from(dataListeners)) {
|
|
listener(data, resolved)
|
|
}
|
|
}
|
|
|
|
const serialize = async (): Promise<{
|
|
data: string
|
|
cols: number
|
|
rows: number
|
|
seq: number
|
|
source: 'headless'
|
|
alternateScreen: false
|
|
terminalOwner: 'shell'
|
|
}> => {
|
|
stub.serializeCount++
|
|
const snapshot = {
|
|
data: stub.buffer,
|
|
cols,
|
|
rows,
|
|
seq: outputSequence,
|
|
source: 'headless' as const,
|
|
alternateScreen: false as const,
|
|
terminalOwner: 'shell' as const
|
|
}
|
|
if (options.overflowInitialSnapshots && stub.serializeCount <= 2) {
|
|
const data = 'x'.repeat(300 * 1024)
|
|
outputSequence += data.length
|
|
for (const listener of Array.from(dataListeners)) {
|
|
listener(data, { seq: outputSequence, rawLength: data.length })
|
|
}
|
|
}
|
|
return snapshot
|
|
}
|
|
|
|
const runtime: Record<string, unknown> = {
|
|
getRuntimeId: () => 'cross-version-host',
|
|
resolveLiveLeafForHandle: (handle: string) => (handle === terminalHandle ? { ptyId } : null),
|
|
resolveLeafForHandle: (handle: string) => (handle === terminalHandle ? { ptyId } : null),
|
|
registerRemoteTerminalViewSubscriber: () => () => {},
|
|
requestRendererTerminalTabMount: () => true,
|
|
updateRemoteDesktopViewer: async () => true,
|
|
unregisterRemoteDesktopViewer: async () => true,
|
|
unregisterRemoteDesktopViewers: async () => true,
|
|
isPtyResizeDrivenRemotely: () => false,
|
|
getRemoteDesktopFitHold: () => ({ mode: 'desktop-fit', cols, rows }),
|
|
isRemoteDesktopViewerOwner: () => false,
|
|
getPtyOutputSequence: () => outputSequence,
|
|
serializeTerminalBuffer: serialize,
|
|
serializeAuthoritativeTerminalBuffer: serialize,
|
|
serializeRendererTerminalBuffer: serialize,
|
|
readTerminal: async () => ({ tail: [], truncated: false }),
|
|
getTerminalSize: () => ({ cols, rows }),
|
|
getMobileDisplayMode: () => 'auto',
|
|
getLayout: () => ({ seq: 1 }),
|
|
getTerminalFitOverride: () => null,
|
|
getDriver: () => ({ kind: 'idle' }),
|
|
subscribeToTerminalData: (
|
|
_ptyId: string,
|
|
listener: (d: string, m?: HostTerminalDataMeta) => void
|
|
) => {
|
|
dataListeners.add(listener)
|
|
return () => dataListeners.delete(listener)
|
|
},
|
|
subscribeToTerminalResize: () => () => {},
|
|
subscribeToFitOverrideChanges: () => () => {},
|
|
subscribeToDriverChanges: () => () => {},
|
|
registerSubscriptionCleanup: (id: string, cleanup: () => void, connectionId?: string) => {
|
|
cleanups.set(id, { connectionId, run: cleanup })
|
|
},
|
|
cleanupSubscription: (id: string) => {
|
|
const entry = cleanups.get(id)
|
|
cleanups.delete(id)
|
|
entry?.run()
|
|
},
|
|
waitForTerminal: () => new Promise(() => {}),
|
|
// The input oracle: the host reached the process with exactly this text.
|
|
sendTerminal: async (_handle: string, action: { text?: string }) => {
|
|
if (typeof action?.text === 'string') {
|
|
stub.writtenInput.push(action.text)
|
|
}
|
|
return { accepted: true }
|
|
},
|
|
beginMobileInputFloor: () => ({ commit: () => {}, rollback: () => {} }),
|
|
isTerminalInputLocked: () => false,
|
|
getTerminalInputLock: () => null,
|
|
// Source-range accounting is a host-internal ledger, not part of the wire; decline it.
|
|
attachRemoteTerminalSourceRangeConsumer: () => false,
|
|
cancelRemoteTerminalSourceRanges: () => {},
|
|
settleRemoteTerminalSourceRanges: () => {},
|
|
reserveRemoteTerminalSourceRangeReplacement: () => null,
|
|
commitRemoteTerminalSourceRangeReplacement: () => {},
|
|
rollbackRemoteTerminalSourceRangeReplacement: () => {},
|
|
getRendererTerminalSerializerGeneration: () => 0,
|
|
getRendererTerminalSerializerGenerationForHandle: () => 0,
|
|
hasHeadlessTerminalState: () => true,
|
|
isTerminalAlternateScreen: () => false,
|
|
isTerminalRunningAgent: () => false,
|
|
getTerminalAgentStatus: () => null,
|
|
isMobileTerminalQueryReplyAuthority: () => false,
|
|
markMobileActor: () => {},
|
|
refreshRemoteDesktopViewer: async () => true,
|
|
resizeForClient: async () => ({ cols, rows }),
|
|
waitForLeafPtyId: async () => ptyId,
|
|
recoverTerminalPane: async () => null,
|
|
getMobileAutoRestoreFitMs: () => null,
|
|
isMobileSubscriberActive: () => false
|
|
}
|
|
|
|
// Why: the two builds may ask the host for different methods. Record the gap by
|
|
// name and return undefined, so the oracle fails naming the method that needs
|
|
// adding here — instead of an unhandled TypeError that reads like a wire break.
|
|
stub.runtime = new Proxy(runtime, {
|
|
get(target, property, receiver) {
|
|
if (typeof property === 'string' && !(property in target)) {
|
|
if (!stub.missingRuntimeMethods.includes(property)) {
|
|
stub.missingRuntimeMethods.push(property)
|
|
}
|
|
return () => undefined
|
|
}
|
|
// oxlint-disable-next-line anti-slop/no-reflect-get -- Proxy get trap default forward.
|
|
return Reflect.get(target, property, receiver)
|
|
}
|
|
})
|
|
|
|
return stub
|
|
}
|