chore: enforce exhaustive switch handling

This commit is contained in:
Neil
2026-05-30 13:18:12 -07:00
parent 36d326c3f9
commit 181741d769
38 changed files with 159 additions and 85 deletions
+4 -1
View File
@@ -39,7 +39,10 @@
"typescript/prefer-function-type": "error",
"typescript/prefer-includes": "error",
"typescript/prefer-optional-chain": "error",
"typescript/switch-exhaustiveness-check": "error",
"typescript/switch-exhaustiveness-check": [
"error",
{ "allowDefaultCaseForExhaustiveSwitch": false }
],
"curly": "error",
"no-unneeded-ternary": "error",
"no-useless-return": "error",
+19
View File
@@ -0,0 +1,19 @@
{
"$schema": "../node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript"],
"categories": {
"correctness": "off",
"suspicious": "off",
"pedantic": "off",
"perf": "off",
"style": "off",
"restriction": "off",
"nursery": "off"
},
"rules": {
"typescript/switch-exhaustiveness-check": [
"error",
{ "allowDefaultCaseForExhaustiveSwitch": false }
]
}
}
+2 -3
View File
@@ -10,10 +10,9 @@
"compilerOptions": {
"composite": true,
"jsx": "react-jsx",
"baseUrl": "..",
"paths": {
"@renderer/*": ["src/renderer/src/*"],
"@/*": ["src/renderer/src/*"]
"@renderer/*": ["../src/renderer/src/*"],
"@/*": ["../src/renderer/src/*"]
}
}
}
+3 -1
View File
@@ -11,7 +11,8 @@
"main": "./out/main/index.js",
"scripts": {
"format": "oxfmt --write .",
"lint": "oxlint && node config/scripts/check-styled-scrollbars.mjs",
"lint": "oxlint && pnpm run lint:switch-exhaustiveness && node config/scripts/check-styled-scrollbars.mjs",
"lint:switch-exhaustiveness": "oxlint --type-aware --config config/oxlint-switch-exhaustiveness.json src/main src/preload src/shared src/relay src/cli src/renderer/src config tests --quiet",
"prepare": "husky",
"test": "node config/scripts/ensure-native-runtime.mjs --runtime=node && vitest run --config config/vitest.config.ts",
"check:styled-scrollbars": "node config/scripts/check-styled-scrollbars.mjs",
@@ -157,6 +158,7 @@
"lint-staged": "^16.4.0",
"oxfmt": "^0.52.0",
"oxlint": "^1.67.0",
"oxlint-tsgolint": "0.23.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-grab": "^0.1.33",
+67 -2
View File
@@ -310,7 +310,10 @@ importers:
version: 0.52.0
oxlint:
specifier: ^1.67.0
version: 1.67.0
version: 1.67.0(oxlint-tsgolint@0.23.0)
oxlint-tsgolint:
specifier: 0.23.0
version: 0.23.0
react:
specifier: ^19.2.5
version: 19.2.5
@@ -1272,6 +1275,36 @@ packages:
cpu: [x64]
os: [win32]
'@oxlint-tsgolint/darwin-arm64@0.23.0':
resolution: {integrity: sha512-gOs9PVr2wEg4ox9z0aJo+RKhhImW86YL5N6yav8BK/rgPsIrwN/igSZ+pbRr723NFvUNKde9fgMhRA6JrXAOZw==}
cpu: [arm64]
os: [darwin]
'@oxlint-tsgolint/darwin-x64@0.23.0':
resolution: {integrity: sha512-kjJ8B+7n4tB9VJdxS5A9GdJt6/bYpzbu4lXp2uO1S3sRmCB5gDEABlGoiePNApRWaW+xqL4b4xgiE727jSLhuA==}
cpu: [x64]
os: [darwin]
'@oxlint-tsgolint/linux-arm64@0.23.0':
resolution: {integrity: sha512-6dCZuKNu135seMXilkRk9SpCx6i1XgmiipYGalLij5WVRX6ZYS8c4xI7preN/zv9fCXhsQclTIMDu2Y/cytTjw==}
cpu: [arm64]
os: [linux]
'@oxlint-tsgolint/linux-x64@0.23.0':
resolution: {integrity: sha512-3bdilnyA7kmSTjK27rvjIjSxL5SIg3wt7vwNiRkouWB83ytssyKnuGvxSYJxgMEmFpSutzaBzcCUM2jDtPGcgA==}
cpu: [x64]
os: [linux]
'@oxlint-tsgolint/win32-arm64@0.23.0':
resolution: {integrity: sha512-j+OEp44SVYiQ+ZD+uttsX7u6L9SvmbbQ77SO1pSFCcJlsVMeCk8qZsjhKfGKuT/jIA+ipOJMVs/+pqUfObBWNw==}
cpu: [arm64]
os: [win32]
'@oxlint-tsgolint/win32-x64@0.23.0':
resolution: {integrity: sha512-5MyjFuqf+g8OUPJBSGWHJtmoWnzFJYyOg4To9WMQshZYEWig/vtu7JtJ03VWnzHv9LJkAUeApY0gVCOywFR/iQ==}
cpu: [x64]
os: [win32]
'@oxlint/binding-android-arm-eabi@1.67.0':
resolution: {integrity: sha512-VrSi571rDv1N8HaEDM+DEX8nmT0y9jJo8tzzW13vsOWTx59xQczCIJx68n2zWOXRT5YKZsOZXp4qkHN/10x4mw==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -5165,6 +5198,10 @@ packages:
vite-plus:
optional: true
oxlint-tsgolint@0.23.0:
resolution: {integrity: sha512-3mBv3CoPbh8dFbzfDGIWa2ytZjn2v+3EX4aKRXjIhsoGFzG8GCjfRirz3rwZf1wYbZzsNLTSgpw8VjQuWdp/jA==}
hasBin: true
oxlint@1.67.0:
resolution: {integrity: sha512-blwwaHPdoH8piQ5/z0KHeoHFR7FZgl12WluKJfu4qFLPkZl6mK04PkLE45Fw1NxfBRSlh40Gu7MkxHUw++ociQ==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -7245,6 +7282,24 @@ snapshots:
'@oxfmt/binding-win32-x64-msvc@0.52.0':
optional: true
'@oxlint-tsgolint/darwin-arm64@0.23.0':
optional: true
'@oxlint-tsgolint/darwin-x64@0.23.0':
optional: true
'@oxlint-tsgolint/linux-arm64@0.23.0':
optional: true
'@oxlint-tsgolint/linux-x64@0.23.0':
optional: true
'@oxlint-tsgolint/win32-arm64@0.23.0':
optional: true
'@oxlint-tsgolint/win32-x64@0.23.0':
optional: true
'@oxlint/binding-android-arm-eabi@1.67.0':
optional: true
@@ -11489,7 +11544,16 @@ snapshots:
'@oxfmt/binding-win32-ia32-msvc': 0.52.0
'@oxfmt/binding-win32-x64-msvc': 0.52.0
oxlint@1.67.0:
oxlint-tsgolint@0.23.0:
optionalDependencies:
'@oxlint-tsgolint/darwin-arm64': 0.23.0
'@oxlint-tsgolint/darwin-x64': 0.23.0
'@oxlint-tsgolint/linux-arm64': 0.23.0
'@oxlint-tsgolint/linux-x64': 0.23.0
'@oxlint-tsgolint/win32-arm64': 0.23.0
'@oxlint-tsgolint/win32-x64': 0.23.0
oxlint@1.67.0(oxlint-tsgolint@0.23.0):
optionalDependencies:
'@oxlint/binding-android-arm-eabi': 1.67.0
'@oxlint/binding-android-arm64': 1.67.0
@@ -11510,6 +11574,7 @@ snapshots:
'@oxlint/binding-win32-arm64-msvc': 1.67.0
'@oxlint/binding-win32-ia32-msvc': 1.67.0
'@oxlint/binding-win32-x64-msvc': 1.67.0
oxlint-tsgolint: 0.23.0
p-cancelable@2.1.1: {}
@@ -58,6 +58,7 @@ export function mapAzureDevOpsMergeable(mergeStatus: string | null | undefined):
return 'MERGEABLE'
case 'conflicts':
return 'CONFLICTING'
case undefined:
default:
return 'UNKNOWN'
}
@@ -80,6 +81,7 @@ function classifyAzureDevOpsStatus(state: string | null | undefined): CheckStatu
case 'queued':
case 'running':
return 'pending'
case undefined:
default:
return 'neutral'
}
@@ -50,6 +50,7 @@ export function mapBitbucketPullRequestState(
case 'SUPERSEDED':
return 'closed'
case 'OPEN':
case undefined:
default:
return 'open'
}
+3 -1
View File
@@ -744,7 +744,9 @@ export function getUserAgentForBrowser(
const v = readBrowserVersion('/Applications/Comet.app')
return v ? `Mozilla/5.0 (${platform}) ${chromeBase} Chrome/${v} Safari/537.36` : null
}
default:
case 'firefox':
case 'safari':
case 'manual':
return null
}
}
+4 -3
View File
@@ -264,6 +264,9 @@ export class DaemonServer {
}
}
case 'cancelCreateOrAttach':
return {}
case 'write':
try {
this.lastInputAtBySessionId.set(request.payload.sessionId, performance.now())
@@ -330,10 +333,8 @@ export class DaemonServer {
}
process.nextTick(() => this.shutdown())
return {}
default:
throw new Error(`Unknown request type: ${(request as { type: string }).type}`)
}
throw new Error(`Unknown request type: ${(request as { type: string }).type}`)
}
private sendExitEvent(
+2
View File
@@ -285,6 +285,8 @@ export class HeadlessEmulator {
case 'any':
seqs.push('\x1b[?1003h')
break
case 'none':
break
}
// Why: xterm tracks the mouse protocol and SGR encoding as independent
// modes, so snapshots must preserve the encoding even when reporting is off.
+8 -1
View File
@@ -35,7 +35,14 @@ export function getGhosttyConfigPaths(): string[] {
// Why: path.win32.join preserves backslashes even when tests run on macOS/Linux.
return [path.win32.join(base, 'config'), path.win32.join(base, 'config.ghostty')]
}
default:
case 'aix':
case 'android':
case 'cygwin':
case 'freebsd':
case 'haiku':
case 'netbsd':
case 'openbsd':
case 'sunos':
return []
}
}
+1
View File
@@ -48,6 +48,7 @@ function classifyGiteaStatus(status: string | null | undefined): CheckStatus {
case 'pending':
return 'pending'
case 'skipped':
case undefined:
default:
return 'neutral'
}
+1
View File
@@ -274,6 +274,7 @@ export function normalizeFieldValue(
.filter((u): u is GitHubProjectUser => u !== null)
return { kind: 'users', fieldId, users }
}
case undefined:
default:
// Unknown __typename → forward-compat: drop silently, do not throw,
// do not classify as drift (see design §Error Handling).
+6 -10
View File
@@ -62,13 +62,10 @@ function graphqlValueForFieldMutation(value: GitHubProjectFieldMutationValue): s
return 'number: $value'
case 'date':
return 'date: $value'
default:
// Why: defensive default. If a new mutation kind is added to the type
// but not handled here, returning undefined would silently produce a
// broken GraphQL query. Throw so updateProjectItemFieldValue can map it
// to a validation_error rather than dispatching a malformed mutation.
throw new UnknownFieldMutationKindError((value as { kind: string }).kind)
}
// Why: keep a runtime guard for malformed IPC payloads while lint enforces
// that every typed mutation kind is handled above.
throw new UnknownFieldMutationKindError((value as { kind: string }).kind)
}
function mutationValueVar(value: GitHubProjectFieldMutationValue): {
@@ -86,11 +83,10 @@ function mutationValueVar(value: GitHubProjectFieldMutationValue): {
return { type: 'Float!', val: value.number }
case 'date':
return { type: 'Date!', val: value.date }
default:
// Why: see graphqlValueForFieldMutation — surface unknown kinds loudly
// instead of returning undefined and dispatching an invalid mutation.
throw new UnknownFieldMutationKindError((value as { kind: string }).kind)
}
// Why: see graphqlValueForFieldMutation — surface unknown kinds loudly
// instead of returning undefined and dispatching an invalid mutation.
throw new UnknownFieldMutationKindError((value as { kind: string }).kind)
}
export async function updateProjectItemFieldValue(
@@ -263,7 +263,9 @@ export class Coordinator {
case 'status':
this.opts.onLog(`Status from ${msg.from_handle}: ${msg.subject}`)
break
default:
case 'dispatch':
case 'handoff':
case 'merge_ready':
break
}
}
+5 -1
View File
@@ -62,7 +62,11 @@ function historyFilename(shell: ShellKind): string | null {
case 'bash':
return 'bash_history'
// Phase 2: fish and PowerShell use different mechanisms
default:
case 'fish':
case 'pwsh':
case 'powershell':
case 'cmd':
case 'unknown':
return null
}
}
@@ -1525,7 +1525,9 @@ function mapPRFileStatus(status: GitHubPRFile['status']): GitBranchChangeEntry['
return 'renamed'
case 'copied':
return 'copied'
default:
case 'changed':
case 'modified':
case 'unchanged':
return 'modified'
}
}
@@ -1532,7 +1532,9 @@ function mapPRFileStatus(status: GitHubPRFile['status']): GitBranchChangeEntry['
return 'renamed'
case 'copied':
return 'copied'
default:
case 'changed':
case 'modified':
case 'unchanged':
return 'modified'
}
}
@@ -22,9 +22,8 @@ function asDotState(state: AgentStatusState | 'idle'): AgentDotState {
case 'done':
case 'idle':
return state
default:
return 'idle'
}
return 'idle'
}
function formatTimeAgo(ts: number, now: number): string {
@@ -57,6 +57,8 @@ function formatReviewState(state: string | null | undefined): string {
return 'Dismissed'
case 'PENDING':
return 'Pending'
case null:
case undefined:
default:
return 'Reviewed'
}
@@ -2922,14 +2922,6 @@ function SourceControlInner(): React.JSX.Element {
case 'publish':
case 'rebase_base':
void runRemoteAction(kind === 'rebase_base' ? 'rebase' : kind)
return
default: {
// Why: exhaustiveness check — if a new DropdownActionKind is added
// to the union, TypeScript will flag this assignment so we can't
// silently drop a case.
const _exhaustive: never = kind
void _exhaustive
}
}
},
[
@@ -3263,11 +3255,6 @@ function SourceControlInner(): React.JSX.Element {
case 'publish':
case 'create_pr':
handleActionInvoke(primaryAction.kind)
return
default: {
const _exhaustive: never = primaryAction.kind
void _exhaustive
}
}
}, [handleActionInvoke, handleStageAllPrimary, primaryAction.kind])
@@ -71,7 +71,8 @@ export function getChecksPanelEmptyStateCopy(
title: 'No pull request found',
description: 'GitHub refresh is paused by the current rate-limit budget'
}
default:
case 'skipped':
case undefined:
return {
title: `No ${reviewLabel} found`,
description: `Create a ${reviewLabel} to start checks and review.`
@@ -9,7 +9,5 @@ export function getSourceControlActions(area: GitStagingArea): SourceControlActi
case 'unstaged':
case 'untracked':
return ['discard', 'stage']
default:
return []
}
}
@@ -69,9 +69,5 @@ export function getDiscardAreaConfirmationCopy(
: `This will revert unstaged changes in ${count} files. This cannot be undone.`,
confirmLabel: 'Discard all'
}
default: {
const _exhaustive: never = area
return _exhaustive
}
}
}
@@ -405,7 +405,8 @@ export function resolveDropdownItems(inputs: DropdownActionInputs): DropdownEntr
return 'A pull request already exists'
case 'fork_head_unsupported':
return 'Fork head unsupported'
default:
case null:
case undefined:
return upstreamLoading ? 'Checking branch status…' : 'Branch is not ready'
}
})()
@@ -60,7 +60,7 @@ function statusLabel(status: ComputerUsePermissionStatus | undefined): string {
case 'unsupported':
return 'macOS only'
case 'not-granted':
default:
case undefined:
return 'Not enabled'
}
}
@@ -111,7 +111,7 @@ function statusLabel(status: DeveloperPermissionStatus | undefined): string {
case 'ready':
return 'Entitled'
case 'unknown':
default:
case undefined:
return 'Check manually'
}
}
@@ -43,7 +43,7 @@ export function statusColor(status: SshConnectionStatus): string {
case 'reconnection-failed':
case 'error':
return 'bg-red-500'
default:
case 'disconnected':
return 'bg-muted-foreground/40'
}
}
@@ -25,7 +25,7 @@ export function checksLabel(status: CheckStatus): string {
return 'Failing'
case 'pending':
return 'Pending'
default:
case 'neutral':
return ''
}
}
@@ -103,10 +103,6 @@ export function buildWorktreeComparator(
(b.manualOrder ?? b.sortOrder) - (a.manualOrder ?? a.sortOrder) ||
a.displayName.localeCompare(b.displayName)
)
default: {
const _exhaustive: never = sortBy
return _exhaustive
}
}
}
}
@@ -15,9 +15,8 @@ function asDotState(state: AgentStatusState | 'idle'): AgentDotState {
case 'done':
case 'idle':
return state
default:
return 'idle'
}
return 'idle'
}
function getAgentDotState(agent: DashboardAgentRowData): AgentDotState {
@@ -48,7 +48,7 @@ function overallDotColor(status: 'connected' | 'partial' | 'disconnected' | 'con
return 'bg-yellow-500'
case 'connecting':
return 'bg-yellow-500'
default:
case 'disconnected':
return 'bg-muted-foreground/40'
}
}
@@ -61,7 +61,7 @@ function overallLabel(status: 'connected' | 'partial' | 'disconnected' | 'connec
return 'Partial'
case 'connecting':
return 'Connecting…'
default:
case 'disconnected':
return 'Disconnected'
}
}
@@ -80,7 +80,8 @@ function syncStatusLabel(status: RemoteWorkspaceSyncStatus | undefined): string
return 'Sync error'
case 'offline':
return 'Sync unavailable'
default:
case 'idle':
case undefined:
return 'Sync idle'
}
}
@@ -97,7 +98,8 @@ function syncStatusTone(status: RemoteWorkspaceSyncStatus | undefined): string {
return 'text-yellow-500'
case 'synced':
return 'text-emerald-500'
default:
case 'idle':
case undefined:
return 'text-muted-foreground'
}
}
+1 -1
View File
@@ -33,7 +33,7 @@ export function getTaskPresetQuery(presetId: TaskViewPresetId | null): string {
return 'author:@me is:pr is:open'
case 'review':
return 'review-requested:@me is:pr is:open'
default:
case null:
return 'is:issue is:open'
}
}
+1 -1
View File
@@ -88,7 +88,7 @@ export function getPRCommentAudienceEmptyLabel(filter: PRCommentAudienceFilter):
return 'No bot comments.'
case 'human':
return 'No human comments.'
default:
case 'all':
return 'No comments yet.'
}
}
+1 -2
View File
@@ -200,9 +200,8 @@ function optimisticFieldValueFromMutation(
return { kind: 'number', fieldId, number: value.number }
case 'date':
return { kind: 'date', fieldId, date: value.date }
default:
return null
}
return null
}
function applyRowPatch(
+1 -1
View File
@@ -155,7 +155,7 @@ function presetToQuery(presetId: TaskViewPresetId | null): string {
return 'review-requested:@me is:pr is:open'
case 'my-prs':
return 'author:@me is:pr is:open'
default:
case null:
return 'is:issue is:open'
}
}
-15
View File
@@ -1812,11 +1812,6 @@ function isNewTurnEvent(source: AgentHookSource, eventName: unknown): boolean {
}
case 'hermes':
return eventName === 'pre_llm_call' || eventName === 'on_session_start'
default: {
const _exhaustive: never = source
void _exhaustive
return false
}
}
}
@@ -1901,11 +1896,6 @@ function extractToolFields(
return extractCopilotToolFields(normalizeCopilotEventName(eventName), hookPayload)
case 'hermes':
return extractHermesToolFields(eventName, hookPayload)
default: {
const _exhaustive: never = source
void _exhaustive
return {}
}
}
}
@@ -2839,11 +2829,6 @@ export function normalizeHookPayload(
case 'hermes':
payload = normalizeHermesEvent(state, eventName, promptText, paneKey, hookPayloadRecord)
break
default: {
const _exhaustive: never = source
void _exhaustive
payload = null
}
}
// Why: connectionId stays null at the listener layer. The local server keeps
-2
View File
@@ -87,8 +87,6 @@ function deriveStringValue(value: GitHubProjectRow['fieldValuesByFieldId'][strin
return value.labels.map((l) => l.name).join(', ')
case 'users':
return value.users.map((u) => u.login).join(', ')
default:
return ''
}
}