From bfdec26352c0f0a36b35c7418f4bfa7f1d33bd25 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 15 Sep 2026 01:59:58 -0700 Subject: [PATCH] fix(lint): enable anti-slop/no-object-parameters (#20781) The rule rejects the broad `object` type on any function input (declarations, expressions, arrows, methods, call/construct signatures, function types), plus local aliases and unions that resolve to `object`. `object` accepts every non-primitive while exposing no properties, so it documents nothing and pushes callers into assertions at the boundary. Fixes all 185 violations across src, config, tests and mobile, and flips the rule from "off" to "error" in config/oxlint-anti-slop.json. Approach: replace each `object` input with the type its owner already has. Most sites took an existing domain type or a type-only import (36 added); 40 new aliases name shapes that had none. Where a value is genuinely only compared by reference, it gets a named identity token instead of a shape -- `Record`, the built-in `WeakKey`, or a `unique symbol` brand, matching the branding already used in src/shared. Same treatment for WeakMap and Map key parameters. Two `as unknown as` casts became unnecessary once the parameter carried a real type and were removed; no new casts were added. Suppressions added: none. No `oxlint-disable` for this rule anywhere, and no max-lines disable or per-file bump. Three files sat exactly at their max-lines cap, so the added type imports were made line-neutral rather than suppressed: - src/main/ipc/browser.ts exports the existing guest-registration args type (renamed BrowserGuestArgs) so browser.test.ts reuses it on one line. - pane-scroll.ts takes TerminalScrollIntentTarget through the existing pane-manager-types import via a type-only re-export. - direct-rpc-client.ts drops the identity parameter entirely: the session check moved into the sendProbe callback that owns the token. Verified: anti-slop config reports zero violations over src config tests mobile; run-typecheck-projects-in-parallel exits 0; 144 affected test files pass (1749 tests); oxlint and oxfmt clean on all changed files. Mobile has no runnable test/typecheck target in this worktree (expo is not installed), so its 6 files were typechecked against a standalone config and diffed against the base branch -- error sets are byte-identical, including test files. --- config/oxlint-anti-slop.json | 2 +- .../agent-status-hot-path-benchmark.test.ts | 9 +++- .../happy-dom-mutation-observer-retention.ts | 8 ++-- .../connection-diagnostics-screen-data.ts | 7 +++- .../mobile-terminal-viewport-resubscribe.ts | 11 +++-- mobile/src/transport/direct-rpc-client.ts | 6 +-- .../host-client-acquisition-registry.ts | 3 +- mobile/src/transport/relay-dial-stage.ts | 23 +++++++--- .../rpc-session-liveness-watchdog.ts | 5 ++- .../ai-vault-search/session-search-clock.ts | 6 +-- .../artifacts/artifact-cloud-recovery.test.ts | 12 ++++-- .../agent-browser-bridge-test-harness.ts | 19 +++++---- .../browser/browser-cookie-import-clear.ts | 17 ++++++-- .../browser-cookie-import-concurrency.test.ts | 5 ++- ...ser-cookie-import-google-exclusion.test.ts | 5 ++- ...r-cookie-import-partition-fidelity.test.ts | 5 ++- .../browser-cookie-import-replacement.test.ts | 5 ++- ...kie-import-route-partition-staging.test.ts | 5 ++- .../browser-cookie-import-scope.test.ts | 5 ++- ...rowser-cookie-import-undecryptable.test.ts | 5 ++- .../browser/browser-cookie-import.test.ts | 5 ++- .../browser/cdp-keyboard-us-layout.test.ts | 8 ++-- .../doc-preview-download-block-notice.test.ts | 5 ++- .../managed-codex-auth-readiness.test.ts | 2 +- .../codex-session-migration-scheduler.ts | 18 ++++++-- ...aemon-pty-adapter-history-recovery.test.ts | 6 ++- src/main/git/git-capability-state.test.ts | 8 +++- src/main/git/git-capability-state.ts | 5 ++- ...browser-preview-tool-authorization.test.ts | 10 ++++- src/main/ipc/browser.test.ts | 5 ++- src/main/ipc/browser.ts | 8 ++-- src/main/ipc/filesystem-test-harness.ts | 8 +++- .../ipc/runtime-watcher-process-pool.test.ts | 3 +- src/main/ipc/settings.test.ts | 6 ++- ...thoritative-local-metadata-pruning.test.ts | 2 +- .../ipc/worktrees-lineage-hydration.test.ts | 3 +- src/main/ipc/worktrees-test-ipc-surface.ts | 3 +- .../wsl-transcript-fs-process-dispatch.ts | 2 +- .../network/electron-proxy-credentials.ts | 13 +++--- .../hook-plugin-fail-open-ownership.test.ts | 6 ++- .../hook-plugin-lifecycle-delivery.test.ts | 8 +++- .../loading-store/automation-persistence.ts | 2 +- .../metadata-lineage-operations.ts | 2 +- .../mobile-tab-selection-persistence.ts | 2 +- .../loading-store/primary-state-writes.ts | 2 +- .../loading-store/profile-preferences.ts | 5 ++- .../project-collection-operations.ts | 2 +- .../loading-store/pty-binding-persistence.ts | 2 +- .../repo-lifecycle-operations.ts | 2 +- .../retired-worktree-name-persistence.ts | 2 +- .../loading-store/session-host-partitions.ts | 2 +- .../session-snapshot-operations.ts | 2 +- .../sparse-preset-persistence.ts | 2 +- .../ssh-lease-recovery-operations.ts | 2 +- .../loading-store/ssh-profile-operations.ts | 2 +- .../loading-store/store-domain-composition.ts | 3 +- .../loading-store/write-flush-barriers.ts | 2 +- .../loading-store/write-scheduling.ts | 2 +- ...ng-local-worktree-metadata-pruning.test.ts | 3 +- .../browser-client-download-transfer-store.ts | 8 +++- ...st-lease-download-transfer-cleanup.test.ts | 4 +- .../relay/relay-control-client.test.ts | 5 ++- .../runtime/relay/relay-control-client.ts | 2 +- .../runtime/relay/relay-control-requests.ts | 42 +++++++++++-------- .../runtime/runtime-browser-page-registry.ts | 6 ++- .../runtime/runtime-linear-command-surface.ts | 15 ++++--- ...ructured-session-worktree-teardown.test.ts | 5 ++- .../structured-worker-terminal-read.test.ts | 9 +++- .../hosted-review-branch-cache.ts | 11 +++-- .../worktree-retirement-backfill-scan.test.ts | 2 +- src/main/worktree-retirement-backfill-scan.ts | 6 ++- ...dispatcher-frame-guard-regressions.test.ts | 5 ++- src/relay/dispatcher.test.ts | 11 ++--- .../relay-filesystem-watch-registry.test.ts | 3 +- .../agent/AgentSettingsDialog.test.tsx | 9 +++- .../useAgentBucketCounts.gate.test.ts | 3 +- ...fCommentDecorator.model-lifecycle.test.tsx | 11 ++++- .../editor/markdown-preview-search.ts | 19 ++++++--- .../editor/rich-markdown-auto-focus.test.ts | 5 ++- .../editor/rich-markdown-key-handler.test.ts | 6 +-- .../rich-markdown-list-continuation.test.ts | 4 +- .../editor/rich-markdown-paragraph.test.ts | 8 ++-- .../rich-markdown-tab-key-handler.test.ts | 20 ++++----- .../use-markdown-preview-source-foundation.ts | 3 +- .../src/components/github-checks-tab-state.ts | 7 +++- .../checks-tab-actions.ts | 9 ++-- .../inspect-pull-request/checks-tab.tsx | 5 ++- .../pull-request-page/checks/rerun.ts | 15 +++++-- .../pull-request-page/checks/tab.tsx | 9 ++-- .../GeneralWorkspaceSettingsSection.test.tsx | 3 +- ...RepositoryWorktreeDefaultsSection.test.tsx | 2 +- ...worktree-agent-orchestration-index.test.ts | 5 ++- .../task-page-github-work-item-quiet-state.ts | 9 ++-- .../hidden-output-restore-scheduler.ts | 12 ++++-- .../pty-renderer-delivery-claims.ts | 13 ++++-- .../terminal-captured-input-dispatch.ts | 4 +- .../terminal-ime-xterm-adversarial.test.ts | 11 +++-- .../terminal-pane-lifecycle-primitives.ts | 9 ++-- .../use-task-page-github-quiet-refresh.ts | 3 +- ...vigationMetadata.capability-owner.test.tsx | 12 +++++- ...WindowsTerminalCapabilityOwnerKey.test.tsx | 2 +- .../technical-literal-catalog-values.test.ts | 2 +- .../src/lib/ime-composition-keyboard-event.ts | 16 +++---- .../pane-cursor-blink-suspension.test.ts | 5 ++- .../lib/pane-manager/pane-lifecycle.test.ts | 4 +- .../lib/pane-manager/pane-manager-types.ts | 2 + .../pane-manager/pane-rendering-control.ts | 10 +++-- .../src/lib/pane-manager/pane-scroll.ts | 10 ++--- .../pane-terminal-output-ack-credit.ts | 5 ++- .../terminal-parsed-dirty-rows.ts | 19 ++++++--- .../terminal-scroll-intent-rebuild.ts | 24 +++++++---- .../terminal-webgl-hidden-retention.test.ts | 5 ++- .../terminal-webgl-hidden-retention.ts | 11 +++-- .../terminal-write-pipeline-health.ts | 33 ++++++++------- ...commit-cascade-store-write-samples.test.ts | 2 +- ...eact-commit-cascade-store-write-samples.ts | 26 ++++++------ .../src/lib/simulator-launch-coordination.ts | 5 ++- .../src/lib/state-collection-byte-estimate.ts | 12 ++++-- .../react-commit-cascade-write-probe.test.ts | 3 +- ...lient-host-reconciliation-protocol.test.ts | 5 ++- .../repro-7732-gitlab-job-id-dropped.test.ts | 2 +- 121 files changed, 593 insertions(+), 298 deletions(-) diff --git a/config/oxlint-anti-slop.json b/config/oxlint-anti-slop.json index bf41f269552..3a115684ba9 100644 --- a/config/oxlint-anti-slop.json +++ b/config/oxlint-anti-slop.json @@ -30,7 +30,7 @@ "anti-slop/no-conditional-empty-object-spread": "off", "anti-slop/no-known-value-widening": "off", "anti-slop/no-module-mocking": "error", - "anti-slop/no-object-parameters": "off", + "anti-slop/no-object-parameters": "error", "anti-slop/no-reduce-accumulator-copy": "error", "anti-slop/no-reflect-apply": "error", "anti-slop/no-reflect-get": "error", diff --git a/config/scripts/agent-status-hot-path-benchmark.test.ts b/config/scripts/agent-status-hot-path-benchmark.test.ts index 6c30b82ebf7..ece89b89d50 100644 --- a/config/scripts/agent-status-hot-path-benchmark.test.ts +++ b/config/scripts/agent-status-hot-path-benchmark.test.ts @@ -244,7 +244,11 @@ describe('agent-status hot path benchmark', () => { let objectAssignCalls = 0 let objectAssignPropertyCopies = 0 let freshnessEntryVisits = 0 - Object.assign = ((target: object, ...sources: object[]) => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: `Object.assign` is an overload set no single arrow can satisfy; this wrapper only counts calls and forwards every argument to the captured native implementation. + Object.assign = (( + target: Record, + ...sources: readonly Record[] + ) => { objectAssignCalls += 1 for (const source of sources) { if (source && typeof source === 'object') { @@ -253,7 +257,8 @@ describe('agent-status hot path benchmark', () => { } return nativeObjectAssign(target, ...sources) }) as typeof Object.assign - Object.values = ((value: object) => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: same overload-set limit as the `Object.assign` wrapper above; this one counts visited entries and returns the native result unchanged. + Object.values = ((value: Record) => { const result = nativeObjectValues(value) freshnessEntryVisits += result.length return result diff --git a/config/scripts/happy-dom-mutation-observer-retention.ts b/config/scripts/happy-dom-mutation-observer-retention.ts index a315b3d520c..c40a070bd58 100644 --- a/config/scripts/happy-dom-mutation-observer-retention.ts +++ b/config/scripts/happy-dom-mutation-observer-retention.ts @@ -48,12 +48,12 @@ export function installHappyDomMutationObserverRetention(): boolean { const disconnect = prototype.disconnect prototype.observe = function patchedObserve( - this: object, + this: PatchableMutationObserver, target: Node, options?: MutationObserverInit ): void { const existing = new Set(readMutationListeners(target)) - observe.call(this as unknown as PatchableMutationObserver, target, options) + observe.call(this, target, options) const pinned = retainedCallbacks.get(this) ?? new Set() for (const listener of readMutationListeners(target)) { if (existing.has(listener)) { @@ -69,8 +69,8 @@ export function installHappyDomMutationObserverRetention(): boolean { } } - prototype.disconnect = function patchedDisconnect(this: object): void { - disconnect.call(this as unknown as PatchableMutationObserver) + prototype.disconnect = function patchedDisconnect(this: PatchableMutationObserver): void { + disconnect.call(this) retainedCallbacks.delete(this) } diff --git a/mobile/src/diagnostics/connection-diagnostics-screen-data.ts b/mobile/src/diagnostics/connection-diagnostics-screen-data.ts index c8a2ed589c8..3ab974b46df 100644 --- a/mobile/src/diagnostics/connection-diagnostics-screen-data.ts +++ b/mobile/src/diagnostics/connection-diagnostics-screen-data.ts @@ -2,10 +2,13 @@ import type { ConnectionLogStore } from '../transport/connection-log-buffer' import type { ConnectionLogEntry, HostProfile } from '../transport/types' import type { RpcClientContextValue } from '../transport/rpc-client-context-contract' +/** Route identity token: compared by reference to detect navigating away and back, never read. */ +export type DiagnosticsRouteKey = Record + export type DiagnosticsHostSelection = { hostId: string requestedHostId: string | undefined - routeKey?: object + routeKey?: DiagnosticsRouteKey } export type DiagnosticsSubmissionState = 'sending' | 'sent' | 'failed' @@ -29,7 +32,7 @@ export function resolveDiagnosticsHostId( hosts: readonly HostProfile[], requestedHostId: string | undefined, manualSelection: DiagnosticsHostSelection | null, - routeKey?: object + routeKey?: DiagnosticsRouteKey ): string | null { const selected = manualSelection if (selected && selected.requestedHostId === requestedHostId && selected.routeKey === routeKey) { diff --git a/mobile/src/session/mobile-terminal-viewport-resubscribe.ts b/mobile/src/session/mobile-terminal-viewport-resubscribe.ts index 8ad3c99c13e..175eb9d92b8 100644 --- a/mobile/src/session/mobile-terminal-viewport-resubscribe.ts +++ b/mobile/src/session/mobile-terminal-viewport-resubscribe.ts @@ -79,6 +79,9 @@ export function shouldResubscribeAfterViewportMeasure(args: { return args.hostCols !== args.measured.cols || args.hostRows !== args.measured.rows } +/** Reference-identity token for a resubscribe attempt; carries no data, only compared by `===`. */ +type RetryGenerationToken = Readonly> + /** Per-handle resubscribe budget, mirroring the chat-side rearm bound: attempts * refill only when the handle actually left terminal.list and came back. A * still-listed non-converging handle re-funded on every list refresh would undo @@ -87,7 +90,7 @@ export class TerminalViewportResubscribeBudget { private readonly attemptsByHandle = new Map() private readonly absentSinceExhaustion = new Set() private readonly announcedExhaustion = new Set() - private readonly retryGenerationByHandle = new Map() + private readonly retryGenerationByHandle = new Map() attempts(handle: string): number { return this.attemptsByHandle.get(handle) ?? 0 @@ -97,17 +100,17 @@ export class TerminalViewportResubscribeBudget { this.attemptsByHandle.set(handle, this.attempts(handle) + 1) } - retryGeneration(handle: string): object { + retryGeneration(handle: string): RetryGenerationToken { const existing = this.retryGenerationByHandle.get(handle) if (existing) { return existing } - const generation = {} + const generation: RetryGenerationToken = {} this.retryGenerationByHandle.set(handle, generation) return generation } - isRetryGenerationCurrent(handle: string, generation: object): boolean { + isRetryGenerationCurrent(handle: string, generation: RetryGenerationToken): boolean { return this.retryGenerationByHandle.get(handle) === generation } diff --git a/mobile/src/transport/direct-rpc-client.ts b/mobile/src/transport/direct-rpc-client.ts index 0031de90c6c..b72c49bd1a7 100644 --- a/mobile/src/transport/direct-rpc-client.ts +++ b/mobile/src/transport/direct-rpc-client.ts @@ -72,7 +72,7 @@ export class DirectRpcClient implements RpcClient { }) this.liveness = new RpcSessionLivenessWatchdog({ transport: 'direct', - sendProbe: (identity) => this.sendLivenessProbe(identity), + sendProbe: (identity) => identity === this.livenessSession && this.sendLivenessProbe(), terminate: (identity) => { if (identity === this.livenessSession && this.socketSession === this.livenessSession) { this.socketClose.forceClose(this.livenessSession) @@ -297,8 +297,8 @@ export class DirectRpcClient implements RpcClient { return false } - private sendLivenessProbe(identity: object): boolean { - if (identity !== this.livenessSession || this.getState() !== 'connected') { + private sendLivenessProbe(): boolean { + if (this.getState() !== 'connected') { return false } return this.sendEncrypted({ diff --git a/mobile/src/transport/host-client-acquisition-registry.ts b/mobile/src/transport/host-client-acquisition-registry.ts index 4e09ebdd9dc..618a4be3c0a 100644 --- a/mobile/src/transport/host-client-acquisition-registry.ts +++ b/mobile/src/transport/host-client-acquisition-registry.ts @@ -1,4 +1,5 @@ -export type HostClientAcquisition = object +/** Holder identity token: the registry only compares references, never reads fields. */ +export type HostClientAcquisition = Record export class HostClientAcquisitionRegistry { private readonly acquisitions = new Map>() diff --git a/mobile/src/transport/relay-dial-stage.ts b/mobile/src/transport/relay-dial-stage.ts index c4a743f84f4..06c6e23477c 100644 --- a/mobile/src/transport/relay-dial-stage.ts +++ b/mobile/src/transport/relay-dial-stage.ts @@ -1,3 +1,5 @@ +import type { RpcClient } from './rpc-client' + // Where a relay dial is waiting, so a bound can tell "the cell never answered the // upgrade" from "the cell took the dial and is slow" — the two look identical from // ConnectionState, which stays 'connecting' until relay-hello arrives. @@ -17,12 +19,21 @@ export type RelayDialStageSource = { onDialStageChange(listener: (stage: RelayDialStage) => void): () => void } -export function relayDialStageSource(session: object): RelayDialStageSource | null { - const candidate = session as Partial - return typeof candidate.getDialStage === 'function' && - typeof candidate.onDialStageChange === 'function' - ? (candidate as RelayDialStageSource) - : null +/** An RPC client that may also report relay dial stages; only relay sessions do. */ +export type MaybeRelayDialStageSource = RpcClient & Partial + +function reportsDialStages( + session: MaybeRelayDialStageSource +): session is MaybeRelayDialStageSource & RelayDialStageSource { + return ( + typeof session.getDialStage === 'function' && typeof session.onDialStageChange === 'function' + ) +} + +export function relayDialStageSource( + session: MaybeRelayDialStageSource +): RelayDialStageSource | null { + return reportsDialStages(session) ? session : null } export class RelayDialStageTracker implements RelayDialStageSource { diff --git a/mobile/src/transport/rpc-session-liveness-watchdog.ts b/mobile/src/transport/rpc-session-liveness-watchdog.ts index 36525f60fb0..cbe891f810c 100644 --- a/mobile/src/transport/rpc-session-liveness-watchdog.ts +++ b/mobile/src/transport/rpc-session-liveness-watchdog.ts @@ -2,7 +2,10 @@ export const LIVENESS_IDLE_MS = 20_000 export const LIVENESS_PROBE_TIMEOUT_MS = 8_000 export const MISSED_PROBE_LIMIT = 3 -export type RpcSessionIdentity = object +declare const rpcSessionIdentityBrand: unique symbol + +/** Opaque per-session token; only ever compared by reference. */ +export type RpcSessionIdentity = object & { readonly [rpcSessionIdentityBrand]?: never } type WatchdogOptions = { transport: 'direct' | 'relay' diff --git a/src/main/ai-vault-search/session-search-clock.ts b/src/main/ai-vault-search/session-search-clock.ts index c9eaf609a34..3c973b273e6 100644 --- a/src/main/ai-vault-search/session-search-clock.ts +++ b/src/main/ai-vault-search/session-search-clock.ts @@ -2,8 +2,8 @@ // makes is "within one reconcile interval", and a guarantee stated in wall time // is only a claim until a test can advance the clock and watch it hold. -/** Opaque to the indexer; a fake clock hands back whatever it likes. */ -export type SessionSearchTimerHandle = object | number +/** Opaque to the indexer: the real clock hands back a timer, a fake clock an id. */ +export type SessionSearchTimerHandle = NodeJS.Timeout | number export type SessionSearchClock = { now(): number @@ -20,5 +20,5 @@ export const systemSessionSearchClock: SessionSearchClock = { timer.unref?.() return timer }, - clearTimeout: (handle) => clearTimeout(handle as NodeJS.Timeout) + clearTimeout: (handle) => clearTimeout(handle) } diff --git a/src/main/artifacts/artifact-cloud-recovery.test.ts b/src/main/artifacts/artifact-cloud-recovery.test.ts index fea3b73bda0..04eebbf25a8 100644 --- a/src/main/artifacts/artifact-cloud-recovery.test.ts +++ b/src/main/artifacts/artifact-cloud-recovery.test.ts @@ -207,7 +207,10 @@ class ArtifactFaultServer { rejectNextDeleteCode: string | null = null rejectNextUpdateStatus: number | null = null private readonly artifacts = new Map() - private readonly createsByKey = new Map() + private readonly createsByKey = new Map< + string, + { body: string; response: ArtifactResponseBody } + >() artifactSlugs(): string[] { return [...this.artifacts.keys()].sort() @@ -325,14 +328,17 @@ async function publishedLink(userDataPath: string): Promise { return result.status === 'ok' ? (result.value?.shareUrl ?? null) : null } -function jsonResponse(body: object, status: number): Response { +/** JSON payload the fake artifact API serialises for a response. */ +type ArtifactResponseBody = Record + +function jsonResponse(body: ArtifactResponseBody, status: number): Response { return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }) } -function createResponseBody(slug: string): object { +function createResponseBody(slug: string): ArtifactResponseBody { return { artifact: { version: 1, diff --git a/src/main/browser/agent-browser-bridge-test-harness.ts b/src/main/browser/agent-browser-bridge-test-harness.ts index 0e614600174..7aa38364a3c 100644 --- a/src/main/browser/agent-browser-bridge-test-harness.ts +++ b/src/main/browser/agent-browser-bridge-test-harness.ts @@ -1,4 +1,5 @@ import { vi, type Mock } from 'vitest' +import type { AgentBrowserBridge } from './agent-browser-bridge' import type { BrowserManager } from './browser-manager' export type ExecFileCallback = (error: unknown, stdout?: string, stderr?: string) => void @@ -95,15 +96,19 @@ export function mockWebContents( // Why: the bridge resolves webContents via dynamic require('electron').webContents.fromId // inside a try/catch. Override the private method to inject our mock. export function overrideBridgeWebContentsLookup( - bridgePrototype: object, + bridgePrototype: AgentBrowserBridge, webContentsFromIdMock: Mock ): void { - ;(bridgePrototype as { getWebContents: (id: number) => unknown }).getWebContents = function ( - id: number - ) { - const target = webContentsFromIdMock(id) as { isDestroyed: () => boolean } | null - return target && !target.isDestroyed() ? target : null - } + // Why defineProperty: getWebContents is protected, so a typed assignment is not expressible. + Object.defineProperty(bridgePrototype, 'getWebContents', { + configurable: true, + enumerable: true, + writable: true, + value: function (id: number) { + const target = webContentsFromIdMock(id) as { isDestroyed: () => boolean } | null + return target && !target.isDestroyed() ? target : null + } + }) } export function createSucceedWith(execFileMock: Mock, stdinWrites: string[]) { diff --git a/src/main/browser/browser-cookie-import-clear.ts b/src/main/browser/browser-cookie-import-clear.ts index af79c4249ed..b1b04a98ff9 100644 --- a/src/main/browser/browser-cookie-import-clear.ts +++ b/src/main/browser/browser-cookie-import-clear.ts @@ -54,7 +54,13 @@ export type CookieClearSession = { restoreClearIdentities: CookieClearStore['restoreClearIdentities'] } -const mutationLocks = new WeakMap>() +/** + * Reference identity of one live cookie jar — the partition's Electron Session on both import + * paths. Held weakly and compared by reference; the lock never reads a field off it. + */ +export type CookieMutationLockOwner = WeakKey + +const mutationLocks = new WeakMap>() function cookieClearKey(url: string, name: string): string { return JSON.stringify([url, name]) @@ -85,7 +91,9 @@ export function identitiesFromClearCookies( * remove cookies the newer import already reported as written. Callers that need the lock across a * try/finally take it directly; callers with a single callback use the wrapper below. */ -export async function acquireCookieMutationLock(owner: object): Promise<() => void> { +export async function acquireCookieMutationLock( + owner: CookieMutationLockOwner +): Promise<() => void> { const previous = mutationLocks.get(owner) ?? Promise.resolve() let release!: () => void const current = new Promise((resolve) => { @@ -99,7 +107,10 @@ export async function acquireCookieMutationLock(owner: object): Promise<() => vo return release } -export async function withCookieMutationLock(owner: object, run: () => Promise): Promise { +export async function withCookieMutationLock( + owner: CookieMutationLockOwner, + run: () => Promise +): Promise { const release = await acquireCookieMutationLock(owner) try { return await run() diff --git a/src/main/browser/browser-cookie-import-concurrency.test.ts b/src/main/browser/browser-cookie-import-concurrency.test.ts index 776ded2f7e7..2826cc1207c 100644 --- a/src/main/browser/browser-cookie-import-concurrency.test.ts +++ b/src/main/browser/browser-cookie-import-concurrency.test.ts @@ -2,6 +2,7 @@ import { copyFileSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { CookiesGetFilter } from 'electron' const { appGetPathMock, @@ -33,11 +34,11 @@ vi.mock('electron', () => ({ vi.mock('./browser-cookie-clear-store', () => ({ openCookieClearStore: (targetSession: { cookies: { - get: (filter: object) => Promise + get: (filter: CookiesGetFilter) => Promise remove: (url: string, name: string) => Promise } }) => ({ - get: (filter: object) => targetSession.cookies.get(filter), + get: (filter: CookiesGetFilter) => targetSession.cookies.get(filter), remove: (url: string, name: string) => targetSession.cookies.remove(url, name), snapshotClearIdentities: snapshotClearIdentitiesMock, restoreClearIdentities: async () => undefined, diff --git a/src/main/browser/browser-cookie-import-google-exclusion.test.ts b/src/main/browser/browser-cookie-import-google-exclusion.test.ts index 1cc13450cff..ee73dba1ed2 100644 --- a/src/main/browser/browser-cookie-import-google-exclusion.test.ts +++ b/src/main/browser/browser-cookie-import-google-exclusion.test.ts @@ -3,6 +3,7 @@ * path. Removing 'google.com' from NON_TRANSPLANTABLE_DOMAINS flips every test here red. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { CookiesGetFilter } from 'electron' const { appGetPathMock, @@ -33,12 +34,12 @@ vi.mock('electron', () => ({ vi.mock('./browser-cookie-clear-store', () => ({ openCookieClearStore: (targetSession: { cookies: { - get: (filter: object) => Promise + get: (filter: CookiesGetFilter) => Promise remove: (url: string, name: string) => Promise set?: (details: Record) => Promise } }) => ({ - get: (filter: object) => targetSession.cookies.get(filter), + get: (filter: CookiesGetFilter) => targetSession.cookies.get(filter), remove: (url: string, name: string) => targetSession.cookies.remove(url, name), // Why (STA-4300): the import writes go through CDP identities; route them to the same spy so // a missing method cannot silently reroute every write down the rejected-cookie path. diff --git a/src/main/browser/browser-cookie-import-partition-fidelity.test.ts b/src/main/browser/browser-cookie-import-partition-fidelity.test.ts index d7c6401eb42..f6fe9cedf6d 100644 --- a/src/main/browser/browser-cookie-import-partition-fidelity.test.ts +++ b/src/main/browser/browser-cookie-import-partition-fidelity.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as NodeFs from 'node:fs' +import type { CookiesGetFilter } from 'electron' const { appGetPathMock, @@ -45,11 +46,11 @@ vi.mock('electron', () => ({ vi.mock('./browser-cookie-clear-store', () => ({ openCookieClearStore: (targetSession: { cookies: { - get: (filter: object) => Promise + get: (filter: CookiesGetFilter) => Promise remove: (url: string, name: string) => Promise } }) => ({ - get: (filter: object) => targetSession.cookies.get(filter), + get: (filter: CookiesGetFilter) => targetSession.cookies.get(filter), remove: (url: string, name: string) => targetSession.cookies.remove(url, name), snapshotClearIdentities: async (items: { cookie: Record; url: string }[]) => items.map(({ cookie, url }) => ({ url, ...cookie })), diff --git a/src/main/browser/browser-cookie-import-replacement.test.ts b/src/main/browser/browser-cookie-import-replacement.test.ts index d645bf944ce..cf7c0795010 100644 --- a/src/main/browser/browser-cookie-import-replacement.test.ts +++ b/src/main/browser/browser-cookie-import-replacement.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { CookiesGetFilter } from 'electron' const { appGetPathMock, @@ -36,12 +37,12 @@ vi.mock('electron', () => ({ vi.mock('./browser-cookie-clear-store', () => ({ openCookieClearStore: (targetSession: { cookies: { - get: (filter: object) => Promise + get: (filter: CookiesGetFilter) => Promise remove: (url: string, name: string) => Promise set?: (details: Record) => Promise } }) => ({ - get: (filter: object) => targetSession.cookies.get(filter), + get: (filter: CookiesGetFilter) => targetSession.cookies.get(filter), remove: (url: string, name: string) => targetSession.cookies.remove(url, name), // Why (STA-4300): the import writes go through CDP identities; route them to the same spy so // a missing method cannot silently reroute every write down the rejected-cookie path. diff --git a/src/main/browser/browser-cookie-import-route-partition-staging.test.ts b/src/main/browser/browser-cookie-import-route-partition-staging.test.ts index 106b6742e80..b56b47ca7cc 100644 --- a/src/main/browser/browser-cookie-import-route-partition-staging.test.ts +++ b/src/main/browser/browser-cookie-import-route-partition-staging.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { CookiesGetFilter } from 'electron' import type * as NodeFs from 'node:fs' const { @@ -43,11 +44,11 @@ vi.mock('electron', () => ({ vi.mock('./browser-cookie-clear-store', () => ({ openCookieClearStore: (targetSession: { cookies: { - get: (filter: object) => Promise + get: (filter: CookiesGetFilter) => Promise remove: (url: string, name: string) => Promise } }) => ({ - get: (filter: object) => targetSession.cookies.get(filter), + get: (filter: CookiesGetFilter) => targetSession.cookies.get(filter), remove: (url: string, name: string) => targetSession.cookies.remove(url, name), snapshotClearIdentities: async (items: { cookie: Record; url: string }[]) => items.map(({ cookie, url }) => ({ url, ...cookie })), diff --git a/src/main/browser/browser-cookie-import-scope.test.ts b/src/main/browser/browser-cookie-import-scope.test.ts index 33381f915d6..e64b33d94f1 100644 --- a/src/main/browser/browser-cookie-import-scope.test.ts +++ b/src/main/browser/browser-cookie-import-scope.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as NodeFs from 'node:fs' +import type { CookiesGetFilter } from 'electron' const { appGetPathMock, @@ -34,11 +35,11 @@ vi.mock('electron', () => ({ vi.mock('./browser-cookie-clear-store', () => ({ openCookieClearStore: (targetSession: { cookies: { - get: (filter: object) => Promise + get: (filter: CookiesGetFilter) => Promise remove: (url: string, name: string) => Promise } }) => ({ - get: (filter: object) => targetSession.cookies.get(filter), + get: (filter: CookiesGetFilter) => targetSession.cookies.get(filter), remove: (url: string, name: string) => targetSession.cookies.remove(url, name), snapshotClearIdentities: async (items: { cookie: Record; url: string }[]) => items.map(({ cookie, url }) => ({ url, ...cookie })), diff --git a/src/main/browser/browser-cookie-import-undecryptable.test.ts b/src/main/browser/browser-cookie-import-undecryptable.test.ts index 95714b36d9e..c17615df22f 100644 --- a/src/main/browser/browser-cookie-import-undecryptable.test.ts +++ b/src/main/browser/browser-cookie-import-undecryptable.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as NodeCrypto from 'node:crypto' import type * as NodeFs from 'node:fs' +import type { CookiesGetFilter } from 'electron' const { appGetPathMock, @@ -44,11 +45,11 @@ vi.mock('electron', () => ({ vi.mock('./browser-cookie-clear-store', () => ({ openCookieClearStore: (targetSession: { cookies: { - get: (filter: object) => Promise + get: (filter: CookiesGetFilter) => Promise remove: (url: string, name: string) => Promise } }) => ({ - get: (filter: object) => targetSession.cookies.get(filter), + get: (filter: CookiesGetFilter) => targetSession.cookies.get(filter), remove: (url: string, name: string) => targetSession.cookies.remove(url, name), snapshotClearIdentities: async (items: { cookie: Record; url: string }[]) => items.map(({ cookie, url }) => ({ url, ...cookie })), diff --git a/src/main/browser/browser-cookie-import.test.ts b/src/main/browser/browser-cookie-import.test.ts index 52662b2366d..32bd17d4018 100644 --- a/src/main/browser/browser-cookie-import.test.ts +++ b/src/main/browser/browser-cookie-import.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { CookiesGetFilter } from 'electron' import type * as NodeFs from 'node:fs' const { @@ -53,11 +54,11 @@ vi.mock('electron', () => ({ vi.mock('./browser-cookie-clear-store', () => ({ openCookieClearStore: (targetSession: { cookies: { - get: (filter: object) => Promise + get: (filter: CookiesGetFilter) => Promise remove: (url: string, name: string) => Promise } }) => ({ - get: (filter: object) => targetSession.cookies.get(filter), + get: (filter: CookiesGetFilter) => targetSession.cookies.get(filter), remove: (url: string, name: string) => targetSession.cookies.remove(url, name), snapshotClearIdentities: async (items: { cookie: Record; url: string }[]) => items.map(({ cookie, url }) => ({ url, ...cookie })), diff --git a/src/main/browser/cdp-keyboard-us-layout.test.ts b/src/main/browser/cdp-keyboard-us-layout.test.ts index b9bcffa50ec..30cfd6264f8 100644 --- a/src/main/browser/cdp-keyboard-us-layout.test.ts +++ b/src/main/browser/cdp-keyboard-us-layout.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { imeFallbackKeyEvent, parseCdpKeyEvent } from './cdp-keyboard-us-layout' +import { imeFallbackKeyEvent, parseCdpKeyEvent, type CdpKeyEvent } from './cdp-keyboard-us-layout' describe('parseCdpKeyEvent', () => { it('maps every printable ASCII character to a key event that types that character', () => { @@ -39,7 +39,7 @@ describe('parseCdpKeyEvent', () => { ['Ctrl+Shift+K', { keyCode: 75, key: 'K', modifiers: 10, text: null }], ['Meta+r', { keyCode: 82, key: 'r', modifiers: 4, text: null }], ['Control+Shift+r', { keyCode: 82, key: 'R', modifiers: 10, text: null }] - ])('parses the shortcut %s', (raw: string, expected: object) => { + ])('parses the shortcut %s', (raw: string, expected: Partial) => { expect(parseCdpKeyEvent(raw)).toMatchObject(expected) }) @@ -66,7 +66,7 @@ describe('parseCdpKeyEvent', () => { ['ContextMenu', { keyCode: 93, text: null }], ['F5', { keyCode: 116, key: 'F5', code: 'F5', text: null }], ['F12', { keyCode: 123, text: null }] - ])('parses the named key %s', (raw: string, expected: object) => { + ])('parses the named key %s', (raw: string, expected: Partial) => { expect(parseCdpKeyEvent(raw)).toMatchObject(expected) }) @@ -77,7 +77,7 @@ describe('parseCdpKeyEvent', () => { ['Meta', { keyCode: 91, key: 'Meta', code: 'MetaLeft', modifiers: 4, selfModifier: 4 }] ])( 'reports the own modifier bit and left-side location for a bare %s press', - (raw: string, expected: object) => { + (raw: string, expected: Partial) => { expect(parseCdpKeyEvent(raw)).toMatchObject({ ...expected, location: 1, text: null }) } ) diff --git a/src/main/browser/doc-preview-download-block-notice.test.ts b/src/main/browser/doc-preview-download-block-notice.test.ts index c57998ea4e5..8cd45d436cc 100644 --- a/src/main/browser/doc-preview-download-block-notice.test.ts +++ b/src/main/browser/doc-preview-download-block-notice.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ publishDocPreviewFailure: vi.fn(), - boundGrantIdByGuest: new Map(), + boundGrantIdByGuest: new Map(), revocationListener: null as null | ((grant: { id: string }) => void) })) @@ -10,7 +10,8 @@ vi.mock('./doc-preview-failure-notice', () => ({ publishDocPreviewFailure: mocks.publishDocPreviewFailure })) vi.mock('./doc-preview-guest-policy', () => ({ - readDocPreviewGuestBoundGrantId: (guest: object) => mocks.boundGrantIdByGuest.get(guest) ?? null + readDocPreviewGuestBoundGrantId: (guest: Electron.WebContents) => + mocks.boundGrantIdByGuest.get(guest) ?? null })) vi.mock('./doc-preview-grant-registry', () => ({ onDocPreviewGrantRevoked: (listener: (grant: { id: string }) => void) => { diff --git a/src/main/codex-accounts/managed-codex-auth-readiness.test.ts b/src/main/codex-accounts/managed-codex-auth-readiness.test.ts index d65a9344acc..e6354826366 100644 --- a/src/main/codex-accounts/managed-codex-auth-readiness.test.ts +++ b/src/main/codex-accounts/managed-codex-auth-readiness.test.ts @@ -263,6 +263,6 @@ function createFixture(): { } } -function writeAuth(home: string, auth: object): void { +function writeAuth(home: string, auth: Record): void { writeFileSync(join(home, 'auth.json'), JSON.stringify(auth), { mode: 0o600 }) } diff --git a/src/main/codex/codex-session-migration-scheduler.ts b/src/main/codex/codex-session-migration-scheduler.ts index d0f696031ae..879a63817db 100644 --- a/src/main/codex/codex-session-migration-scheduler.ts +++ b/src/main/codex/codex-session-migration-scheduler.ts @@ -253,12 +253,21 @@ export function createCodexSessionMigrationScheduler(args: { } } +type MigrationFailureCountKey = 'failedDirectories' | 'failedFiles' | 'failedHealAuditRecords' + +/** The run-result fields the scheduler consults; each runner returns its own summary shape. */ +type MigrationResultFields = Partial> + +function isMigrationResultFields(result: unknown): result is MigrationResultFields { + return typeof result === 'object' && result !== null +} + function isStoppedMigrationResult(result: unknown): boolean { return Boolean(result && typeof result === 'object' && 'stopped' in result && result.stopped) } function isIncompleteBackfillResult(result: unknown): boolean { - if (!result || typeof result !== 'object') { + if (!isMigrationResultFields(result)) { return true } return ( @@ -269,7 +278,10 @@ function isIncompleteBackfillResult(result: unknown): boolean { ) } -function readPositiveResultCount(result: object, key: string): boolean { - const value = key in result ? (result as Record)[key] : undefined +function readPositiveResultCount( + result: MigrationResultFields, + key: MigrationFailureCountKey +): boolean { + const value = result[key] return typeof value === 'number' && value > 0 } diff --git a/src/main/daemon/daemon-pty-adapter-history-recovery.test.ts b/src/main/daemon/daemon-pty-adapter-history-recovery.test.ts index 820f318d6a8..4703f11300b 100644 --- a/src/main/daemon/daemon-pty-adapter-history-recovery.test.ts +++ b/src/main/daemon/daemon-pty-adapter-history-recovery.test.ts @@ -235,12 +235,16 @@ describe('DaemonPtyAdapter history recovery', () => { ).id ) ) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: `checkpointSessions` and `runExclusiveCheckpoint` are `protected` on the checkpoint scheduler, so they are absent from the adapter's public type; the shape below mirrors their declarations and this suite only spies on them. const internals = historyAdapter as unknown as { checkpointSessions( sessionIds: Iterable, opts?: { final?: boolean; teardown?: boolean } ): Promise> - runExclusiveCheckpoint(operation: () => Promise, options?: object): Promise + runExclusiveCheckpoint( + operation: () => Promise, + options?: { rescheduleDirty?: boolean; callerDeadlineMs?: number } + ): Promise } const originalCheckpointSessions = internals.checkpointSessions.bind(historyAdapter) // Call-through spy: entering the exclusive gate is the observable "queued behind the in-flight checkpoint" moment. diff --git a/src/main/git/git-capability-state.test.ts b/src/main/git/git-capability-state.test.ts index b6e655efe62..845c2f47bf8 100644 --- a/src/main/git/git-capability-state.test.ts +++ b/src/main/git/git-capability-state.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { SshGitProvider } from '../providers/ssh-git-provider' import { clearGitCapabilityStateForTests, getLocalGitCapabilityCache, @@ -10,6 +11,9 @@ import { seedWslLinkedWorktreeGitRoutingForTests } from './wsl-linked-worktree-git-routing' +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the cache keys providers by reference only and never calls a method on them. +const createProviderIdentity = (): SshGitProvider => ({}) as SshGitProvider + describe('Git capability execution-host state', () => { beforeEach(() => { clearGitCapabilityStateForTests() @@ -32,8 +36,8 @@ describe('Git capability execution-host state', () => { }) it('shares one SSH provider lifetime without leaking into a replacement provider', () => { - const provider = {} - const replacementProvider = {} + const provider = createProviderIdentity() + const replacementProvider = createProviderIdentity() expect(getSshGitCapabilityCache(provider)).toBe(getSshGitCapabilityCache(provider)) expect(getSshGitCapabilityCache(provider)).not.toBe( diff --git a/src/main/git/git-capability-state.ts b/src/main/git/git-capability-state.ts index 7721df722c8..d998c21ee75 100644 --- a/src/main/git/git-capability-state.ts +++ b/src/main/git/git-capability-state.ts @@ -1,4 +1,5 @@ import { GitCapabilityCache } from '../../shared/git-capability-cache' +import type { SshGitProvider } from '../providers/ssh-git-provider' import { parseWslUncPath } from '../../shared/wsl-paths' import { isWslLinkedWorktreeGitRoutingCandidate, @@ -14,7 +15,7 @@ type LocalGitCapabilityTarget = { const localCapabilitiesByExecutionHost = new Map() // Why: reconnecting creates a new provider, while concurrent IPC/runtime users // of one SSH connection must share the same remote Git capability results. -let sshCapabilitiesByProvider = new WeakMap() +let sshCapabilitiesByProvider = new WeakMap() function getLocalGitExecutionHostKey(target: LocalGitCapabilityTarget): string { const wslDistro = @@ -56,7 +57,7 @@ export function withLocalGitCapabilityCacheForExecution( ) } -export function getSshGitCapabilityCache(provider: object): GitCapabilityCache { +export function getSshGitCapabilityCache(provider: SshGitProvider): GitCapabilityCache { let cache = sshCapabilitiesByProvider.get(provider) if (!cache) { cache = new GitCapabilityCache() diff --git a/src/main/ipc/browser-preview-tool-authorization.test.ts b/src/main/ipc/browser-preview-tool-authorization.test.ts index 0b09f2e0ead..58d72ded647 100644 --- a/src/main/ipc/browser-preview-tool-authorization.test.ts +++ b/src/main/ipc/browser-preview-tool-authorization.test.ts @@ -179,6 +179,12 @@ function grantForNewDocPage(): { id: string; browserPageId: string } { return { id: grant.id, browserPageId } } +/** The fake WebContents a preview's policy installs onto; tools are matched against its identity. */ +type PreviewGuestContents = { + isDestroyed: () => boolean + getURL: () => string +} + /** A preview guest already showing its document, which is the only state a tool can act in. */ function renderPreviewForGrant( grant: { id: string; browserPageId: string }, @@ -186,7 +192,7 @@ function renderPreviewForGrant( ): { grantId: string browserPageId: string - contents: object + contents: PreviewGuestContents markContentsDestroyed: () => void } { const browserPageId = grant.browserPageId @@ -250,7 +256,7 @@ function toolArgs(channel: string, browserPageId: string): Record ({ } })) -import { registerBrowserHandlers, setAgentBrowserBridgeRef } from './browser' +import { registerBrowserHandlers, setAgentBrowserBridgeRef, type BrowserGuestArgs } from './browser' import { waitForAnyTabRegistration, waitForTabRegistration, @@ -136,9 +136,10 @@ describe('registerBrowserHandlers', () => { registerGuestMock.mockReturnValue(false) const settled = Promise.allSettled([waitForTabRegistration('page-1', 1000)]) registerBrowserHandlers() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: ipcMain.handle's mock records handlers as a loose tuple; this is the signature registerBrowserHandlers registered for this channel. const registerHandler = handleMock.mock.calls.find( ([channel]) => channel === 'browser:registerGuest' - )?.[1] as (event: { sender: Electron.WebContents }, args: object) => boolean + )?.[1] as (event: { sender: Electron.WebContents }, args: BrowserGuestArgs) => boolean const result = registerHandler( { diff --git a/src/main/ipc/browser.ts b/src/main/ipc/browser.ts index d9aa0409c08..d9841cf3bd1 100644 --- a/src/main/ipc/browser.ts +++ b/src/main/ipc/browser.ts @@ -24,7 +24,7 @@ import type { BrowserWebAuthnAccountResponse } from '../../shared/browser-webaut let agentBrowserBridgeRef: AgentBrowserBridge | null = null -type BrowserGuestRegistrationArgs = { +export type BrowserGuestArgs = { browserPageId: string workspaceId: string worktreeId: string @@ -48,7 +48,7 @@ export function registerBrowserHandlers(): void { const registerGuest = ( event: Electron.IpcMainInvokeEvent, - args: BrowserGuestRegistrationArgs, + args: BrowserGuestArgs, repairPolicies: boolean ): boolean => { if (!isTrustedBrowserRenderer(event.sender)) { @@ -96,7 +96,7 @@ export function registerBrowserHandlers(): void { return true } - ipcMain.handle('browser:registerGuest', (event, args: BrowserGuestRegistrationArgs) => + ipcMain.handle('browser:registerGuest', (event, args: BrowserGuestArgs) => registerGuest(event, args, false) ) @@ -136,7 +136,7 @@ export function registerBrowserHandlers(): void { } ) - ipcMain.handle('browser:repairGuestRegistration', (event, args: BrowserGuestRegistrationArgs) => + ipcMain.handle('browser:repairGuestRegistration', (event, args: BrowserGuestArgs) => registerGuest(event, args, true) ) diff --git a/src/main/ipc/filesystem-test-harness.ts b/src/main/ipc/filesystem-test-harness.ts index 47efa88d6cd..a3a06b95e85 100644 --- a/src/main/ipc/filesystem-test-harness.ts +++ b/src/main/ipc/filesystem-test-harness.ts @@ -207,12 +207,16 @@ export async function withPlatform( } } -function collectMocks(moduleMock: object): IpcMock[] { +function isMockContainer(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function collectMocks(moduleMock: Record): IpcMock[] { return Object.values(moduleMock).flatMap((value) => { if (vi.isMockFunction(value)) { return [value as IpcMock] } - return value && typeof value === 'object' ? collectMocks(value) : [] + return isMockContainer(value) ? collectMocks(value) : [] }) } diff --git a/src/main/ipc/runtime-watcher-process-pool.test.ts b/src/main/ipc/runtime-watcher-process-pool.test.ts index c722b5ccdd3..5b326191d03 100644 --- a/src/main/ipc/runtime-watcher-process-pool.test.ts +++ b/src/main/ipc/runtime-watcher-process-pool.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { WatcherProcessFailure } from './parcel-watcher-process-failure' +import type { WatcherProcessSubscribeOptions } from './parcel-watcher-process-protocol' import type { WatcherProcessCallback, WatcherProcessHooks, @@ -29,7 +30,7 @@ class FakeSupervisor { async subscribe( dir: string, _callback: WatcherProcessCallback, - _opts: object, + _opts: WatcherProcessSubscribeOptions, hooks: WatcherProcessHooks ): Promise { if (this.subscribeError) { diff --git a/src/main/ipc/settings.test.ts b/src/main/ipc/settings.test.ts index 31d587bd056..e2a27ad6aab 100644 --- a/src/main/ipc/settings.test.ts +++ b/src/main/ipc/settings.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi, beforeEach } from 'vitest' +import type { GlobalSettings } from '../../shared/global-settings-types' const { applyAppIconMock, @@ -840,7 +841,10 @@ describe('registerSettingsHandlers', () => { it('normalizes an agent-session-search write and hands the change to the index', async () => { const before = { aiVaultSearch: { enabled: false, historyDays: null } } store.getSettings.mockReturnValue(before) - store.updateSettings.mockImplementation((args: object) => ({ ...before, ...args })) + store.updateSettings.mockImplementation((args: Partial) => ({ + ...before, + ...args + })) registerSettingsHandlers(store as never) const handler = handleMock.mock.calls.find((call) => call[0] === 'settings:set')?.[1] as ( event: typeof settingsInvokeEvent, diff --git a/src/main/ipc/worktrees-authoritative-local-metadata-pruning.test.ts b/src/main/ipc/worktrees-authoritative-local-metadata-pruning.test.ts index aa3fff56f77..1bd306c5c2d 100644 --- a/src/main/ipc/worktrees-authoritative-local-metadata-pruning.test.ts +++ b/src/main/ipc/worktrees-authoritative-local-metadata-pruning.test.ts @@ -107,7 +107,7 @@ vi.mock('./pty', async () => (await import('./worktrees-test-module-mocks')).pty const REPO_ID = 'repo-1' const REPO_PATH = '/workspace/repo' -const LOCAL_HOST_ID = 'local' +const LOCAL_HOST_ID = 'local' as const function worktree(path: string, overrides: Partial = {}): GitWorktreeInfo { return { diff --git a/src/main/ipc/worktrees-lineage-hydration.test.ts b/src/main/ipc/worktrees-lineage-hydration.test.ts index e109a13848f..79ece2cc369 100644 --- a/src/main/ipc/worktrees-lineage-hydration.test.ts +++ b/src/main/ipc/worktrees-lineage-hydration.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { WorktreeMeta } from '../../shared/worktree/meta-types' import type { Worktree } from '../../shared/worktree/types' import { toSshExecutionHostId } from '../../shared/execution-host' import { LINEAGE_HYDRATION_TIMEOUT_MS } from './worktrees/metadata/host-lineage-listing' @@ -390,7 +391,7 @@ describe('registerWorktreeHandlers', () => { [childId]: { instanceId: 'child-instance' } } store.getWorktreeMeta.mockImplementation((id: string) => metaById[id]) - store.setWorktreeMeta.mockImplementation((id: string, updates: object) => ({ + store.setWorktreeMeta.mockImplementation((id: string, updates: Partial) => ({ ...metaById[id], ...updates })) diff --git a/src/main/ipc/worktrees-test-ipc-surface.ts b/src/main/ipc/worktrees-test-ipc-surface.ts index a858aacf6b8..7df4f6cda0a 100644 --- a/src/main/ipc/worktrees-test-ipc-surface.ts +++ b/src/main/ipc/worktrees-test-ipc-surface.ts @@ -1,4 +1,5 @@ import { type Mock, vi } from 'vitest' +import type { WorktreeMeta } from '../../shared/worktree/meta-types' export type HandlerMap = Record unknown> @@ -7,7 +8,7 @@ type StoreMock = Mock<(...args: unknown[]) => unknown> /** Store lookups tests re-implement per id, so the first arg stays narrowed. */ type KeyedStoreMock = Mock<(id: string, ...rest: unknown[]) => unknown> /** Store writers tests re-implement by merging the patch they receive. */ -type KeyedStoreWriteMock = Mock<(id: string, patch: object) => unknown> +type KeyedStoreWriteMock = Mock<(id: string, patch: Partial) => unknown> export type TestMainWindow = { isDestroyed: () => boolean diff --git a/src/main/native-chat/wsl-transcript-fs-process-dispatch.ts b/src/main/native-chat/wsl-transcript-fs-process-dispatch.ts index fda412aa0fc..f601ebe0f11 100644 --- a/src/main/native-chat/wsl-transcript-fs-process-dispatch.ts +++ b/src/main/native-chat/wsl-transcript-fs-process-dispatch.ts @@ -86,7 +86,7 @@ export function closeWslTranscriptFsProcess(handle: WslTranscriptFsProcessHandle } export function isWslTranscriptFsProcessHandle( - value: object + value: FileHandle | WslTranscriptFsProcessHandle ): value is WslTranscriptFsProcessHandle { return 'wslTranscriptFsProcessHandle' in value } diff --git a/src/main/network/electron-proxy-credentials.ts b/src/main/network/electron-proxy-credentials.ts index 43a93659474..d9ff26eb940 100644 --- a/src/main/network/electron-proxy-credentials.ts +++ b/src/main/network/electron-proxy-credentials.ts @@ -1,4 +1,5 @@ import { normalizeProxyUrl } from '../../shared/network-proxy' +import type { ProxySession } from './electron-default-proxy-session' export type ElectronProxyCredentials = { host: string @@ -20,7 +21,7 @@ const DEFAULT_PROXY_PORTS: Record = { 'socks5:': 1080 } -let proxyCredentialsBySession = new WeakMap() +let proxyCredentialsBySession = new WeakMap() function decodeProxyCredential(value: string): string { try { @@ -64,7 +65,7 @@ export function haveSameElectronProxyCredentials( } export function setElectronProxyCredentialsForSession( - proxySession: object, + proxySession: ProxySession, credentials: ElectronProxyCredentials | null ): void { if (credentials) { @@ -74,11 +75,11 @@ export function setElectronProxyCredentialsForSession( } } -export function clearElectronProxyCredentialsForSession(proxySession: object): void { +export function clearElectronProxyCredentialsForSession(proxySession: ProxySession): void { proxyCredentialsBySession.delete(proxySession) } -export function resetElectronProxyCredentialsForTests(proxySession?: object): void { +export function resetElectronProxyCredentialsForTests(proxySession?: ProxySession): void { if (proxySession) { clearElectronProxyCredentialsForSession(proxySession) } else { @@ -88,11 +89,11 @@ export function resetElectronProxyCredentialsForTests(proxySession?: object): vo export function handleElectronProxyLogin( event: { preventDefault(): void }, - webContents: { session: object } | null, + webContents: { session: ProxySession } | null, _authenticationResponseDetails: unknown, authInfo: { isProxy: boolean; host: string; port: number; scheme?: string; realm?: string }, callback: (username?: string, password?: string) => void, - defaultProxySession?: object + defaultProxySession?: ProxySession ): void { if (!authInfo.isProxy) { return diff --git a/src/main/opencode/hook-plugin-fail-open-ownership.test.ts b/src/main/opencode/hook-plugin-fail-open-ownership.test.ts index 9f48059604f..6262768da4d 100644 --- a/src/main/opencode/hook-plugin-fail-open-ownership.test.ts +++ b/src/main/opencode/hook-plugin-fail-open-ownership.test.ts @@ -19,6 +19,10 @@ vi.mock('electron', () => ({ import { _internals } from './hook-service' type SessionFixture = { id: string; parentID?: string } +/** The session half of the SDK client, as the plugin's ancestry lookup uses it. */ +type SessionClientFixture = { + list: (options?: { signal?: AbortSignal }) => Promise<{ data: SessionFixture[] }> +} type PluginEvent = { type: string; properties?: Record } type PluginEventHandler = (input: { event: PluginEvent }) => Promise type PluginHooks = { event: PluginEventHandler; dispose?: () => Promise } @@ -83,7 +87,7 @@ describe('OpenCode plugin fail-open ownership', () => { return loadHooksWithSession({ list }) } - async function loadHooksWithSession(session: object): Promise { + async function loadHooksWithSession(session: SessionClientFixture): Promise { return loadHooksWithContext({ client: { session } }) } diff --git a/src/main/opencode/hook-plugin-lifecycle-delivery.test.ts b/src/main/opencode/hook-plugin-lifecycle-delivery.test.ts index 9fc69c8c78d..83a71533f1c 100644 --- a/src/main/opencode/hook-plugin-lifecycle-delivery.test.ts +++ b/src/main/opencode/hook-plugin-lifecycle-delivery.test.ts @@ -19,6 +19,12 @@ vi.mock('electron', () => ({ import { _internals } from './hook-service' type SessionFixture = { id: string; parentID?: string } + +/** The plugin probes both SDK call conventions — current `(parameters, options)` and legacy + * single-options — so fixtures for one session-client method differ in arity. */ +type SessionClientCall = (...args: never[]) => Promise<{ data: SessionFixture[] }> + +type SessionClientFixture = { list: SessionClientCall; get?: SessionClientCall } type PluginEvent = { type: string; properties?: Record } type PluginEventHandler = (input: { event: PluginEvent }) => Promise type PluginHooks = { event: PluginEventHandler; dispose?: () => Promise } @@ -83,7 +89,7 @@ describe('OpenCode plugin lifecycle delivery', () => { return loadHooksWithSession({ list }) } - async function loadHooksWithSession(session: object): Promise { + async function loadHooksWithSession(session: SessionClientFixture): Promise { const pluginPath = join(tempDir, 'orca-opencode-status.mjs') writeFileSync(pluginPath, _internals.getOpenCodePluginSource()) const module = (await import(pathToFileURL(pluginPath).href)) as { diff --git a/src/main/persistence/loading-store/automation-persistence.ts b/src/main/persistence/loading-store/automation-persistence.ts index 9f33f508fd9..2bed89ff09b 100644 --- a/src/main/persistence/loading-store/automation-persistence.ts +++ b/src/main/persistence/loading-store/automation-persistence.ts @@ -243,7 +243,7 @@ export function getAutomationRunWorkspaceDisplayName( } export function installAutomationPersistenceContext( - target: object, + target: AutomationPersistence, source: AutomationPersistence ): void { Object.defineProperty(target, automationPersistenceContext, { diff --git a/src/main/persistence/loading-store/metadata-lineage-operations.ts b/src/main/persistence/loading-store/metadata-lineage-operations.ts index 4b0a301fe26..2532ff3b2d3 100644 --- a/src/main/persistence/loading-store/metadata-lineage-operations.ts +++ b/src/main/persistence/loading-store/metadata-lineage-operations.ts @@ -316,7 +316,7 @@ export function removeWorkspaceLineageForFolderParent( } export function installMetadataLineageOperationsContext( - target: object, + target: MetadataLineageOperations, source: MetadataLineageOperations ): void { Object.defineProperty(target, metadataLineageOperationsContext, { diff --git a/src/main/persistence/loading-store/mobile-tab-selection-persistence.ts b/src/main/persistence/loading-store/mobile-tab-selection-persistence.ts index 321baad1aaf..8f0428c46bb 100644 --- a/src/main/persistence/loading-store/mobile-tab-selection-persistence.ts +++ b/src/main/persistence/loading-store/mobile-tab-selection-persistence.ts @@ -33,7 +33,7 @@ export class MobileTabSelectionPersistence { } export function installMobileTabSelectionPersistenceContext( - target: object, + target: MobileTabSelectionPersistence, source: MobileTabSelectionPersistence ): void { Object.defineProperty(target, mobileTabSelectionPersistenceContext, { diff --git a/src/main/persistence/loading-store/primary-state-writes.ts b/src/main/persistence/loading-store/primary-state-writes.ts index f61f6c691bd..3820723fffb 100644 --- a/src/main/persistence/loading-store/primary-state-writes.ts +++ b/src/main/persistence/loading-store/primary-state-writes.ts @@ -280,7 +280,7 @@ export function writeToDiskSync( } export function installPrimaryStateWriteOperationsContext( - target: object, + target: PrimaryStateWriteOperations, source: PrimaryStateWriteOperations ): void { Object.defineProperty(target, primaryStateWriteOperationsContext, { diff --git a/src/main/persistence/loading-store/profile-preferences.ts b/src/main/persistence/loading-store/profile-preferences.ts index 0ec4e383c34..8e910ed235d 100644 --- a/src/main/persistence/loading-store/profile-preferences.ts +++ b/src/main/persistence/loading-store/profile-preferences.ts @@ -189,7 +189,10 @@ export function getFeatureInteractionOperations( } } -export function installProfilePreferencesContext(target: object, source: ProfilePreferences): void { +export function installProfilePreferencesContext( + target: ProfilePreferences, + source: ProfilePreferences +): void { Object.defineProperty(target, profilePreferencesContext, { value: source[profilePreferencesContext] }) diff --git a/src/main/persistence/loading-store/project-collection-operations.ts b/src/main/persistence/loading-store/project-collection-operations.ts index ecb1130072f..e8d22147769 100644 --- a/src/main/persistence/loading-store/project-collection-operations.ts +++ b/src/main/persistence/loading-store/project-collection-operations.ts @@ -226,7 +226,7 @@ export function getFolderWorkspaceOperations( } export function installProjectCollectionOperationsContext( - target: object, + target: ProjectCollectionOperations, source: ProjectCollectionOperations ): void { Object.defineProperty(target, projectCollectionOperationsContext, { diff --git a/src/main/persistence/loading-store/pty-binding-persistence.ts b/src/main/persistence/loading-store/pty-binding-persistence.ts index 27d62c8602b..9cdb51fffe7 100644 --- a/src/main/persistence/loading-store/pty-binding-persistence.ts +++ b/src/main/persistence/loading-store/pty-binding-persistence.ts @@ -266,7 +266,7 @@ function applyPtyBinding( } export function installPtyBindingPersistenceOperationsContext( - target: object, + target: PtyBindingPersistenceOperations, source: PtyBindingPersistenceOperations ): void { Object.defineProperty(target, ptyBindingPersistenceOperationsContext, { diff --git a/src/main/persistence/loading-store/repo-lifecycle-operations.ts b/src/main/persistence/loading-store/repo-lifecycle-operations.ts index ec1df02d714..2573c63305f 100644 --- a/src/main/persistence/loading-store/repo-lifecycle-operations.ts +++ b/src/main/persistence/loading-store/repo-lifecycle-operations.ts @@ -323,7 +323,7 @@ export function hydrateRepo(owner: RepoLifecycleOperations, repo: Repo): Repo { } export function installRepoLifecycleOperationsContext( - target: object, + target: RepoLifecycleOperations, source: RepoLifecycleOperations ): void { Object.defineProperty(target, repoLifecycleOperationsContext, { diff --git a/src/main/persistence/loading-store/retired-worktree-name-persistence.ts b/src/main/persistence/loading-store/retired-worktree-name-persistence.ts index c5260850522..bf0e7383667 100644 --- a/src/main/persistence/loading-store/retired-worktree-name-persistence.ts +++ b/src/main/persistence/loading-store/retired-worktree-name-persistence.ts @@ -110,7 +110,7 @@ export function applyRetiredWorktreeNames( } export function installRetiredWorktreeNamePersistenceContext( - target: object, + target: RetiredWorktreeNamePersistence, source: RetiredWorktreeNamePersistence ): void { Object.defineProperty(target, retiredWorktreeNamePersistenceContext, { diff --git a/src/main/persistence/loading-store/session-host-partitions.ts b/src/main/persistence/loading-store/session-host-partitions.ts index 353c89da4e5..d358f4c8f62 100644 --- a/src/main/persistence/loading-store/session-host-partitions.ts +++ b/src/main/persistence/loading-store/session-host-partitions.ts @@ -210,7 +210,7 @@ export function setHostWorkspaceSession( } export function installSessionHostPartitionOperationsContext( - target: object, + target: SessionHostPartitionOperations, source: SessionHostPartitionOperations ): void { Object.defineProperty(target, sessionHostPartitionOperationsContext, { diff --git a/src/main/persistence/loading-store/session-snapshot-operations.ts b/src/main/persistence/loading-store/session-snapshot-operations.ts index 2f5f1c64b86..37ffd9d366b 100644 --- a/src/main/persistence/loading-store/session-snapshot-operations.ts +++ b/src/main/persistence/loading-store/session-snapshot-operations.ts @@ -93,7 +93,7 @@ export function getSessionSnapshotOperationsContext(owner: SessionSnapshotOperat } export function installSessionSnapshotOperationsContext( - target: object, + target: SessionSnapshotOperations, source: SessionSnapshotOperations ): void { Object.defineProperty(target, sessionSnapshotOperationsContext, { diff --git a/src/main/persistence/loading-store/sparse-preset-persistence.ts b/src/main/persistence/loading-store/sparse-preset-persistence.ts index 2de83313ecd..20b78fcd9cc 100644 --- a/src/main/persistence/loading-store/sparse-preset-persistence.ts +++ b/src/main/persistence/loading-store/sparse-preset-persistence.ts @@ -46,7 +46,7 @@ export class SparsePresetPersistence { } export function installSparsePresetPersistenceContext( - target: object, + target: SparsePresetPersistence, source: SparsePresetPersistence ): void { Object.defineProperty(target, sparsePresetPersistenceContext, { diff --git a/src/main/persistence/loading-store/ssh-lease-recovery-operations.ts b/src/main/persistence/loading-store/ssh-lease-recovery-operations.ts index 75aa19ad24b..7da5144898e 100644 --- a/src/main/persistence/loading-store/ssh-lease-recovery-operations.ts +++ b/src/main/persistence/loading-store/ssh-lease-recovery-operations.ts @@ -245,7 +245,7 @@ export function getSshPtyLeaseOperations(owner: SshLeaseRecoveryOperations): Ssh } export function installSshLeaseRecoveryOperationsContext( - target: object, + target: SshLeaseRecoveryOperations, source: SshLeaseRecoveryOperations ): void { Object.defineProperty(target, sshLeaseRecoveryOperationsContext, { diff --git a/src/main/persistence/loading-store/ssh-profile-operations.ts b/src/main/persistence/loading-store/ssh-profile-operations.ts index dce7a92890f..5fed1a3d021 100644 --- a/src/main/persistence/loading-store/ssh-profile-operations.ts +++ b/src/main/persistence/loading-store/ssh-profile-operations.ts @@ -146,7 +146,7 @@ export function getSshTargetStateOperations(owner: SshProfileOperations): SshTar } export function installSshProfileOperationsContext( - target: object, + target: SshProfileOperations, source: SshProfileOperations ): void { Object.defineProperty(target, sshProfileOperationsContext, { diff --git a/src/main/persistence/loading-store/store-domain-composition.ts b/src/main/persistence/loading-store/store-domain-composition.ts index 2bbe91a1a2e..c3f2059efe1 100644 --- a/src/main/persistence/loading-store/store-domain-composition.ts +++ b/src/main/persistence/loading-store/store-domain-composition.ts @@ -1,4 +1,5 @@ import type { StoreRuntimeState } from './store-runtime-state' +import type { Store } from './store' import { LoadedStateAdaptationOperations } from './loaded-state-adaptation' import { BackupRecoveryRotationOperations } from './backup-recovery-rotation' import { LoadedCohortMigrationOperations } from './loaded-cohort-migrations' @@ -108,7 +109,7 @@ export const STORE_DOMAIN_OPERATION_CLASSES = [ WriteFlushBarrierOperations ] as const -export function installStoreDomainContexts(target: object, domains: StoreDomains): void { +export function installStoreDomainContexts(target: Store, domains: StoreDomains): void { installWriteSchedulingOperationsContext(target, domains.scheduling) installPrimaryStateWriteOperationsContext(target, domains.writes) installProjectCollectionOperationsContext(target, domains.projects) diff --git a/src/main/persistence/loading-store/write-flush-barriers.ts b/src/main/persistence/loading-store/write-flush-barriers.ts index 1a80c77976f..c08d4367989 100644 --- a/src/main/persistence/loading-store/write-flush-barriers.ts +++ b/src/main/persistence/loading-store/write-flush-barriers.ts @@ -269,7 +269,7 @@ export function writeGithubCacheSnapshotSync(owner: WriteFlushBarrierOperations) } export function installWriteFlushBarrierOperationsContext( - target: object, + target: WriteFlushBarrierOperations, source: WriteFlushBarrierOperations ): void { Object.defineProperty(target, writeFlushBarrierOperationsContext, { diff --git a/src/main/persistence/loading-store/write-scheduling.ts b/src/main/persistence/loading-store/write-scheduling.ts index c78a5d0dfd4..0301da34a7e 100644 --- a/src/main/persistence/loading-store/write-scheduling.ts +++ b/src/main/persistence/loading-store/write-scheduling.ts @@ -64,7 +64,7 @@ export function scheduleSave(owner: WriteSchedulingOperations): void { } export function installWriteSchedulingOperationsContext( - target: object, + target: WriteSchedulingOperations, source: WriteSchedulingOperations ): void { Object.defineProperty(target, writeSchedulingOperationsContext, { diff --git a/src/main/persistence/tracking-repos/missing-local-worktree-metadata-pruning.test.ts b/src/main/persistence/tracking-repos/missing-local-worktree-metadata-pruning.test.ts index 0588a2f6dae..2b744f6cdf0 100644 --- a/src/main/persistence/tracking-repos/missing-local-worktree-metadata-pruning.test.ts +++ b/src/main/persistence/tracking-repos/missing-local-worktree-metadata-pruning.test.ts @@ -3,6 +3,7 @@ import { getDefaultPersistedState, getDefaultWorkspaceSession } from '../../../s import type { PersistedState } from '../../../shared/persisted-state-types' import type { Project } from '../../../shared/project-types' import type { Repo } from '../../../shared/repo-types' +import type { SshRemotePtyLease } from '../../../shared/ssh-types' import { worktreeWorkspaceKey } from '../../../shared/workspace-scope' import type { WorktreeMeta } from '../../../shared/worktree/meta-types' import { @@ -299,7 +300,7 @@ describe('pruneSessionlessMissingLocalWorktreeMetadataForRepo', () => { for (const worktreeId of allIds) { state.worktreeMeta[worktreeId] = makeMeta(worktreeId) } - const lease = (worktreeId: string, index: number, extra: object) => ({ + const lease = (worktreeId: string, index: number, extra: Partial) => ({ targetId: 'builder', ptyId: `pty-${index}`, worktreeId, diff --git a/src/main/runtime/browser-client-download-transfer-store.ts b/src/main/runtime/browser-client-download-transfer-store.ts index 7da5042d7ea..c6c63b322fe 100644 --- a/src/main/runtime/browser-client-download-transfer-store.ts +++ b/src/main/runtime/browser-client-download-transfer-store.ts @@ -21,7 +21,11 @@ type RuntimeFileChannelHost = { statRuntimeFile(worktree: string, relativePath: string): Promise } -const stores = new WeakMap() +// Release runs from the lease registry, which only knows the runtime by id; the store itself is +// only ever created for a file-channel host. +type DownloadTransferRuntime = RuntimeFileChannelHost | { getRuntimeId(): string } + +const stores = new WeakMap() /** * Drops every staged download a page still owns. @@ -31,7 +35,7 @@ const stores = new WeakMap() * opened a file channel. */ export function releaseBrowserClientDownloadTransfersForPage( - runtime: object, + runtime: DownloadTransferRuntime, browserPageId: string ): Promise { return stores.get(runtime)?.releasePage(browserPageId) ?? Promise.resolve() diff --git a/src/main/runtime/browser-host-lease-download-transfer-cleanup.test.ts b/src/main/runtime/browser-host-lease-download-transfer-cleanup.test.ts index aaa78e6d45b..77b716218aa 100644 --- a/src/main/runtime/browser-host-lease-download-transfer-cleanup.test.ts +++ b/src/main/runtime/browser-host-lease-download-transfer-cleanup.test.ts @@ -18,7 +18,9 @@ function createRuntime() { return { runtime, removed } } -async function stageTransfer(runtime: object, browserPageId: string): Promise { +type FakeRuntime = ReturnType['runtime'] + +async function stageTransfer(runtime: FakeRuntime, browserPageId: string): Promise { await getBrowserClientDownloadTransferStore(runtime as never).accept({ transferId: `transfer-${browserPageId}`, browserPageId, diff --git a/src/main/runtime/relay/relay-control-client.test.ts b/src/main/runtime/relay/relay-control-client.test.ts index 745a79ac84e..431d26574cf 100644 --- a/src/main/runtime/relay/relay-control-client.test.ts +++ b/src/main/runtime/relay/relay-control-client.test.ts @@ -8,6 +8,9 @@ import { MOBILE_RELAY_CLOSE_CODE } from '../../../shared/mobile-relay-close-code import { RelayControlClient } from './relay-control-client' const encoder = new TextEncoder() + +/** A JSON control frame, including the forward-compat frames the client must ignore. */ +type ControlFrame = { type: string } & Record const HOST_PROOF_DOMAIN = 'orca-relay-host-proof/v1' const CHALLENGE_DOMAIN = 'orca-relay-host-challenge/v1' @@ -410,7 +413,7 @@ class FakeControlSocket extends EventEmitter { this.close(1006) } - deliver(message: object): void { + deliver(message: ControlFrame): void { this.emit('message', JSON.stringify(message), false) } } diff --git a/src/main/runtime/relay/relay-control-client.ts b/src/main/runtime/relay/relay-control-client.ts index 0c863cce3ad..8321bf8caf2 100644 --- a/src/main/runtime/relay/relay-control-client.ts +++ b/src/main/runtime/relay/relay-control-client.ts @@ -269,7 +269,7 @@ export class RelayControlClient { this.clearConnectPromise() } - private sendActive(payload: object): void { + private sendActive(payload: Record): void { if (!this.socket || (this.state !== 'active' && this.state !== 'draining')) { throw new Error('relay_control_not_active') } diff --git a/src/main/runtime/relay/relay-control-requests.ts b/src/main/runtime/relay/relay-control-requests.ts index bbceb067a59..6151d634f0d 100644 --- a/src/main/runtime/relay/relay-control-requests.ts +++ b/src/main/runtime/relay/relay-control-requests.ts @@ -22,6 +22,23 @@ export type DeviceCredentialInstallAuthorization = | { mode: 'relay-basis'; basisConnId: string } | { mode: 'authenticated-direct'; directAuthId: string } +export type DeviceCredentialInstallInput = { + relayDeviceId: string + newResumeTokenHash: string + expectedCurrentHash?: string + authorization: DeviceCredentialInstallAuthorization +} + +/** Every control-plane request this class hands to `send`. */ +type RelayControlRequestPayload = + | { type: 'invite-create'; reqId: string; relayDeviceId: string } + | { type: 'device-revoke'; reqId: string; relayDeviceId: string } + | ({ type: 'device-credential-install'; v: 1; reqId: string } & DeviceCredentialInstallInput) + | { type: 'device-credential-install-status'; v: 1; reqId: string; relayDeviceId: string } + | { type: 'device-resume-confirm'; v: 1; reqId: string; basisConnId: string } + +type SendRelayControlRequest = (payload: RelayControlRequestPayload) => void + export class RelayControlRequests { private readonly pending = new Map() @@ -34,7 +51,7 @@ export class RelayControlRequests { createInvite( reqId: string, relayDeviceId: string, - send: (payload: object) => void + send: SendRelayControlRequest ): Promise { return this.request( reqId, @@ -44,11 +61,7 @@ export class RelayControlRequests { ) as Promise } - revokeDevice( - reqId: string, - relayDeviceId: string, - send: (payload: object) => void - ): Promise { + revokeDevice(reqId: string, relayDeviceId: string, send: SendRelayControlRequest): Promise { return this.request( reqId, 'revoke', @@ -59,13 +72,8 @@ export class RelayControlRequests { installCredential( reqId: string, - input: { - relayDeviceId: string - newResumeTokenHash: string - expectedCurrentHash?: string - authorization: DeviceCredentialInstallAuthorization - }, - send: (payload: object) => void + input: DeviceCredentialInstallInput, + send: SendRelayControlRequest ): Promise { return this.request( reqId, @@ -78,7 +86,7 @@ export class RelayControlRequests { credentialInstallStatus( reqId: string, relayDeviceId: string, - send: (payload: object) => void + send: SendRelayControlRequest ): Promise { return this.request( reqId, @@ -91,7 +99,7 @@ export class RelayControlRequests { confirmResume( reqId: string, basisConnId: string, - send: (payload: object) => void + send: SendRelayControlRequest ): Promise { return this.request( reqId, @@ -156,8 +164,8 @@ export class RelayControlRequests { private request( reqId: string, kind: PendingRequest['kind'], - payload: object, - send: (payload: object) => void + payload: RelayControlRequestPayload, + send: SendRelayControlRequest ): Promise { if (this.pending.has(reqId)) { return Promise.reject(new Error('duplicate_relay_request_id')) diff --git a/src/main/runtime/runtime-browser-page-registry.ts b/src/main/runtime/runtime-browser-page-registry.ts index 9209e24e4af..f32f83dd105 100644 --- a/src/main/runtime/runtime-browser-page-registry.ts +++ b/src/main/runtime/runtime-browser-page-registry.ts @@ -226,9 +226,11 @@ export class RuntimeBrowserPageRegistry { } } -const registries = new WeakMap() +/** Keyed by runtime identity alone; this module never reads from the runtime, and the callers' + * declared host types share no member. */ +const registries = new WeakMap() -export function getRuntimeBrowserPageRegistry(runtime: object): RuntimeBrowserPageRegistry { +export function getRuntimeBrowserPageRegistry(runtime: WeakKey): RuntimeBrowserPageRegistry { let registry = registries.get(runtime) if (!registry) { registry = new RuntimeBrowserPageRegistry() diff --git a/src/main/runtime/runtime-linear-command-surface.ts b/src/main/runtime/runtime-linear-command-surface.ts index cf9ad9e69df..97c91e3af6d 100644 --- a/src/main/runtime/runtime-linear-command-surface.ts +++ b/src/main/runtime/runtime-linear-command-surface.ts @@ -13,9 +13,12 @@ type LinearFacadeInstance = { type LinearMethodBag = Record unknown> const delegators = new WeakSet() -const receiverByCommands = new WeakMap() +const receiverByCommands = new WeakMap() -function collectMethodNames(instancePrototype: object, stopAt: object | null): Set { +function collectMethodNames( + instancePrototype: RuntimeLinearBrowseCommands, + stopAt: RuntimeLinearBrowseCommands | null +): Set { const names = new Set() let prototype: object | null = instancePrototype while (prototype && prototype !== Object.prototype && prototype !== stopAt) { @@ -31,10 +34,10 @@ function collectMethodNames(instancePrototype: object, stopAt: object | null): S // Why: the chain used to live on the facade, so a facade override (test spy) has to win for re-entrant `this` calls too. function overrideAwareReceiver( - facade: object, - commands: object, + facade: LinearFacadeInstance, + commands: LinearMethodBag, surfaceNames: ReadonlySet -): object { +): LinearMethodBag { const cached = receiverByCommands.get(commands) if (cached) { return cached @@ -55,7 +58,7 @@ function overrideAwareReceiver( return receiver } -export function installRuntimeLinearCommandSurface(target: object): void { +export function installRuntimeLinearCommandSurface(target: LinearFacadeInstance): void { const names = collectMethodNames( RuntimeLinearCommands.prototype, RuntimeLinearCommandBase.prototype diff --git a/src/main/runtime/structured-session-worktree-teardown.test.ts b/src/main/runtime/structured-session-worktree-teardown.test.ts index 86258ad9dab..915fbd52986 100644 --- a/src/main/runtime/structured-session-worktree-teardown.test.ts +++ b/src/main/runtime/structured-session-worktree-teardown.test.ts @@ -144,7 +144,10 @@ function destructiveDeps(extra: { allowUnverifiedStop?: boolean; timeoutMs?: num } } -function runtimeDouble(hooks: object): TeardownRuntime { +/** Keys are pinned to the real runtime; each stub narrows its own args to what the case drives. */ +type TeardownRuntimeStubs = Partial> + +function runtimeDouble(hooks: TeardownRuntimeStubs): TeardownRuntime { return Object.assign(Object.create(null), hooks) } diff --git a/src/main/runtime/structured-worker-terminal-read.test.ts b/src/main/runtime/structured-worker-terminal-read.test.ts index 97859a988e0..8d7f4ef6b70 100644 --- a/src/main/runtime/structured-worker-terminal-read.test.ts +++ b/src/main/runtime/structured-worker-terminal-read.test.ts @@ -161,12 +161,17 @@ describe('reading a structured worker through the terminal-read path', () => { // could be perfect and a peer would still get `terminal_handle_stale` if nothing called it. const handle = registerWorker() installHost({ items: [message('i1', 'hello')] }) - const runtime = Object.assign(Object.create(OrcaRuntimeWithResolveTerminalPane.prototype), { + const runtime: { + readTerminal: ( + handle: string, + opts?: { cursor?: number; limit?: number; screen?: boolean } + ) => Promise<{ tail: string[] }> + } = Object.assign(Object.create(OrcaRuntimeWithResolveTerminalPane.prototype), { getOrchestrationDbIfAvailable: () => null, getLivePtyForHandle: () => { throw new Error('the PTY lookup must never be reached for a structured worker') } - }) as { readTerminal: (handle: string, opts?: object) => Promise<{ tail: string[] }> } + }) await expect(runtime.readTerminal(handle)).resolves.toMatchObject({ tail: ['[assistant] hello'], source: 'stream' diff --git a/src/main/source-control/hosted-review-branch-cache.ts b/src/main/source-control/hosted-review-branch-cache.ts index 7fafc855ba2..0e6f207ab9c 100644 --- a/src/main/source-control/hosted-review-branch-cache.ts +++ b/src/main/source-control/hosted-review-branch-cache.ts @@ -59,9 +59,14 @@ type CacheEntry = { startedAt: number } +declare const inflightTokenBrand: unique symbol + +/** Identity token for one lookup; only ever compared by reference. */ +type InflightToken = { readonly [inflightTokenBrand]?: never } + type InflightRecord = { /** Identity, so a detached lookup can only ever clear its own entry. */ - token: object + token: InflightToken startedAt: number promise: Promise /** Releases the callers and unpins the branch; idempotent. */ @@ -154,7 +159,7 @@ function storeEntry(key: string, entry: CacheEntry): void { } /** Clears the key's in-flight record only if it is still this lookup's. */ -function releaseInflight(key: string, token: object): boolean { +function releaseInflight(key: string, token: InflightToken): boolean { if (inflight.get(key)?.token !== token) { return false } @@ -271,7 +276,7 @@ function startLookup( ): Promise { const startedAt = Date.now() const generation = scopeGeneration(scope) - const token = {} + const token: InflightToken = {} /** The deadline released the callers; the lookup itself runs on, detached. */ let timedOut = false let completed = false diff --git a/src/main/worktree-retirement-backfill-scan.test.ts b/src/main/worktree-retirement-backfill-scan.test.ts index f90d4ccf405..0f02f11222f 100644 --- a/src/main/worktree-retirement-backfill-scan.test.ts +++ b/src/main/worktree-retirement-backfill-scan.test.ts @@ -32,7 +32,7 @@ function stallingScan(): { } /** Drive one namespace to the state where its listing is abandoned but still stuck in the kernel. */ -async function stallPastDeadline(store: object, scanKey: string) { +async function stallPastDeadline(store: WeakKey, scanKey: string) { const scan = stallingScan() const pending = runRetirementBackfillScan(store, scanKey, scan.run) const settled = expect(pending).rejects.toThrow(/exceeded/) diff --git a/src/main/worktree-retirement-backfill-scan.ts b/src/main/worktree-retirement-backfill-scan.ts index 8ca5b26ccd5..c0c111729a3 100644 --- a/src/main/worktree-retirement-backfill-scan.ts +++ b/src/main/worktree-retirement-backfill-scan.ts @@ -20,7 +20,9 @@ type BackfillScan = { outstanding: boolean } -const scansByStore = new WeakMap>() +/** Only the store's identity is the memo key — this module never reads from it, and cannot name the + * store's own type without importing its caller. */ +const scansByStore = new WeakMap>() /** Monotonic, like the WSL gate's own stuck timer: wall time misjudges a backoff across laptop * sleep or an NTP step, either pinning a namespace in its failure memo or ending it early. */ @@ -59,7 +61,7 @@ function withScanDeadline(scan: Promise): Promise { * the rule per namespace rather than process-wide is deliberate: a global budget lets one bad mount * spend it on its own retries and starve every healthy repo. */ export function runRetirementBackfillScan( - store: object, + store: WeakKey, scanKey: string, scan: () => Promise ): Promise> { diff --git a/src/relay/dispatcher-frame-guard-regressions.test.ts b/src/relay/dispatcher-frame-guard-regressions.test.ts index eb39e90bbdb..6574579d4ee 100644 --- a/src/relay/dispatcher-frame-guard-regressions.test.ts +++ b/src/relay/dispatcher-frame-guard-regressions.test.ts @@ -1,11 +1,12 @@ import { describe, expect, it, vi } from 'vitest' import { RelayDispatcher } from './dispatcher' +import type { RelayClient } from './dispatcher-contract' import type { JsonRpcNotification } from './protocol' type DispatcherInternals = { - primaryClient: object + primaryClient: RelayClient estimateFrameBytes: (msg: JsonRpcNotification) => number - enqueueFrame: (client: object, msg: JsonRpcNotification, lane: string) => boolean + enqueueFrame: (client: RelayClient, msg: JsonRpcNotification, lane: string) => boolean } describe('RelayDispatcher frame guards', () => { diff --git a/src/relay/dispatcher.test.ts b/src/relay/dispatcher.test.ts index 280f9351200..f8ccc11c153 100644 --- a/src/relay/dispatcher.test.ts +++ b/src/relay/dispatcher.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' import { RelayDispatcher, type SinkWriteSettlement } from './dispatcher' +import type { PreparedRelayFrame, RelayClient } from './dispatcher-contract' import { relayWriterControlReserve } from './dispatcher-writer-admission' import { encodeJsonRpcFrame, @@ -723,18 +724,18 @@ describe('RelayDispatcher', () => { describe('legacy PTY chunk sizing', () => { type DispatcherInternals = { - primaryClient: object + primaryClient: RelayClient estimateFrameBytes: (msg: JsonRpcNotification) => number - prepareFrame: (msg: JsonRpcNotification) => object + prepareFrame: (msg: JsonRpcNotification) => PreparedRelayFrame enqueueFrame: ( - client: object, + client: RelayClient, msg: JsonRpcNotification, lane: string, onSettled?: (result: SinkWriteSettlement) => void ) => boolean enqueuePreparedFrame: ( - client: object, - frame: object, + client: RelayClient, + frame: PreparedRelayFrame, lane: string, onSettled?: (result: SinkWriteSettlement) => void ) => boolean diff --git a/src/relay/relay-filesystem-watch-registry.test.ts b/src/relay/relay-filesystem-watch-registry.test.ts index de924230f0f..dcea47e7d44 100644 --- a/src/relay/relay-filesystem-watch-registry.test.ts +++ b/src/relay/relay-filesystem-watch-registry.test.ts @@ -3,6 +3,7 @@ import { join } from 'node:path' import { tmpdir } from 'node:os' import { WatcherProcessFailure } from '../main/ipc/parcel-watcher-process-failure' import { WatcherProcessSupervisor } from '../main/ipc/parcel-watcher-process-supervisor' +import type { WatcherProcessSubscribeOptions } from '../main/ipc/parcel-watcher-process-protocol' import type { WatcherProcessCallback, WatcherProcessHooks, @@ -50,7 +51,7 @@ class FakeWatcherPool { async subscribe( rootPath: string, callback: WatcherProcessCallback, - _options: object, + _options: WatcherProcessSubscribeOptions, hooks: WatcherProcessHooks ): Promise { const unsubscribe = vi.fn(async () => undefined) diff --git a/src/renderer/src/components/agent/AgentSettingsDialog.test.tsx b/src/renderer/src/components/agent/AgentSettingsDialog.test.tsx index ee45c71c30e..ab3ea61b1e2 100644 --- a/src/renderer/src/components/agent/AgentSettingsDialog.test.tsx +++ b/src/renderer/src/components/agent/AgentSettingsDialog.test.tsx @@ -16,8 +16,15 @@ const testState = vi.hoisted(() => ({ runtimeEnvironments: [] as { id: string; createdAt: number; pairingRevision?: number }[] })) +type MockedAppStoreState = { + settings: GlobalSettings | null + updateSettings: (settings: Partial) => void + runtimeEnvironments: { id: string; createdAt: number; pairingRevision?: number }[] + runtimeStatusByEnvironmentId: Map +} + vi.mock('@/store', () => ({ - useAppStore: (selector: (state: object) => unknown) => + useAppStore: (selector: (state: MockedAppStoreState) => unknown) => selector({ settings: testState.settings, updateSettings: testState.updateSettings, diff --git a/src/renderer/src/components/dashboard/useAgentBucketCounts.gate.test.ts b/src/renderer/src/components/dashboard/useAgentBucketCounts.gate.test.ts index 618d04a9169..5f89b90f715 100644 --- a/src/renderer/src/components/dashboard/useAgentBucketCounts.gate.test.ts +++ b/src/renderer/src/components/dashboard/useAgentBucketCounts.gate.test.ts @@ -65,7 +65,8 @@ function countAllocations(run: () => void): { entries: number; maps: number } { const RealMap = globalThis.Map let entries = 0 let maps = 0 - Object.entries = ((target: object) => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: `Object.entries` is an overload set no single arrow can satisfy; this wrapper only counts calls and returns the native result unchanged. + Object.entries = ((target: Record) => { entries += 1 return realEntries(target) }) as typeof Object.entries diff --git a/src/renderer/src/components/diff-comments/useDiffCommentDecorator.model-lifecycle.test.tsx b/src/renderer/src/components/diff-comments/useDiffCommentDecorator.model-lifecycle.test.tsx index fb92076c081..b8ef6823236 100644 --- a/src/renderer/src/components/diff-comments/useDiffCommentDecorator.model-lifecycle.test.tsx +++ b/src/renderer/src/components/diff-comments/useDiffCommentDecorator.model-lifecycle.test.tsx @@ -19,6 +19,13 @@ afterEach(() => { vi.clearAllMocks() }) +/** No zones exist in this suite, so the hook never reaches these. */ +const viewZoneAccessor: MonacoEditor.IViewZoneChangeAccessor = { + addZone: () => '', + removeZone: () => undefined, + layoutZone: () => undefined +} + describe('useDiffCommentDecorator model lifecycle', () => { it('rebuilds model-scoped resources when a retained editor swaps models', () => { const editorDomNode = document.createElement('div') @@ -26,13 +33,15 @@ describe('useDiffCommentDecorator model lifecycle', () => { const disposeMouseMove = vi.fn() const disposeMouseLeave = vi.fn() const disposeScroll = vi.fn() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a partial stand-in for Monaco's ICodeEditor; useDiffCommentDecorator calls only the members defined here, and a real editor needs a laid-out DOM this suite does not build. const editor = { getDomNode: () => editorDomNode, getOption: () => 19, onMouseMove: () => ({ dispose: disposeMouseMove }), onMouseLeave: () => ({ dispose: disposeMouseLeave }), onDidScrollChange: () => ({ dispose: disposeScroll }), - changeViewZones: (callback: (accessor: object) => void) => callback({}) + changeViewZones: (callback: (accessor: MonacoEditor.IViewZoneChangeAccessor) => void) => + callback(viewZoneAccessor) } as unknown as MonacoEditor.ICodeEditor const hook = renderHook( ({ monacoModelIdentity }) => diff --git a/src/renderer/src/components/editor/markdown-preview-search.ts b/src/renderer/src/components/editor/markdown-preview-search.ts index 1b92f958fa8..3dc168ddd61 100644 --- a/src/renderer/src/components/editor/markdown-preview-search.ts +++ b/src/renderer/src/components/editor/markdown-preview-search.ts @@ -219,8 +219,15 @@ function getHighlightApi(): { // window). Track each instance's ranges by its own token and paint the UNION, // so a second preview's Find does not clobber the first's highlights. Ranges // live in each instance's own subtree, so the union paints every pane correctly. -const searchRangesByInstance = new Map() -const activeRangeByInstance = new Map() +declare const markdownPreviewSearchInstanceBrand: unique symbol + +/** Per-preview identity for the highlight maps; only compared by reference. */ +export type MarkdownPreviewSearchInstance = { + readonly [markdownPreviewSearchInstanceBrand]?: never +} + +const searchRangesByInstance = new Map() +const activeRangeByInstance = new Map() // Avoid array spread when collecting union ranges — a large doc can produce // 100k+ ranges and create()/registry writes must not build variadic arg lists. @@ -250,7 +257,9 @@ function paintActiveHighlight(api: NonNullable + function setupScheduledFocus( - activeElement: object | null, + activeElement: StubbedActiveElement | null, force = false ): { focus: ReturnType diff --git a/src/renderer/src/components/editor/rich-markdown-key-handler.test.ts b/src/renderer/src/components/editor/rich-markdown-key-handler.test.ts index 26847626853..e6893049633 100644 --- a/src/renderer/src/components/editor/rich-markdown-key-handler.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-key-handler.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { Editor } from '@tiptap/core' +import { Editor, type JSONContent } from '@tiptap/core' import StarterKit from '@tiptap/starter-kit' import { createIsolatedMarkdownExtensionForTests } from './isolated-markdown-extension-for-tests' import { createRichMarkdownKeyHandler, type KeyHandlerContext } from './rich-markdown-key-handler' @@ -14,7 +14,7 @@ vi.mock('@/lib/shortcut-platform', () => ({ const extensions = [StarterKit, createIsolatedMarkdownExtensionForTests()] -function createEditor(content: object): Editor { +function createEditor(content: JSONContent): Editor { return new Editor({ element: null, extensions, @@ -133,7 +133,7 @@ function createContext(editor: Editor, typedMarker: boolean): KeyHandlerContext } } -function emptyTopLevelOrderedList(): object { +function emptyTopLevelOrderedList(): JSONContent { return { type: 'doc', content: [ diff --git a/src/renderer/src/components/editor/rich-markdown-list-continuation.test.ts b/src/renderer/src/components/editor/rich-markdown-list-continuation.test.ts index 31a3e06aa01..a184882a2e0 100644 --- a/src/renderer/src/components/editor/rich-markdown-list-continuation.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-list-continuation.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Editor } from '@tiptap/core' +import { Editor, type JSONContent } from '@tiptap/core' import StarterKit from '@tiptap/starter-kit' import { createIsolatedMarkdownExtensionForTests } from './isolated-markdown-extension-for-tests' import { @@ -10,7 +10,7 @@ import { isSingleEmptyTopLevelOrderedList } from './rich-markdown-list-continuation' -function createEditor(content: object): Editor { +function createEditor(content: JSONContent): Editor { // Why: each Editor needs its own marked registry; sharing one module-scoped // extension accumulates tokenizer state across tests. return new Editor({ diff --git a/src/renderer/src/components/editor/rich-markdown-paragraph.test.ts b/src/renderer/src/components/editor/rich-markdown-paragraph.test.ts index d59cb0c4c04..1ec3fcc04df 100644 --- a/src/renderer/src/components/editor/rich-markdown-paragraph.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-paragraph.test.ts @@ -2,9 +2,11 @@ import { describe, expect, it, vi } from 'vitest' import { RichMarkdownParagraph } from './rich-markdown-paragraph' vi.mock('@tiptap/extension-paragraph', async () => { - const actual = (await vi.importActual('@tiptap/extension-paragraph')) as { - Paragraph: { extend: (config: object) => { config: Record } } - } + const actual = await vi.importActual<{ + Paragraph: { + extend: (config: Record) => { config: Record } + } + }>('@tiptap/extension-paragraph') // Simulates a Tiptap upgrade that drops `parseMarkdown` from the upstream paragraph. const Paragraph = actual.Paragraph.extend({}) Paragraph.config.parseMarkdown = undefined diff --git a/src/renderer/src/components/editor/rich-markdown-tab-key-handler.test.ts b/src/renderer/src/components/editor/rich-markdown-tab-key-handler.test.ts index 1d597023b3e..a25a06141ec 100644 --- a/src/renderer/src/components/editor/rich-markdown-tab-key-handler.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-tab-key-handler.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { Editor } from '@tiptap/core' +import { Editor, type JSONContent } from '@tiptap/core' import StarterKit from '@tiptap/starter-kit' import TaskList from '@tiptap/extension-task-list' import TaskItem from '@tiptap/extension-task-item' @@ -8,7 +8,7 @@ import { createRichMarkdownExtensions } from './rich-markdown-extensions' import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport' import { createRichMarkdownKeyHandler, type KeyHandlerContext } from './rich-markdown-key-handler' -function createEditor(content: object): Editor { +function createEditor(content: JSONContent): Editor { // Why: each Editor needs its own marked registry; sharing one module-scoped // extension accumulates tokenizer state across tests. return new Editor({ @@ -43,7 +43,7 @@ function createMarkdownEditor(markdown: string): Editor { * editor has no plain-text markdown paste transform. The DOM-less test env * cannot parse HTML, so assert against the node shapes that paste produces. */ -function createNodeEditor(content: object): Editor { +function createNodeEditor(content: JSONContent): Editor { return new Editor({ element: null, extensions: createRichMarkdownExtensions({ @@ -53,18 +53,18 @@ function createNodeEditor(content: object): Editor { }) } -function para(text: string): object { +function para(text: string): JSONContent { return { type: 'paragraph', content: [{ type: 'text', text }] } } -function bullets(...items: object[][]): object { +function bullets(...items: JSONContent[][]): JSONContent { return { type: 'bulletList', content: items.map((content) => ({ type: 'listItem', content })) } } -function tasks(...items: object[][]): object { +function tasks(...items: JSONContent[][]): JSONContent { return { type: 'taskList', content: items.map((content) => ({ @@ -75,7 +75,7 @@ function tasks(...items: object[][]): object { } } -function doc(...content: object[]): object { +function doc(...content: JSONContent[]): JSONContent { return { type: 'doc', content } } @@ -175,7 +175,7 @@ function createContext(editor: Editor): KeyHandlerContext { } } -function bulletListDocument(): object { +function bulletListDocument(): JSONContent { return { type: 'doc', content: [ @@ -196,7 +196,7 @@ function bulletListDocument(): object { } } -function parentAndFixesDocument(): object { +function parentAndFixesDocument(): JSONContent { return { type: 'doc', content: [ @@ -226,7 +226,7 @@ function parentAndFixesDocument(): object { } } -function taskListDocument(): object { +function taskListDocument(): JSONContent { return { type: 'doc', content: [ diff --git a/src/renderer/src/components/editor/use-markdown-preview-source-foundation.ts b/src/renderer/src/components/editor/use-markdown-preview-source-foundation.ts index 1f32ed4f668..ca6f4c023f2 100644 --- a/src/renderer/src/components/editor/use-markdown-preview-source-foundation.ts +++ b/src/renderer/src/components/editor/use-markdown-preview-source-foundation.ts @@ -6,6 +6,7 @@ import { isMarkdownComment } from '@/lib/diff-comment-compat' import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' import { useAppStore } from '@/store' import { prewarmMarkdownPreviewLocalImages } from './markdown-preview-local-images' +import type { MarkdownPreviewSearchInstance } from './markdown-preview-search' import { deriveMarkdownPreviewSourceRoot, findMarkdownPreviewSourceOpenFile, @@ -40,7 +41,7 @@ export function useMarkdownPreviewSourceFoundation({ input.select() }, []) const matchesRef = useRef([]) - const searchInstanceRef = useRef({}) + const searchInstanceRef = useRef({}) const lastAppliedInitialAnchorRef = useRef(null) const pendingEditorRevealFrameIdsRef = useRef([]) const [isSearchOpen, setIsSearchOpen] = useState(false) diff --git a/src/renderer/src/components/github-checks-tab-state.ts b/src/renderer/src/components/github-checks-tab-state.ts index 18c12cf089b..6d60adaf71a 100644 --- a/src/renderer/src/components/github-checks-tab-state.ts +++ b/src/renderer/src/components/github-checks-tab-state.ts @@ -7,9 +7,14 @@ export type CheckDetailsLoadState = { error: string | null } +declare const checksContextOwnerBrand: unique symbol + +/** Identity minted per checks context; only its reference is ever compared. */ +export type GitHubChecksContextOwner = object & { readonly [checksContextOwnerBrand]?: never } + export type GitHubChecksTabState = { contextKey: string - contextOwner: object + contextOwner: GitHubChecksContextOwner sourceChecks: GitHubChecksSource localChecks: PRCheckDetail[] | null expandedCheckKey: string | null diff --git a/src/renderer/src/components/github-item-dialog/inspect-pull-request/checks-tab-actions.ts b/src/renderer/src/components/github-item-dialog/inspect-pull-request/checks-tab-actions.ts index b4a46e24c33..21b10c2cd88 100644 --- a/src/renderer/src/components/github-item-dialog/inspect-pull-request/checks-tab-actions.ts +++ b/src/renderer/src/components/github-item-dialog/inspect-pull-request/checks-tab-actions.ts @@ -4,6 +4,7 @@ import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import { resetGitHubChecksTabForSource, updateGitHubChecksTabLocalChecks, + type GitHubChecksContextOwner, type GitHubChecksTabState } from '@/components/github-checks-tab-state' import { getGitHubRuntimeRepoId, type GitHubRuntimeHost } from '@/lib/github-source-runtime-context' @@ -28,21 +29,21 @@ export type ChecksTabActionContext = { headSha: string | undefined prRepo: GitHubOwnerRepo | null mountedRef: { current: boolean } - committedChecksContextOwnerRef: { current: object } + committedChecksContextOwnerRef: { current: GitHubChecksContextOwner } nextChecksRefreshRequestIdRef: { current: number } activeChecksRefreshRequestIdRef: { current: number | null } nextCheckDetailsRequestIdRef: { current: number } setChecksState: React.Dispatch> setRefreshingOwner: React.Dispatch< - React.SetStateAction<{ contextOwner: object; requestId: number } | null> + React.SetStateAction<{ contextOwner: GitHubChecksContextOwner; requestId: number } | null> > - setRerunningOwner: React.Dispatch> + setRerunningOwner: React.Dispatch> onChecksUpdated: (checks: PRCheckDetail[]) => void } export async function refreshGitHubChecksTab( ctx: ChecksTabActionContext, - expectedContextOwner?: object + expectedContextOwner?: GitHubChecksContextOwner ): Promise { if (!ctx.canUseChecksRepoContext) { toast.error( diff --git a/src/renderer/src/components/github-item-dialog/inspect-pull-request/checks-tab.tsx b/src/renderer/src/components/github-item-dialog/inspect-pull-request/checks-tab.tsx index 3e9811d997c..9692d59fc18 100644 --- a/src/renderer/src/components/github-item-dialog/inspect-pull-request/checks-tab.tsx +++ b/src/renderer/src/components/github-item-dialog/inspect-pull-request/checks-tab.tsx @@ -40,6 +40,9 @@ import { import { requestGitHubCheckDetails } from './checks-tab-request-details' import { ChecksTabActions, ChecksTabCompactHeader } from './checks-tab-header' +/** Identity token for one checks context; compared by reference so a stale refresh is dropped. */ +type ChecksContextOwner = Record + export function ChecksTab({ item, repoPath, @@ -111,7 +114,7 @@ export function ChecksTab({ const canFixBrokenChecks = Boolean((repoId ?? item.repoId) && failedChecks.length > 0) const handleRefresh = useCallback( - async (expectedContextOwner?: object): Promise => + async (expectedContextOwner?: ChecksContextOwner): Promise => refreshGitHubChecksTab( { canUseChecksRepoContext, diff --git a/src/renderer/src/components/pull-request-page/checks/rerun.ts b/src/renderer/src/components/pull-request-page/checks/rerun.ts index 40637c10993..6f8eaec76a7 100644 --- a/src/renderer/src/components/pull-request-page/checks/rerun.ts +++ b/src/renderer/src/components/pull-request-page/checks/rerun.ts @@ -6,12 +6,21 @@ import type { GitHubOwnerRepo } from '../../../../../shared/github/pull-request- import type { GitHubWorkItem } from '../../../../../shared/github/work-item-types' import type { PRCheckDetail } from '../../../../../shared/github/check-types' import type { TaskSourceContext } from '../../../../../shared/task-source-context' +import type { GitHubChecksTabState } from '../../github-checks-tab-state' + +/** The checks tab mints one of these per context; only its reference identity is ever read. */ +type ChecksContextOwner = GitHubChecksTabState['contextOwner'] export async function rerunPullRequestChecks(args: { canUseChecksRepoContext: boolean rerunning: boolean - committedChecksContextOwnerRef: { current: object } - setRerunningOwner: (value: object | null | ((current: object | null) => object | null)) => void + committedChecksContextOwnerRef: { current: ChecksContextOwner } + setRerunningOwner: ( + value: + | ChecksContextOwner + | null + | ((current: ChecksContextOwner | null) => ChecksContextOwner | null) + ) => void runtimeHost: GitHubRuntimeHost | null sourceContext?: TaskSourceContext | null repoId: string | null @@ -21,7 +30,7 @@ export async function rerunPullRequestChecks(args: { prRepo: GitHubOwnerRepo | null failedOnly: boolean mountedRef: { current: boolean } - handleRefresh: (expectedContextOwner?: object) => Promise + handleRefresh: (expectedContextOwner?: ChecksContextOwner) => Promise }): Promise { if (!args.canUseChecksRepoContext || args.rerunning) { return diff --git a/src/renderer/src/components/pull-request-page/checks/tab.tsx b/src/renderer/src/components/pull-request-page/checks/tab.tsx index 9ccbc4d546e..0c12d91ae7e 100644 --- a/src/renderer/src/components/pull-request-page/checks/tab.tsx +++ b/src/renderer/src/components/pull-request-page/checks/tab.tsx @@ -6,7 +6,8 @@ import { CHECK_COLOR, CHECK_ICON } from '@/components/right-sidebar/checks-panel import { createGitHubChecksTabState, resolveGitHubChecksTabState, - toggleGitHubChecksTabExpandedKey + toggleGitHubChecksTabExpandedKey, + type GitHubChecksContextOwner } from '@/components/github-checks-tab-state' import { getCheckDetailsKey } from '@/components/github/pr-check-presentation' import { getCheckCounts, getChecksSummaryLabel } from '@/components/pr-check-counts' @@ -94,11 +95,11 @@ export function ChecksTab({ const nextChecksRefreshRequestIdRef = useRef(0) const activeChecksRefreshRequestIdRef = useRef(null) const [refreshingOwner, setRefreshingOwner] = useState<{ - contextOwner: object + contextOwner: GitHubChecksContextOwner requestId: number } | null>(null) const refreshing = refreshingOwner?.contextOwner === resolvedChecksState.contextOwner - const [rerunningOwner, setRerunningOwner] = useState(null) + const [rerunningOwner, setRerunningOwner] = useState(null) const rerunning = rerunningOwner === resolvedChecksState.contextOwner useLayoutEffect(() => { committedChecksContextOwnerRef.current = resolvedChecksState.contextOwner @@ -173,7 +174,7 @@ export function ChecksTab({ const canFixBrokenChecks = Boolean((repoId ?? item.repoId) && failedChecks.length > 0) const handleRefresh = useCallback( - async (expectedContextOwner?: object) => + async (expectedContextOwner?: GitHubChecksContextOwner) => refreshPullRequestChecks({ canUseChecksRepoContext, expectedContextOwner, diff --git a/src/renderer/src/components/settings/GeneralWorkspaceSettingsSection.test.tsx b/src/renderer/src/components/settings/GeneralWorkspaceSettingsSection.test.tsx index a601584f60e..12aed316a9c 100644 --- a/src/renderer/src/components/settings/GeneralWorkspaceSettingsSection.test.tsx +++ b/src/renderer/src/components/settings/GeneralWorkspaceSettingsSection.test.tsx @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getDefaultSettings } from '../../../../shared/constants' import { GeneralWorkspaceSettingsSection } from './GeneralWorkspaceSettingsSection' import type { ReactNode } from 'react' +import type { GlobalSettings } from '../../../../shared/global-settings-types' vi.mock('./WorkspaceDirectorySetting', () => ({ WorkspaceDirectorySetting: () => null })) vi.mock('./OpenInMenuSetting', () => ({ OpenInMenuSetting: () => null })) @@ -30,7 +31,7 @@ afterEach(() => { }) function renderSection( - updateSettings: (updates: object) => void | Promise, + updateSettings: (updates: Partial) => void | Promise, options: { defaultsSupported?: boolean sourceDefaultsSupported?: boolean diff --git a/src/renderer/src/components/settings/RepositoryWorktreeDefaultsSection.test.tsx b/src/renderer/src/components/settings/RepositoryWorktreeDefaultsSection.test.tsx index a01d7d99ffe..f764f6264b3 100644 --- a/src/renderer/src/components/settings/RepositoryWorktreeDefaultsSection.test.tsx +++ b/src/renderer/src/components/settings/RepositoryWorktreeDefaultsSection.test.tsx @@ -64,7 +64,7 @@ afterEach(() => { function render( repo: Repo, - updateRepo: (repoId: string, updates: object) => void | Promise, + updateRepo: React.ComponentProps['updateRepo'], options: { settings?: Pick | null refreshRepo?: (repoId: string) => void | Promise diff --git a/src/renderer/src/components/sidebar/worktree-agent-orchestration-index.test.ts b/src/renderer/src/components/sidebar/worktree-agent-orchestration-index.test.ts index d97a7d41906..452cec1f813 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-orchestration-index.test.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-orchestration-index.test.ts @@ -306,7 +306,10 @@ describe('selectWorktreeAgentOrchestration', () => { } let liveReads = 0 let retainedReads = 0 - const countReads = (target: object, onRead: () => void): object => + const countReads = ( + target: Record, + onRead: () => void + ): Record => new Proxy(target, { get(source, key, receiver) { if (typeof key === 'string') { diff --git a/src/renderer/src/components/task-page-github-work-item-quiet-state.ts b/src/renderer/src/components/task-page-github-work-item-quiet-state.ts index e1862e51b56..2d9891020b5 100644 --- a/src/renderer/src/components/task-page-github-work-item-quiet-state.ts +++ b/src/renderer/src/components/task-page-github-work-item-quiet-state.ts @@ -1,5 +1,8 @@ import { taskPageGitHubFamilyDirtyKey } from './task-page-github-work-item-mutation-keys' +/** Identity token for the caller driving one quiet run; compared by reference, never read. */ +export type QuietRevalidateRunOwner = Record + export type QuietRevalidateState = { inFlight: boolean trailingQueued: boolean @@ -10,7 +13,7 @@ export type QuietRevalidateState = { networkFailureAttempts: number lastConfirmAt: number runGeneration: number - runOwner: object | null + runOwner: QuietRevalidateRunOwner | null } const quietByQueryKey = new Map() @@ -37,7 +40,7 @@ export function getOrCreateQuietRevalidateState(queryKey: string): QuietRevalida export function beginTaskPageQuietRevalidateRun( state: QuietRevalidateState, - owner: object + owner: QuietRevalidateRunOwner ): number | null { if (state.inFlight && state.runOwner === owner) { state.trailingQueued = true @@ -52,7 +55,7 @@ export function beginTaskPageQuietRevalidateRun( export function finishTaskPageQuietRevalidateRun( state: QuietRevalidateState, - owner: object, + owner: QuietRevalidateRunOwner, generation: number ): boolean { if (state.runOwner !== owner || state.runGeneration !== generation) { diff --git a/src/renderer/src/components/terminal-pane/hidden-output-restore-scheduler.ts b/src/renderer/src/components/terminal-pane/hidden-output-restore-scheduler.ts index df22453e59d..8d4ca1fb33e 100644 --- a/src/renderer/src/components/terminal-pane/hidden-output-restore-scheduler.ts +++ b/src/renderer/src/components/terminal-pane/hidden-output-restore-scheduler.ts @@ -1,3 +1,9 @@ +import type { Terminal } from '@xterm/xterm' + +/** The pane's terminal, used only as the queue's identity key — no member is ever read, so a + * bare stand-in is a valid target. */ +type HiddenOutputRestoreTarget = Partial + type HiddenOutputRestorePriority = 'active' | 'inactive' /** Returns whether the pane actually started a replay; a guard-only return is free. */ @@ -11,7 +17,7 @@ type HiddenOutputRestoreEntry = { // on the active pane while still catching watched split panes up quickly. const INACTIVE_RESTORE_INTERVAL_MS = 16 -const inactiveRestoreQueue = new Map() +const inactiveRestoreQueue = new Map() let inactiveRestoreTimer: ReturnType | null = null function clearInactiveRestoreTimer(): void { @@ -51,7 +57,7 @@ function drainInactiveRestoreQueue(): void { } export function scheduleHiddenOutputRestore( - target: object, + target: HiddenOutputRestoreTarget, requestRestore: HiddenOutputRestoreRequest, priority: HiddenOutputRestorePriority ): void { @@ -64,7 +70,7 @@ export function scheduleHiddenOutputRestore( scheduleInactiveRestoreDrain() } -export function cancelScheduledHiddenOutputRestore(target: object): void { +export function cancelScheduledHiddenOutputRestore(target: HiddenOutputRestoreTarget): void { inactiveRestoreQueue.delete(target) if (inactiveRestoreQueue.size === 0) { clearInactiveRestoreTimer() diff --git a/src/renderer/src/components/terminal-pane/pty-renderer-delivery-claims.ts b/src/renderer/src/components/terminal-pane/pty-renderer-delivery-claims.ts index 57644da8a83..9296809816b 100644 --- a/src/renderer/src/components/terminal-pane/pty-renderer-delivery-claims.ts +++ b/src/renderer/src/components/terminal-pane/pty-renderer-delivery-claims.ts @@ -5,7 +5,14 @@ const hiddenClaimCounts = new Map() type VisibilityClaim = { ptyId: string; visible: boolean } -const visibilityClaimsByOwner = new Map() +declare const visibilityClaimOwnerBrand: unique symbol + +/** The mounted transport holding a claim; only its reference is ever compared. */ +export type RendererPtyVisibilityClaimOwner = object & { + readonly [visibilityClaimOwnerBrand]?: never +} + +const visibilityClaimsByOwner = new Map() const visibleClaimCounts = new Map() function sendHiddenState(ptyId: string, hidden: boolean): void { @@ -72,7 +79,7 @@ function removeVisibleClaim(claim: VisibilityClaim): boolean { * a retiring pane from hiding a PTY after its replacement has already bound. */ export function setRendererPtyVisibilityClaim( - owner: object, + owner: RendererPtyVisibilityClaimOwner, ptyId: string, visible: boolean ): void { @@ -103,7 +110,7 @@ export function setRendererPtyVisibilityClaim( } } -export function releaseRendererPtyVisibilityClaim(owner: object): void { +export function releaseRendererPtyVisibilityClaim(owner: RendererPtyVisibilityClaimOwner): void { const previous = visibilityClaimsByOwner.get(owner) if (!previous) { return diff --git a/src/renderer/src/components/terminal-pane/terminal-captured-input-dispatch.ts b/src/renderer/src/components/terminal-pane/terminal-captured-input-dispatch.ts index 8f96ba80efb..7a2780a5d32 100644 --- a/src/renderer/src/components/terminal-pane/terminal-captured-input-dispatch.ts +++ b/src/renderer/src/components/terminal-pane/terminal-captured-input-dispatch.ts @@ -1,3 +1,4 @@ +import type { IDisposable } from '@xterm/xterm' import type { PtyTransport } from './pty-transport' type CapturedTerminalInputDispatch = { @@ -38,8 +39,9 @@ export function sendCapturedTerminalInput({ return sent } +/** currentBinding arrives as the pane's raw xterm binding; only its identity is read. */ export function requestCapturedTerminalReconfirmation( - currentBinding: object | undefined, + currentBinding: IDisposable | TerminalCapturedInputBinding | undefined, capturedBinding: TerminalCapturedInputBinding | undefined ): void { if (currentBinding === capturedBinding) { diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-xterm-adversarial.test.ts b/src/renderer/src/components/terminal-pane/terminal-ime-xterm-adversarial.test.ts index 94dbb221e43..394951d1a4e 100644 --- a/src/renderer/src/components/terminal-pane/terminal-ime-xterm-adversarial.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-ime-xterm-adversarial.test.ts @@ -197,12 +197,16 @@ describe.each([ terminal.dispose() }) + /** The handle this suite's setTimeout stub hands back; only its identity is compared. */ + type FakeTimerToken = Record + it('keeps newer timer slots when canceled callbacks are forced', () => { const { terminal, textarea } = openTerminal(TerminalType) const callbacks: (() => void)[] = [] - const cleared = new Set() + const cleared = new Set() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub hands back an identity token instead of a real timer handle, which `typeof setTimeout` cannot express; only the clearTimeout stub below ever receives it. vi.spyOn(globalThis, 'setTimeout').mockImplementation(((callback: () => void) => { - const token = {} + const token: FakeTimerToken = {} callbacks.push(() => { if (!cleared.has(token)) { callback() @@ -210,7 +214,8 @@ describe.each([ }) return token }) as typeof setTimeout) - vi.spyOn(globalThis, 'clearTimeout').mockImplementation(((token: object) => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the matching stub for the setTimeout token above; `typeof clearTimeout` declares a real timer handle this suite never creates. + vi.spyOn(globalThis, 'clearTimeout').mockImplementation(((token: FakeTimerToken) => { cleared.add(token) }) as typeof clearTimeout) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-lifecycle-primitives.ts b/src/renderer/src/components/terminal-pane/terminal-pane-lifecycle-primitives.ts index d3d14e99204..d569e265098 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-lifecycle-primitives.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-lifecycle-primitives.ts @@ -1,5 +1,6 @@ import type { Terminal } from '@xterm/xterm' import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' +import type { PtyPaneStartup } from './pty-connection-types' import type { PtyTransport } from './pty-transport' import type { PaneCwdMap } from './resolve-split-cwd' import { writeTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-scheduler' @@ -180,15 +181,15 @@ export function resolveTerminalHomePathFromEnv( } export function paneOwnsQueuedStartup( - paneStartup: object | null | undefined, - queuedStartup: object | null | undefined + paneStartup: PtyPaneStartup | null | undefined, + queuedStartup: PtyPaneStartup | null | undefined ): boolean { return queuedStartup != null && paneStartup === queuedStartup } export function createQueuedStartupConsumer( - paneStartup: object | null | undefined, - queuedStartup: object | null | undefined, + paneStartup: PtyPaneStartup | null | undefined, + queuedStartup: PtyPaneStartup | null | undefined, consume: () => void, isStillQueued: () => boolean ): (() => void) | undefined { diff --git a/src/renderer/src/components/use-task-page-github-quiet-refresh.ts b/src/renderer/src/components/use-task-page-github-quiet-refresh.ts index 50267617a13..3359dd346f6 100644 --- a/src/renderer/src/components/use-task-page-github-quiet-refresh.ts +++ b/src/renderer/src/components/use-task-page-github-quiet-refresh.ts @@ -1,6 +1,7 @@ import type { TaskPageGitHubLandingRefreshModel } from './use-task-page-github-landing-refresh' import { useMountedRef } from '@/hooks/useMountedRef' import { useRef } from 'react' +import type { QuietRevalidateRunOwner } from '@/components/task-page-github-work-item-quiet-state' import { advanceTaskPageQuietRevalidateScope } from '@/components/task-page-github-work-item-mutations' import { useTaskPageGitHubQuietRefreshEffect } from './use-task-page-github-quiet-refresh-effect' export type TaskPageGitHubQuietRefreshPreludeModel = ReturnType< @@ -12,7 +13,7 @@ export function useTaskPageGitHubQuietRefreshPrelude(model: TaskPageGitHubLandin // shared quietState (inFlight/trailingQueued), so a nonce-triggered re-render // must NOT cancel the in-flight run's trailing bookkeeping. const quietRevalidateMountedRef = useMountedRef() - const quietRevalidateOwnerRef = useRef({}) + const quietRevalidateOwnerRef = useRef({}) const quietRevalidateScopeRef = useRef({ queryKey: githubWorkItemMutationQueryKey, generation: 0 diff --git a/src/renderer/src/hooks/useSettingsNavigationMetadata.capability-owner.test.tsx b/src/renderer/src/hooks/useSettingsNavigationMetadata.capability-owner.test.tsx index fc0e4e04464..733716d2482 100644 --- a/src/renderer/src/hooks/useSettingsNavigationMetadata.capability-owner.test.tsx +++ b/src/renderer/src/hooks/useSettingsNavigationMetadata.capability-owner.test.tsx @@ -6,6 +6,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getDefaultSettings } from '../../../shared/constants' import type { GlobalSettings } from '../../../shared/global-settings-types' import type { SettingsNavSection } from '@/lib/settings-navigation-types' +import type { RuntimeEnvironmentStatus } from '@/store/slices/runtime-status-types' +import type { Repo } from '../../../shared/repo-types' import { resetWindowsTerminalCapabilitiesForTests } from '@/lib/windows-terminal-capabilities' const testState = vi.hoisted(() => ({ @@ -14,8 +16,16 @@ const testState = vi.hoisted(() => ({ runtimeEnvironments: [] as { id: string; createdAt: number; pairingRevision?: number }[] })) +/** Only the store fields this screen's selectors read; the mock supplies nothing else. */ +type MockedSettingsNavState = { + settings: GlobalSettings | null + repos: Repo[] + runtimeEnvironments: typeof testState.runtimeEnvironments + runtimeStatusByEnvironmentId: Map +} + vi.mock('@/store', () => ({ - useAppStore: (selector: (state: object) => unknown) => + useAppStore: (selector: (state: MockedSettingsNavState) => unknown) => selector({ settings: testState.settings, repos: [], diff --git a/src/renderer/src/hooks/useWindowsTerminalCapabilityOwnerKey.test.tsx b/src/renderer/src/hooks/useWindowsTerminalCapabilityOwnerKey.test.tsx index 0b8a177016f..d3adc9bfe83 100644 --- a/src/renderer/src/hooks/useWindowsTerminalCapabilityOwnerKey.test.tsx +++ b/src/renderer/src/hooks/useWindowsTerminalCapabilityOwnerKey.test.tsx @@ -15,7 +15,7 @@ const testState = vi.hoisted(() => ({ })) vi.mock('@/store', () => ({ - useAppStore: (selector: (state: object) => unknown) => selector(testState) + useAppStore: (selector: (state: typeof testState) => unknown) => selector(testState) })) vi.mock('@/lib/web-client-location', () => ({ diff --git a/src/renderer/src/i18n/technical-literal-catalog-values.test.ts b/src/renderer/src/i18n/technical-literal-catalog-values.test.ts index 5cf9963175b..c8e24528c5f 100644 --- a/src/renderer/src/i18n/technical-literal-catalog-values.test.ts +++ b/src/renderer/src/i18n/technical-literal-catalog-values.test.ts @@ -53,7 +53,7 @@ const repairedEntries = [ const catalogs = { es, ko, zh } as const -function readValue(catalog: object, key: string): unknown { +function readValue(catalog: Record, key: string): unknown { return key.split('.').reduce((value, part) => { if (typeof value !== 'object' || value === null || Array.isArray(value)) { return undefined diff --git a/src/renderer/src/lib/ime-composition-keyboard-event.ts b/src/renderer/src/lib/ime-composition-keyboard-event.ts index 280a26476b2..554a0dcd565 100644 --- a/src/renderer/src/lib/ime-composition-keyboard-event.ts +++ b/src/renderer/src/lib/ime-composition-keyboard-event.ts @@ -13,14 +13,16 @@ type ImeModifierGestureEvent = ImeKeyboardEvent & { shiftKey?: boolean } -/** True when the IME, rather than Orca, owns a keyboard event. */ -export function isImeOwnedKeyboardEvent(event: object): boolean { - const candidate = event as ImeKeyboardEvent +/** True when the IME, rather than Orca, owns a keyboard event. Generic so synthetic, native, and + * gesture events each pass their own richer shape. */ +export function isImeOwnedKeyboardEvent( + event: KeyEvent +): boolean { return ( - candidate.isComposing === true || - candidate.keyCode === 229 || - candidate.nativeEvent?.isComposing === true || - candidate.nativeEvent?.keyCode === 229 + event.isComposing === true || + event.keyCode === 229 || + event.nativeEvent?.isComposing === true || + event.nativeEvent?.keyCode === 229 ) } diff --git a/src/renderer/src/lib/pane-manager/pane-cursor-blink-suspension.test.ts b/src/renderer/src/lib/pane-manager/pane-cursor-blink-suspension.test.ts index 66dc3fc872b..4d0696a2012 100644 --- a/src/renderer/src/lib/pane-manager/pane-cursor-blink-suspension.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-cursor-blink-suspension.test.ts @@ -87,7 +87,10 @@ function renderedText(pane: TestPane): string { } /** Reveal = the manager's resume pass, then the terminal regains real DOM focus. */ -async function reveal(panes: TestPane[], owner?: object): Promise { +async function reveal( + panes: TestPane[], + owner?: Parameters[1] +): Promise { resumePaneRendering(panes, owner) for (const pane of panes) { pane.terminal.focus() diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts index e84638c33ea..d613cac4a3c 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ITerminalAddon } from '@xterm/xterm' import { WebglAddon } from '@xterm/addon-webgl' import type { ManagedPaneInternal } from './pane-manager-types' import { @@ -508,6 +509,7 @@ describe('openTerminal — addon and provider wiring', () => { }) ) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a hand-built stand-in for xterm's Terminal; openTerminal touches only the members defined here, and a real Terminal needs a rendering canvas this suite has no DOM for. const terminal = { element: fakeTerminalElement, textarea: null, @@ -516,7 +518,7 @@ describe('openTerminal — addon and provider wiring', () => { open: vi.fn(() => { events.push('open') }), - loadAddon: vi.fn((addon: object) => { + loadAddon: vi.fn((addon: ITerminalAddon) => { if (addon === fitAddon) { events.push('loadAddon:fit') } else if (addon === searchAddon) { diff --git a/src/renderer/src/lib/pane-manager/pane-manager-types.ts b/src/renderer/src/lib/pane-manager/pane-manager-types.ts index a26be1e3a9f..341e40a891b 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager-types.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager-types.ts @@ -10,6 +10,8 @@ import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { TerminalLeafId } from '../../../../shared/stable-pane-id' import type { TerminalWebglAutoDecision } from './terminal-webgl-auto-policy' +export type { TerminalScrollIntentTarget } from './terminal-scroll-intent' + // --------------------------------------------------------------------------- // Public interfaces // --------------------------------------------------------------------------- diff --git a/src/renderer/src/lib/pane-manager/pane-rendering-control.ts b/src/renderer/src/lib/pane-manager/pane-rendering-control.ts index 219f17f4be6..114a0b850ee 100644 --- a/src/renderer/src/lib/pane-manager/pane-rendering-control.ts +++ b/src/renderer/src/lib/pane-manager/pane-rendering-control.ts @@ -21,7 +21,8 @@ import { } from './pane-webgl-reattach' import { releaseHiddenWebglRetention, - tryRetainHiddenPanesWebgl + tryRetainHiddenPanesWebgl, + type HiddenWebglRetentionOwner } from './terminal-webgl-hidden-retention' export function setPaneGpuRenderingState( @@ -59,7 +60,10 @@ export function markPaneComplexScriptOutput( export function suspendPaneRendering( panes: Iterable, - retention?: { owner: object; livePanes: () => Iterable } + retention?: { + owner: HiddenWebglRetentionOwner + livePanes: () => Iterable + } ): void { const suspended = Array.from(panes) // Why: both branches must leave a suspended pane in the same state; only the retention @@ -89,7 +93,7 @@ export function suspendPaneRendering( export function resumePaneRendering( panes: Iterable, - retentionOwner?: object + retentionOwner?: HiddenWebglRetentionOwner ): void { if (retentionOwner) { releaseHiddenWebglRetention(retentionOwner) diff --git a/src/renderer/src/lib/pane-manager/pane-scroll.ts b/src/renderer/src/lib/pane-manager/pane-scroll.ts index bb79faa4556..5945e7257c2 100644 --- a/src/renderer/src/lib/pane-manager/pane-scroll.ts +++ b/src/renderer/src/lib/pane-manager/pane-scroll.ts @@ -1,5 +1,5 @@ import type { Terminal } from '@xterm/xterm' -import type { ScrollState } from './pane-manager-types' +import type { ScrollState, TerminalScrollIntentTarget } from './pane-manager-types' import { captureLogicalLineAnchor, resolveLogicalCellOffsetLine @@ -8,7 +8,7 @@ import { forceTerminalViewportScrollbarSync } from './terminal-viewport-scrollba const terminalOutputEpochs = new WeakMap() const deferredScrollRestores = new WeakMap< - object, + TerminalScrollIntentTarget, { cancelled: boolean rafIds: number[] @@ -17,7 +17,7 @@ const deferredScrollRestores = new WeakMap< } >() const pendingFitScrollRestores = new WeakMap< - object, + TerminalScrollIntentTarget, { cancelled: boolean rafId: number | null @@ -38,7 +38,7 @@ export function getTerminalOutputEpoch(terminal: Terminal): number { return terminalOutputEpochs.get(terminal) ?? 0 } -export function cancelDeferredScrollRestore(terminal: object): void { +export function cancelDeferredScrollRestore(terminal: TerminalScrollIntentTarget): void { cancelPendingFitScrollRestore(terminal) const pending = deferredScrollRestores.get(terminal) if (!pending) { @@ -323,7 +323,7 @@ export function releaseScrollStateMarker(state: ScrollState): void { state.firstVisibleLineMarker = state.firstVisibleLogicalLineMarker = undefined } -function cancelPendingFitScrollRestore(terminal: object): void { +function cancelPendingFitScrollRestore(terminal: TerminalScrollIntentTarget): void { const pending = pendingFitScrollRestores.get(terminal) if (!pending) { return diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-output-ack-credit.ts b/src/renderer/src/lib/pane-manager/pane-terminal-output-ack-credit.ts index a245f2d82b9..7ba4c418f1f 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-output-ack-credit.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-output-ack-credit.ts @@ -1,4 +1,7 @@ -type TerminalOutputAckTarget = object +import type { ForegroundTerminalOutputTarget } from './pane-terminal-foreground-render-settle' + +/** The xterm instance the credits belong to; only its reference is used as a key. */ +type TerminalOutputAckTarget = ForegroundTerminalOutputTarget const inFlightAckCompletions = new WeakMap void>>() diff --git a/src/renderer/src/lib/pane-manager/terminal-parsed-dirty-rows.ts b/src/renderer/src/lib/pane-manager/terminal-parsed-dirty-rows.ts index 0b0185035c3..829075d11dd 100644 --- a/src/renderer/src/lib/pane-manager/terminal-parsed-dirty-rows.ts +++ b/src/renderer/src/lib/pane-manager/terminal-parsed-dirty-rows.ts @@ -15,6 +15,13 @@ export type ParsedDirtyRowSpan = { start: number; end: number } type RequestRefreshRowsEvent = { start: number; end: number } | undefined +/** + * A terminal instance, used as the span cache's identity. Not `ParsedDirtyRowSource`: callers hold + * their own partial structural views of the same xterm terminal, so the internals below stay a + * defensive probe rather than a requirement on the caller's type. + */ +export type ParsedDirtyRowTerminal = WeakKey + type ParsedDirtyRowSource = { _core?: { _inputHandler?: { @@ -35,9 +42,9 @@ type ParsedDirtyRowTracker = { // `null` marks a terminal whose parse spans cannot be observed, so callers keep // the full-grid behavior instead of narrowing on an absent signal. -const trackersByTerminal = new WeakMap() +const trackersByTerminal = new WeakMap() -function attachTracker(terminal: object): ParsedDirtyRowTracker | null { +function attachTracker(terminal: ParsedDirtyRowTerminal): ParsedDirtyRowTracker | null { const existing = trackersByTerminal.get(terminal) if (existing !== undefined) { return existing @@ -82,7 +89,7 @@ function attachTracker(terminal: object): ParsedDirtyRowTracker | null { } /** Start (or reset) parse-span observation for the write that is about to run. */ -export function resetParsedDirtyRows(terminal: object): void { +export function resetParsedDirtyRows(terminal: ParsedDirtyRowTerminal): void { const tracker = attachTracker(terminal) if (!tracker) { return @@ -98,7 +105,9 @@ export function resetParsedDirtyRows(terminal: object): void { * unknown (unobservable terminal, no parse seen, or an xterm full-refresh * request) and the caller must repaint the whole viewport. */ -export function readParsedDirtyRowSpan(terminal: object): ParsedDirtyRowSpan | null { +export function readParsedDirtyRowSpan( + terminal: ParsedDirtyRowTerminal +): ParsedDirtyRowSpan | null { const tracker = trackersByTerminal.get(terminal) if (!tracker || !tracker.observed || tracker.wholeViewport) { return null @@ -106,7 +115,7 @@ export function readParsedDirtyRowSpan(terminal: object): ParsedDirtyRowSpan | n return { start: tracker.start, end: tracker.end } } -export function disposeParsedDirtyRows(terminal: object): void { +export function disposeParsedDirtyRows(terminal: ParsedDirtyRowTerminal): void { const tracker = trackersByTerminal.get(terminal) if (tracker) { try { diff --git a/src/renderer/src/lib/pane-manager/terminal-scroll-intent-rebuild.ts b/src/renderer/src/lib/pane-manager/terminal-scroll-intent-rebuild.ts index 3de8e79a9f1..8ecc03617f5 100644 --- a/src/renderer/src/lib/pane-manager/terminal-scroll-intent-rebuild.ts +++ b/src/renderer/src/lib/pane-manager/terminal-scroll-intent-rebuild.ts @@ -1,15 +1,17 @@ +import type { TerminalScrollIntentTarget } from './terminal-scroll-intent' + // Why: buffer rebuilds (snapshot replay clear + rewrite) parse asynchronously. // Until the rebuild's bytes have parsed, viewportY/baseY describe a transient // half-cleared buffer; any intent capture/enforce latched from it pins the // terminal at line 0. Callers bracket the rebuild and re-apply intent once // after parse (see terminal-scroll-intent.ts). -const terminalScrollIntentRebuilds = new WeakMap() +const terminalScrollIntentRebuilds = new WeakMap() const terminalScrollIntentRebuildCompletions = new WeakMap< - object, + TerminalScrollIntentTarget, Set<(completed: boolean) => void> >() const deferredTerminalGeometryMutations = new WeakMap< - object, + TerminalScrollIntentTarget, { mutations: Map void> } @@ -30,11 +32,11 @@ function notifyRebuildCompletions( } } -export function beginTerminalScrollIntentBufferRebuild(terminal: object): void { +export function beginTerminalScrollIntentBufferRebuild(terminal: TerminalScrollIntentTarget): void { terminalScrollIntentRebuilds.set(terminal, (terminalScrollIntentRebuilds.get(terminal) ?? 0) + 1) } -export function endTerminalScrollIntentBufferRebuild(terminal: object): void { +export function endTerminalScrollIntentBufferRebuild(terminal: TerminalScrollIntentTarget): void { const count = terminalScrollIntentRebuilds.get(terminal) ?? 0 if (count <= 1) { terminalScrollIntentRebuilds.delete(terminal) @@ -46,12 +48,14 @@ export function endTerminalScrollIntentBufferRebuild(terminal: object): void { terminalScrollIntentRebuilds.set(terminal, count - 1) } -export function isTerminalScrollIntentRebuildInFlight(terminal: object): boolean { +export function isTerminalScrollIntentRebuildInFlight( + terminal: TerminalScrollIntentTarget +): boolean { return (terminalScrollIntentRebuilds.get(terminal) ?? 0) > 0 } export function onTerminalScrollIntentBufferRebuildComplete( - terminal: object, + terminal: TerminalScrollIntentTarget, completion: (completed: boolean) => void ): () => void { if (!isTerminalScrollIntentRebuildInFlight(terminal)) { @@ -75,7 +79,7 @@ export function onTerminalScrollIntentBufferRebuildComplete( // Why: source-dimension replay must finish and restore its viewport before // unrelated fit/resize work is allowed to reflow the rebuilt buffer. export function deferTerminalGeometryMutationDuringRebuild( - terminal: object, + terminal: TerminalScrollIntentTarget, operationKey: string, mutation: () => void ): boolean { @@ -117,7 +121,9 @@ export function deferTerminalGeometryMutationDuringRebuild( return true } -export function cancelTerminalScrollIntentBufferRebuildCompletions(terminal: object): void { +export function cancelTerminalScrollIntentBufferRebuildCompletions( + terminal: TerminalScrollIntentTarget +): void { const completions = terminalScrollIntentRebuildCompletions.get(terminal) terminalScrollIntentRebuildCompletions.delete(terminal) notifyRebuildCompletions(completions, false) diff --git a/src/renderer/src/lib/pane-manager/terminal-webgl-hidden-retention.test.ts b/src/renderer/src/lib/pane-manager/terminal-webgl-hidden-retention.test.ts index 708beed44f5..0c7cad8f89d 100644 --- a/src/renderer/src/lib/pane-manager/terminal-webgl-hidden-retention.test.ts +++ b/src/renderer/src/lib/pane-manager/terminal-webgl-hidden-retention.test.ts @@ -6,7 +6,8 @@ import { releaseHiddenWebglRetention, resetHiddenWebglRetentionForTest, retainedHiddenWebglOwnerCountForTest, - tryRetainHiddenPanesWebgl + tryRetainHiddenPanesWebgl, + type HiddenWebglRetentionOwner } from './terminal-webgl-hidden-retention' function createPane(withAddon = true): ManagedPaneInternal { @@ -24,7 +25,7 @@ function createPane(withAddon = true): ManagedPaneInternal { } as unknown as ManagedPaneInternal } -function retentionFor(owner: object, panes: ManagedPaneInternal[]) { +function retentionFor(owner: HiddenWebglRetentionOwner, panes: ManagedPaneInternal[]) { return { owner, livePanes: () => panes } } diff --git a/src/renderer/src/lib/pane-manager/terminal-webgl-hidden-retention.ts b/src/renderer/src/lib/pane-manager/terminal-webgl-hidden-retention.ts index e4ca8fa63b0..bd467b6e71a 100644 --- a/src/renderer/src/lib/pane-manager/terminal-webgl-hidden-retention.ts +++ b/src/renderer/src/lib/pane-manager/terminal-webgl-hidden-retention.ts @@ -6,8 +6,11 @@ import { disposeWebgl } from './pane-webgl-renderer' // letting hidden worktrees grow that cost with the mounted-pane population. const MAX_RETAINED_HIDDEN_WEBGL_CONTEXTS = 6 +/** Identity of the surface whose hidden panes are retained; compared by reference only. */ +export type HiddenWebglRetentionOwner = WeakKey + type RetainedHiddenEntry = { - owner: object + owner: HiddenWebglRetentionOwner livePanes: () => Iterable } @@ -30,7 +33,7 @@ function disposeEntryContexts(entry: RetainedHiddenEntry): void { } } -function removeEntry(owner: object): void { +function removeEntry(owner: HiddenWebglRetentionOwner): void { const index = retainedEntries.findIndex((entry) => entry.owner === owner) if (index !== -1) { retainedEntries.splice(index, 1) @@ -43,7 +46,7 @@ function removeEntry(owner: object): void { * least-recently-hidden owners to stay under the context cap. */ export function tryRetainHiddenPanesWebgl( - owner: object, + owner: HiddenWebglRetentionOwner, livePanes: () => Iterable ): boolean { removeEntry(owner) @@ -68,7 +71,7 @@ export function tryRetainHiddenPanesWebgl( } /** Drop retention bookkeeping on reveal/destroy; never disposes live addons. */ -export function releaseHiddenWebglRetention(owner: object): void { +export function releaseHiddenWebglRetention(owner: HiddenWebglRetentionOwner): void { removeEntry(owner) } diff --git a/src/renderer/src/lib/pane-manager/terminal-write-pipeline-health.ts b/src/renderer/src/lib/pane-manager/terminal-write-pipeline-health.ts index 5c67e7138ea..cd0c0adb2be 100644 --- a/src/renderer/src/lib/pane-manager/terminal-write-pipeline-health.ts +++ b/src/renderer/src/lib/pane-manager/terminal-write-pipeline-health.ts @@ -20,26 +20,26 @@ export type UndeliverableWriteReason = 'write-stalled' | 'replay-wedged' type UndeliverableWriteHandler = (reason: UndeliverableWriteReason) => void -const handlersByTerminal = new WeakMap() -const certifiedDeadTerminals = new WeakSet() +const handlersByTerminal = new WeakMap() +const certifiedDeadTerminals = new WeakSet() // Why: wedge verdicts must distinguish "dead" from "alive but behind". A // generation avoids same-millisecond misses and wall-clock adjustments while // keeping the completion hot path constant-time and terminal-scoped. -const parseProgressGenerationByTerminal = new WeakMap() +const parseProgressGenerationByTerminal = new WeakMap() /** Report one parsed write completion for this terminal. */ -export function recordTerminalParseProgress(terminal: object): void { +export function recordTerminalParseProgress(terminal: WriteTarget): void { const nextGeneration = (parseProgressGenerationByTerminal.get(terminal) ?? 0) + 1 parseProgressGenerationByTerminal.set(terminal, nextGeneration) } /** Capture the current parse-progress generation for a later quiet-window check. */ -export function captureTerminalParseProgressGeneration(terminal: object): number { +export function captureTerminalParseProgressGeneration(terminal: WriteTarget): number { return parseProgressGenerationByTerminal.get(terminal) ?? 0 } /** Whether a write completion parsed after `generation` was captured. */ -export function hasTerminalParseProgressSince(terminal: object, generation: number): boolean { +export function hasTerminalParseProgressSince(terminal: WriteTarget, generation: number): boolean { return captureTerminalParseProgressGeneration(terminal) !== generation } @@ -51,11 +51,11 @@ type StallWatch = { mode: StallWatchMode } -const stallWatchByTerminal = new WeakMap() +const stallWatchByTerminal = new WeakMap() export const WRITE_PIPELINE_STALL_CHECK_MS = 10_000 -function certifyTerminalWritePipelineDead(terminal: object, expectedWatch?: StallWatch): void { +function certifyTerminalWritePipelineDead(terminal: WriteTarget, expectedWatch?: StallWatch): void { const watch = stallWatchByTerminal.get(terminal) // Why: a real parse can settle and remove the watch before a stale probe // deadline runs. Only the watch that armed that deadline may certify. @@ -75,7 +75,7 @@ function certifyTerminalWritePipelineDead(terminal: object, expectedWatch?: Stal } export function registerUndeliverableWriteHandler( - terminal: object, + terminal: WriteTarget, handler: UndeliverableWriteHandler ): () => void { handlersByTerminal.set(terminal, handler) @@ -88,7 +88,10 @@ export function registerUndeliverableWriteHandler( /** One notification per terminal instance: recovery replaces the xterm, so a * second notification for the same object is always a duplicate. */ -export function notifyUndeliverableWrite(terminal: object, reason: UndeliverableWriteReason): void { +export function notifyUndeliverableWrite( + terminal: WriteTarget, + reason: UndeliverableWriteReason +): void { if (certifiedDeadTerminals.has(terminal)) { return } @@ -102,7 +105,7 @@ export function notifyUndeliverableWrite(terminal: object, reason: Undeliverable } } -export function isTerminalWritePipelineCertifiedDead(terminal: object): boolean { +export function isTerminalWritePipelineCertifiedDead(terminal: WriteTarget): boolean { return certifiedDeadTerminals.has(terminal) } @@ -196,7 +199,7 @@ export function requestTerminalWritePipelineProbe( } /** Cancel a pending watch without claiming that any bytes parsed. */ -export function cancelTerminalWriteStallWatch(terminal: object): void { +export function cancelTerminalWriteStallWatch(terminal: WriteTarget): void { const watch = stallWatchByTerminal.get(terminal) if (!watch) { return @@ -206,7 +209,7 @@ export function cancelTerminalWriteStallWatch(terminal: object): void { } /** Write completed normally — the pipeline is healthy; drop any pending watch. */ -export function settleTerminalWriteStallWatch(terminal: object): void { +export function settleTerminalWriteStallWatch(terminal: WriteTarget): void { recordTerminalParseProgress(terminal) if (stallWatchByTerminal.get(terminal)?.mode === 'fifo-probe') { return @@ -216,11 +219,11 @@ export function settleTerminalWriteStallWatch(terminal: object): void { /** A synchronous terminal.write failure proves the pipeline cannot accept the * issued bytes. Recover immediately without reporting fake parse progress. */ -export function failTerminalWriteStallWatch(terminal: object): void { +export function failTerminalWriteStallWatch(terminal: WriteTarget): void { certifyTerminalWritePipelineDead(terminal) } -export function _resetWritePipelineHealthForTests(terminal?: object): void { +export function _resetWritePipelineHealthForTests(terminal?: WriteTarget): void { if (terminal) { const watch = stallWatchByTerminal.get(terminal) if (watch) { diff --git a/src/renderer/src/lib/react-commit-cascade-store-write-samples.test.ts b/src/renderer/src/lib/react-commit-cascade-store-write-samples.test.ts index 17489399883..11a545ae2cf 100644 --- a/src/renderer/src/lib/react-commit-cascade-store-write-samples.test.ts +++ b/src/renderer/src/lib/react-commit-cascade-store-write-samples.test.ts @@ -13,7 +13,7 @@ import { // Not an intersection with ErrorConstructor: both fields have to stay optional // so the absent-captureStackTrace platform can be simulated. type ErrorWithCapture = { - captureStackTrace?: (target: object, constructorOpt?: unknown) => void + captureStackTrace?: (target: { stack?: string }, constructorOpt?: unknown) => void stackTraceLimit?: number } const errorWithCapture = Error as unknown as ErrorWithCapture diff --git a/src/renderer/src/lib/react-commit-cascade-store-write-samples.ts b/src/renderer/src/lib/react-commit-cascade-store-write-samples.ts index ef8565255a8..6801a5c49b3 100644 --- a/src/renderer/src/lib/react-commit-cascade-store-write-samples.ts +++ b/src/renderer/src/lib/react-commit-cascade-store-write-samples.ts @@ -33,6 +33,9 @@ export const MAX_REPORTED_CHANGED_KEYS = 12 type SampledWrite = { stack?: string } +/** The wrapping `set` function: V8 elides it and every frame above it, so only identity matters. */ +export type ReactCommitCascadeWriteBoundary = (...args: never[]) => unknown + let storeWrites = 0 let samples: SampledWrite[] = [] let changedKeys: Set | null = null @@ -58,10 +61,12 @@ export function resetReactCommitCascadeWriteSamples(): void { /** * Call only while armed. `boundary` is the wrapping `set` function, so V8 elides - * our own frames and the first captured frame is the real caller. Typed as - * `object` because zustand's `set` is an overload set, not a plain signature. + * our own frames and the first captured frame is the real caller. */ -export function noteReactCommitCascadeStoreWrite(boundary: object, partial: unknown): void { +export function noteReactCommitCascadeStoreWrite( + boundary: ReactCommitCascadeWriteBoundary, + partial: unknown +): void { storeWrites += 1 // Why the write count and not samples.length: samples only grows where // Error.captureStackTrace exists, so that cap would never engage without it and @@ -76,23 +81,20 @@ export function noteReactCommitCascadeStoreWrite(boundary: object, partial: unkn changedKeys.add(key) } } - const capture = Error as ErrorConstructor & { - captureStackTrace?: (target: object, constructorOpt?: unknown) => void - stackTraceLimit?: number - } - if (typeof capture.captureStackTrace !== 'function') { + // Only V8 has it; a non-V8 host gets no samples rather than a synthesized stack. + if (typeof Error.captureStackTrace !== 'function') { return } - const previousLimit = capture.stackTraceLimit + const previousLimit = Error.stackTraceLimit const sample: SampledWrite = {} try { - capture.stackTraceLimit = CAPTURE_STACK_FRAME_LIMIT - capture.captureStackTrace(sample, boundary) + Error.stackTraceLimit = CAPTURE_STACK_FRAME_LIMIT + Error.captureStackTrace(sample, boundary) samples.push(sample) } catch { // Best-effort crash evidence only. } finally { - capture.stackTraceLimit = previousLimit + Error.stackTraceLimit = previousLimit } } diff --git a/src/renderer/src/lib/simulator-launch-coordination.ts b/src/renderer/src/lib/simulator-launch-coordination.ts index db363683385..fee47276361 100644 --- a/src/renderer/src/lib/simulator-launch-coordination.ts +++ b/src/renderer/src/lib/simulator-launch-coordination.ts @@ -87,7 +87,10 @@ export function dispatchManualSimulatorLaunchFailed(worktreeId: string, message: }) } -function dispatchManualSimulatorLaunchEvent(type: string, detail: object): void { +function dispatchManualSimulatorLaunchEvent( + type: string, + detail: { worktreeId: string; message?: string } +): void { if (typeof window === 'undefined') { return } diff --git a/src/renderer/src/lib/state-collection-byte-estimate.ts b/src/renderer/src/lib/state-collection-byte-estimate.ts index 2491826420b..8f1d28dc16f 100644 --- a/src/renderer/src/lib/state-collection-byte-estimate.ts +++ b/src/renderer/src/lib/state-collection-byte-estimate.ts @@ -149,7 +149,9 @@ function estimateValueBytes(value: unknown, depth: number, ctx: EstimateContext) if (ArrayBuffer.isView(value)) { return BYTES_OBJECT_BASE + value.byteLength } - return BYTES_OBJECT_BASE + estimatePlainObjectEntries(value, depth, ctx) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the typeof switch and null check above leave only a non-null, non-collection object here. + const plainObject = value as Record + return BYTES_OBJECT_BASE + estimatePlainObjectEntries(plainObject, depth, ctx) } function estimateArrayElements(value: unknown[], depth: number, ctx: EstimateContext): number { @@ -208,7 +210,11 @@ function estimateIterableEntries( : Math.round((sampledBytes / sampledCount + BYTES_ENTRY_OVERHEAD) * size) } -function estimatePlainObjectEntries(value: object, depth: number, ctx: EstimateContext): number { +function estimatePlainObjectEntries( + value: Record, + depth: number, + ctx: EstimateContext +): number { let ownCount = 0 const sampledKeys: string[] = [] const entryFloor = depth === 0 ? ENTRY_DESCENT_RESERVE : 0 @@ -232,7 +238,7 @@ function estimatePlainObjectEntries(value: object, depth: number, ctx: EstimateC let sampledBytes = 0 for (const key of sampledKeys) { sampledBytes += BYTES_STRING_BASE + key.length * BYTES_PER_CHAR - sampledBytes += estimateValueBytes((value as Record)[key], depth + 1, ctx) + sampledBytes += estimateValueBytes(value[key], depth + 1, ctx) } return sampledKeys.length === 0 ? 0 diff --git a/src/renderer/src/store/react-commit-cascade-write-probe.test.ts b/src/renderer/src/store/react-commit-cascade-write-probe.test.ts index 0c18706e976..34d9db8fb6e 100644 --- a/src/renderer/src/store/react-commit-cascade-write-probe.test.ts +++ b/src/renderer/src/store/react-commit-cascade-write-probe.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { create, type StoreApi, type UseBoundStore } from 'zustand' import { withReactCommitCascadeWriteProbe } from './react-commit-cascade-write-probe' +import type { ReactCommitCascadeWriteBoundary } from '@/lib/react-commit-cascade-store-write-samples' const { probe, noteWrite } = vi.hoisted(() => ({ probe: { armed: false }, @@ -8,7 +9,7 @@ const { probe, noteWrite } = vi.hoisted(() => ({ })) vi.mock('@/lib/react-commit-cascade-store-write-samples', () => ({ reactCommitCascadeWriteProbe: probe, - noteReactCommitCascadeStoreWrite: (boundary: object, partial: unknown) => + noteReactCommitCascadeStoreWrite: (boundary: ReactCommitCascadeWriteBoundary, partial: unknown) => noteWrite(boundary, partial) })) diff --git a/src/shared/browser-client-host-reconciliation-protocol.test.ts b/src/shared/browser-client-host-reconciliation-protocol.test.ts index 3d3fc526cc6..9ce314ee9b2 100644 --- a/src/shared/browser-client-host-reconciliation-protocol.test.ts +++ b/src/shared/browser-client-host-reconciliation-protocol.test.ts @@ -19,7 +19,10 @@ const inventoryPage = { state: 'active' as const } -const command = (reconciliationCommand: object) => ({ +/** Raw command input for the parser under test, including deliberately malformed shapes. */ +type RawReconciliationCommand = { type: string } & Record + +const command = (reconciliationCommand: RawReconciliationCommand) => ({ type: 'command' as const, authorityRuntimeId: 'runtime-a', authorityEpoch: 'epoch-new', diff --git a/src/shared/repro-7732-gitlab-job-id-dropped.test.ts b/src/shared/repro-7732-gitlab-job-id-dropped.test.ts index 2f108571dcf..72a460a7146 100644 --- a/src/shared/repro-7732-gitlab-job-id-dropped.test.ts +++ b/src/shared/repro-7732-gitlab-job-id-dropped.test.ts @@ -4,7 +4,7 @@ import type { GitLabPipelineJob } from './gitlab-types' // Repro for #7732: the Checks side panel can only ask for a GitLab job trace if the mapped // check row still carries the numeric GitLab job id (gitlab:jobTrace takes { jobId }). -function numericHandles(value: object): number[] { +function numericHandles(value: Record): number[] { return Object.values(value).filter((v): v is number => typeof v === 'number') }