diff --git a/apps/extension/.oxlintrc.json b/apps/extension/.oxlintrc.json index 74fbcfcd90..2d5e3cf3eb 100644 --- a/apps/extension/.oxlintrc.json +++ b/apps/extension/.oxlintrc.json @@ -46,11 +46,44 @@ "vitest/prefer-to-be-falsy": "off", "vitest/prefer-to-be-truthy": "off", "vitest/require-hook": "off", - "vitest/require-test-timeout": "off" + "vitest/require-test-timeout": "off", + "anti-slop/no-chained-type-assertions": "error", + "anti-slop/no-known-value-widening": "error", + "anti-slop/no-object-parameters": "error", + "anti-slop/no-reflect-apply": "error", + "anti-slop/no-reflect-get": "error", + "anti-slop/no-runtime-typeof": "error", + "anti-slop/no-unknown-returns": "error", + "zod-utils/no-inline-zod-schema": "error" }, "env": { "browser": true, "builtin": true, "node": true - } + }, + "jsPlugins": [ + { + "name": "anti-slop", + "specifier": "../../tools/oxlint/anti-slop/index.ts" + }, + { + "name": "zod-utils", + "specifier": "../../tools/oxlint/zod-utils.mjs" + } + ], + "overrides": [ + { + "files": ["**/*.test.ts", "**/*.test.tsx", "tests/**", "scripts/**"], + "rules": { + "anti-slop/no-chained-type-assertions": "off", + "anti-slop/no-known-value-widening": "off", + "anti-slop/no-object-parameters": "off", + "anti-slop/no-reflect-apply": "off", + "anti-slop/no-reflect-get": "off", + "anti-slop/no-runtime-typeof": "off", + "anti-slop/no-unknown-returns": "off", + "zod-utils/no-inline-zod-schema": "off" + } + } + ] } diff --git a/apps/extension/entrypoints/background.ts b/apps/extension/entrypoints/background.ts index 56865890ab..453039409b 100644 --- a/apps/extension/entrypoints/background.ts +++ b/apps/extension/entrypoints/background.ts @@ -1,5 +1,6 @@ /* eslint-disable max-lines */ import { storage } from '#imports'; +import { z } from 'zod'; import { buildPendingMemoryDraft } from '@/src/shared/agent-memories'; import { savePendingAgentMemoryDraft } from '@/src/shared/agent-memories-storage'; import { @@ -64,16 +65,31 @@ interface ChromeRuntimeApi { * extension `id` but reports the host page's web origin, while an extension page reports an * extension-scheme origin (`chrome-extension://` on Chrome, `moz-extension://` on Firefox). */ +const extensionSchemeSchema = z + .string() + .refine(value => value.startsWith('chrome-extension://') || value.startsWith('moz-extension://')); + const isExtensionScheme = (value: unknown): boolean => - typeof value === 'string' && - (value.startsWith('chrome-extension://') || value.startsWith('moz-extension://')); + extensionSchemeSchema.safeParse(value).success; + +const extensionSenderSchema = z.object({ + id: z.string().optional(), + origin: z.unknown().optional(), + url: z.unknown().optional(), +}); const isTrustedExtensionSender = (sender: unknown, runtimeId: string | undefined): boolean => { - if (runtimeId === undefined || typeof sender !== 'object' || sender === null) { + if (runtimeId === undefined) { + return false; + } + + const parsed = extensionSenderSchema.safeParse(sender); + + if (!parsed.success) { return false; } - const { id, origin, url } = sender as { id?: unknown; origin?: unknown; url?: unknown }; + const { id, origin, url } = parsed.data; // Same-extension is already pinned by `id === runtimeId`, so origin only separates an extension page from a content script. return id === runtimeId && (isExtensionScheme(origin) || isExtensionScheme(url)); diff --git a/apps/extension/entrypoints/sidepanel/agent-conversation-events.tsx b/apps/extension/entrypoints/sidepanel/agent-conversation-events.tsx index c1391f55c8..c3149a86ba 100644 --- a/apps/extension/entrypoints/sidepanel/agent-conversation-events.tsx +++ b/apps/extension/entrypoints/sidepanel/agent-conversation-events.tsx @@ -16,11 +16,16 @@ import { CollapsibleCodeBlock } from './collapsible-code-block.tsx'; const remarkPlugins = [remarkGfm]; const extractCodeText = (codeChildren: unknown): string | undefined => { + // oxlint-disable-next-line anti-slop/no-runtime-typeof -- Walks react-markdown's ReactNode children tree, not a parseable data contract. if (typeof codeChildren === 'string') { return codeChildren; } - if (Array.isArray(codeChildren) && codeChildren.every(part => typeof part === 'string')) { + if ( + Array.isArray(codeChildren) && + // oxlint-disable-next-line anti-slop/no-runtime-typeof -- Walks react-markdown's ReactNode children tree, not a parseable data contract. + codeChildren.every(part => typeof part === 'string') + ) { return codeChildren.join(''); } @@ -39,6 +44,7 @@ const extractCodeChild = ( children: ReactNode ): { readonly className: string | undefined; readonly code: string } | undefined => { for (const child of asNodeList(children)) { + // oxlint-disable-next-line anti-slop/no-runtime-typeof -- Walks a ReactNode children tree, not a parseable data contract. if (child === null || typeof child !== 'object') { // Skip non-element children (text nodes, null). } else if ( @@ -81,13 +87,17 @@ const assistantMarkdownComponentsStreaming = createAssistantMarkdownComponents(t const assistantMarkdownComponentsFinalized = createAssistantMarkdownComponents(false); const formatToolValue = (value: unknown): string => { + // oxlint-disable-next-line anti-slop/no-runtime-typeof -- Formats an arbitrary tool-result value by its JS runtime type; no fixed data contract to parse against. if (typeof value === 'string') { return value; } if ( + // oxlint-disable-next-line anti-slop/no-runtime-typeof -- Formats an arbitrary tool-result value by its JS runtime type; no fixed data contract to parse against. typeof value === 'number' || + // oxlint-disable-next-line anti-slop/no-runtime-typeof -- Formats an arbitrary tool-result value by its JS runtime type; no fixed data contract to parse against. typeof value === 'boolean' || + // oxlint-disable-next-line anti-slop/no-runtime-typeof -- Formats an arbitrary tool-result value by its JS runtime type; no fixed data contract to parse against. typeof value === 'bigint' || value === null ) { diff --git a/apps/extension/entrypoints/sidepanel/agent-conversation-schemas.ts b/apps/extension/entrypoints/sidepanel/agent-conversation-schemas.ts index 3a5904d693..9cd0558ef4 100644 --- a/apps/extension/entrypoints/sidepanel/agent-conversation-schemas.ts +++ b/apps/extension/entrypoints/sidepanel/agent-conversation-schemas.ts @@ -1,7 +1,17 @@ import { z } from 'zod'; -import type { RemoteMcpAgentToolName, WorkflowToolName } from '@/src/shared/agent-conversation'; import type { WebMcpGatewayToolName } from '@/src/shared/kilo-gateway-chat-client'; +const remoteMcpAgentToolNameSchema = z.templateLiteral(['mcp_', z.string()]); +const workflowToolNameSchema = z.enum([ + 'delete_workflow', + 'get_workflow', + 'run_workflow', + 'save_memory', + 'save_workflow', + 'search_workflows', +]); +const genericStringSchema = z.string(); + export const conversationEventSchema = z.union([ z.object({ id: z.string(), @@ -42,9 +52,7 @@ export const conversationEventSchema = z.union([ z.object({ arguments: z.record(z.string(), z.unknown()), id: z.string(), - name: z.custom( - value => typeof value === 'string' && value.startsWith('mcp_') - ), + name: remoteMcpAgentToolNameSchema, providerToolCallId: z.string().optional(), remoteToolName: z.string(), serverId: z.string(), @@ -54,18 +62,7 @@ export const conversationEventSchema = z.union([ z.object({ arguments: z.record(z.string(), z.unknown()), id: z.string(), - name: z.custom( - (value): value is WorkflowToolName => - typeof value === 'string' && - [ - 'delete_workflow', - 'get_workflow', - 'run_workflow', - 'save_memory', - 'save_workflow', - 'search_workflows', - ].includes(value) - ), + name: workflowToolNameSchema, providerToolCallId: z.string().optional(), tabId: z.number(), type: z.literal('tool-call'), @@ -75,7 +72,7 @@ export const conversationEventSchema = z.union([ definitionSignature: z.string(), documentId: z.string(), id: z.string(), - name: z.custom(value => typeof value === 'string'), + name: z.custom(value => genericStringSchema.safeParse(value).success), providerToolCallId: z.string().optional(), tabId: z.number(), type: z.literal('tool-call'), diff --git a/apps/extension/entrypoints/sidepanel/agent-safe-tool-runtime.ts b/apps/extension/entrypoints/sidepanel/agent-safe-tool-runtime.ts index 47bd031485..eceef49b77 100644 --- a/apps/extension/entrypoints/sidepanel/agent-safe-tool-runtime.ts +++ b/apps/extension/entrypoints/sidepanel/agent-safe-tool-runtime.ts @@ -145,23 +145,23 @@ const readViewportScreenshot = async (tabId: number): Promise => const getSnapshot = async ( tabId: number, options: { readonly query?: string; readonly textStart?: number } = {} -): Promise => { +): Promise<{ error: string; ok: false } | { ok: true; value: PageSnapshot }> => { const result = await readPageSnapshot(tabId, options); if (!result.ok) { - return result.error; + return { error: result.error, ok: false }; } const snapshot = pageSnapshotSchema.safeParse(result.value); if (!snapshot.success) { - return 'Page snapshot was invalid.'; + return { error: 'Page snapshot was invalid.', ok: false }; } const pageSnapshot = toPageSnapshot(snapshot.data); cacheSnapshot(tabId, pageSnapshot); - return pageSnapshot; + return { ok: true, value: pageSnapshot }; }; const searchableFields = ['text', 'label', 'href', 'role', 'tag'] as const; @@ -277,15 +277,17 @@ const runSafeToolCall = async ( } if (toolCall.name === 'get_page_snapshot') { - const snapshot = await getSnapshot( + const snapshotResult = await getSnapshot( toolCall.tabId, toolCall.textStart === undefined ? {} : { textStart: toolCall.textStart } ); - if (typeof snapshot === 'string') { - return { error: snapshot, ok: false }; + if (!snapshotResult.ok) { + return { error: snapshotResult.error, ok: false }; } + const snapshot = snapshotResult.value; + // Serving the same unchanged page again only burns context and invites a snapshot loop; a compact marker tells the model to act on what it already has. const contentKey = JSON.stringify({ nodes: snapshot.nodes, @@ -326,13 +328,13 @@ const runSafeToolCall = async ( } // One injection serves both halves of find_in_page: the snapshot nodes and the full-page text matches come from the same walk. - const snapshot = await getSnapshot(toolCall.tabId, { query }); + const snapshotResult = await getSnapshot(toolCall.tabId, { query }); - if (typeof snapshot === 'string') { - return { error: snapshot, ok: false }; + if (!snapshotResult.ok) { + return { error: snapshotResult.error, ok: false }; } - return { ok: true, value: getFindResults(snapshot, query) }; + return { ok: true, value: getFindResults(snapshotResult.value, query) }; }; // One executor per turn: its unchanged-snapshot memory must not cross conversations (or a compaction), where the marker would reference a snapshot the model never saw. diff --git a/apps/extension/entrypoints/sidepanel/agent-web-mcp-tool-runtime.ts b/apps/extension/entrypoints/sidepanel/agent-web-mcp-tool-runtime.ts index fd1e2ba4ea..a0f6bac9b4 100644 --- a/apps/extension/entrypoints/sidepanel/agent-web-mcp-tool-runtime.ts +++ b/apps/extension/entrypoints/sidepanel/agent-web-mcp-tool-runtime.ts @@ -1,4 +1,5 @@ import { browser } from '#imports'; +import { z } from 'zod'; import type { WebMcpToolCallEvent } from '@/src/shared/agent-conversation'; import { WEB_MCP_DISCOVER_MESSAGE, @@ -13,23 +14,30 @@ import { capRemoteMcpToolResult } from '@/src/shared/remote-mcp-tools'; * result enters conversation state as an object, not a quoted string. A * non-JSON string and null pass through verbatim. */ -const parseWebMcpResult = (value: unknown): unknown => { - if (typeof value !== 'string') { +const stringSchema = z.string(); +const jsonRecordSchema = z.record(z.string(), z.unknown()); + +const parseWebMcpResult = (value: unknown) => { + const asString = stringSchema.safeParse(value); + if (!asString.success) { return value; } try { - return JSON.parse(value); + const parsed: unknown = JSON.parse(asString.data); + return parsed; } catch { - return value; + return asString.data; } }; const isRecordObject = (value: unknown): value is Record => - typeof value === 'object' && value !== null && !Array.isArray(value); + jsonRecordSchema.safeParse(value).success; const isWebMcpDiscoveryResult = (value: unknown): value is WebMcpDiscoveryResult => - isRecordObject(value) && typeof value['documentId'] === 'string' && Array.isArray(value['tools']); + isRecordObject(value) && + stringSchema.safeParse(value['documentId']).success && + Array.isArray(value['tools']); /* * Discover the WebMCP tools registered by the selected tab's page through the diff --git a/apps/extension/entrypoints/sidepanel/agent-web-search-tool-runtime.ts b/apps/extension/entrypoints/sidepanel/agent-web-search-tool-runtime.ts index db59cf12dc..29370c94f6 100644 --- a/apps/extension/entrypoints/sidepanel/agent-web-search-tool-runtime.ts +++ b/apps/extension/entrypoints/sidepanel/agent-web-search-tool-runtime.ts @@ -61,9 +61,14 @@ const postSearch = async (query: string, context: WebSearchContext): Promise => { +const readJson = async ( + response: Response, + schema: z.ZodType +): Promise => { try { - return await response.json(); + const raw: unknown = await response.json(); + const parsed = schema.safeParse(raw); + return parsed.success ? parsed.data : undefined; } catch { return undefined; } @@ -71,7 +76,9 @@ const readJson = async (response: Response): Promise => { const toResultEntry = (result: z.infer['results'][number]) => ({ ...(result.publishedDate === undefined ? {} : { publishedDate: result.publishedDate }), - ...(typeof result.title === 'string' && result.title !== '' ? { title: result.title } : {}), + ...(result.title !== undefined && result.title !== null && result.title !== '' + ? { title: result.title } + : {}), ...(result.text === undefined ? {} : { text: result.text.slice(0, MAX_SNIPPET_CHARS) }), url: result.url, }); @@ -99,25 +106,24 @@ export const executeWebSearchToolCall = async ( } const { response } = outcome; - const body = await readJson(response); if (!response.ok) { // The proxy explains allowance and balance failures in its error body; pass that through so the model can tell the user why. - const errorBody = errorBodySchema.safeParse(body); - const detail = errorBody.success ? ` ${errorBody.data.error}` : ''; + const errorBody = await readJson(response, errorBodySchema); + const detail = errorBody === undefined ? '' : ` ${errorBody.error}`; return { error: `Web search failed with status ${String(response.status)}.${detail}`, ok: false, }; } - const parsed = exaResponseSchema.safeParse(body); + const parsed = await readJson(response, exaResponseSchema); - if (!parsed.success) { + if (parsed === undefined) { return { error: 'Web search returned an invalid response.', ok: false }; } - const results = parsed.data.results.slice(0, MAX_RESULTS).map(result => toResultEntry(result)); + const results = parsed.results.slice(0, MAX_RESULTS).map(result => toResultEntry(result)); return { ok: true, diff --git a/apps/extension/entrypoints/sidepanel/agent-workflow-runtime.ts b/apps/extension/entrypoints/sidepanel/agent-workflow-runtime.ts index a723084350..9d6f858f38 100644 --- a/apps/extension/entrypoints/sidepanel/agent-workflow-runtime.ts +++ b/apps/extension/entrypoints/sidepanel/agent-workflow-runtime.ts @@ -1,4 +1,5 @@ import { browser } from '#imports'; +import type { Browser } from 'wxt/browser'; import { WORKFLOW_NAVIGATION_TIMEOUT_MS, WORKFLOW_PAGE_EVAL_TIMEOUT_MS, @@ -93,7 +94,11 @@ export const navigateTab = async (tabId: number, url: string): Promise => } const preNavigationUrl = tab.url; - type TabListener = (updatedTabId: number, changeInfo: object, tabInfo: object) => void; + type TabListener = ( + updatedTabId: number, + changeInfo: Browser.tabs.OnUpdatedInfo, + tabInfo: Browser.tabs.Tab + ) => void; let listener: TabListener | undefined = undefined; let timeoutHandle: ReturnType | undefined = undefined; @@ -149,13 +154,16 @@ export const navigateTab = async (tabId: number, url: string): Promise => }); }, WORKFLOW_NAVIGATION_TIMEOUT_MS); - listener = (updatedTabId: number, changeInfo: object, _tabInfo: object): void => { + listener = ( + updatedTabId: number, + changeInfo: Browser.tabs.OnUpdatedInfo, + _tabInfo: Browser.tabs.Tab + ): void => { if (updatedTabId !== tabId) { return; } - const info = changeInfo as { status?: string }; - if (info.status !== 'complete') { + if (changeInfo.status !== 'complete') { return; } diff --git a/apps/extension/entrypoints/sidepanel/agent-workflow-tool-runtime.ts b/apps/extension/entrypoints/sidepanel/agent-workflow-tool-runtime.ts index 7dc3a1a3ba..383fa3baed 100644 --- a/apps/extension/entrypoints/sidepanel/agent-workflow-tool-runtime.ts +++ b/apps/extension/entrypoints/sidepanel/agent-workflow-tool-runtime.ts @@ -22,6 +22,8 @@ import type { ApprovalKind, ApprovalOutcome } from './pending-approval'; // ---------- tool context ---------- +type MaybePromise = Promise | Value; + export interface WorkflowToolContext { readonly selectedTabUrl: string; readonly selectedTabId: number; @@ -29,7 +31,7 @@ export interface WorkflowToolContext { readonly mode: 'safe' | 'dangerous'; readonly allowWorkflowsInSafeMode: boolean; readonly storage: { - getItem(key: string): unknown; + getItem(key: string): MaybePromise; setItem(key: string, value: unknown): void | Promise; removeItem(key: string): void | Promise; }; @@ -97,10 +99,10 @@ const NEXT_STEP_RUNS_ASK_USER = // ---------- helpers ---------- // Zod's generic "Invalid input" gives a model nothing to act on for the one field it most often garbles. Field-specific guidance replaces it. -const ARGS_FIELD_GUIDANCE: Record = { +const ARGS_FIELD_GUIDANCE = { script: 'script must be a non-empty string: the workflow function body (or a full async function) using the page.* helpers', -}; +} satisfies Record; /** * Format a zod failure into a field-level message the model can act on. @@ -110,7 +112,7 @@ const formatArgsError = (toolName: string, error: z.ZodError): string => { .slice(0, 5) .map(issue => { const path = issue.path.join('.') || '(root)'; - const guidance = ARGS_FIELD_GUIDANCE[path]; + const guidance = Object.entries(ARGS_FIELD_GUIDANCE).find(([key]) => key === path)?.[1]; return `${path}: ${guidance ?? issue.message}`; }) .join('; '); diff --git a/apps/extension/entrypoints/sidepanel/agents-new-session.tsx b/apps/extension/entrypoints/sidepanel/agents-new-session.tsx index 2ddb2036d5..96869881d5 100644 --- a/apps/extension/entrypoints/sidepanel/agents-new-session.tsx +++ b/apps/extension/entrypoints/sidepanel/agents-new-session.tsx @@ -2,6 +2,7 @@ /* eslint-disable max-lines -- Cohesive single-purpose new-session form; splitting would scatter form state */ import { storage } from '#imports'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { z } from 'zod'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { JSX } from 'react'; import { @@ -26,7 +27,6 @@ import { } from '@kilocode/cloud-agent-sdk'; import type { CreateRemoteSessionInput } from '@kilocode/cloud-agent-sdk'; import { generateMessageId } from '@kilocode/cloud-agent-sdk/message-id'; -import type { StoredAuth } from '@/src/shared/auth'; import { getKiloApiBaseUrl, loadStoredAuth } from '@/src/shared/auth'; import { fetchModelPreferences } from '@/src/shared/model-preferences-client'; import { isGatewayModelId } from '@/src/shared/model-picker-rows'; @@ -68,10 +68,7 @@ export { PROMPT_MAX_LENGTH, PROMPT_MIN_LENGTH, MODE }; const storedAuthQueryKey = ['side-panel', 'stored-auth'] as const; -const useStoredAuth = (): { - readonly auth: StoredAuth | undefined; - readonly isLoading: boolean; -} => { +const useStoredAuth = () => { const query = useQuery({ queryFn: async () => (await loadStoredAuth(storage)) ?? null, queryKey: storedAuthQueryKey, @@ -133,7 +130,7 @@ export const buildSubmitInput = ({ selectedVariant: string; selectedRepo: string; initialMessageId: string; -}): Record => ({ +}) => ({ autoCommit: true, autoInitiate: true, githubRepo: selectedRepo, @@ -216,13 +213,14 @@ export const submitBlockedReason = ({ return null; }; +const modelPreferencesGetResultSchema = z.looseObject({ + favorites: z.array(z.unknown()), +}); + const isModelPreferencesGetResult = ( value: unknown ): value is { favorites: string[]; lastSelected: LastSelected | null } => - typeof value === 'object' && - value !== null && - 'favorites' in value && - Array.isArray((value as Record)['favorites']); + modelPreferencesGetResultSchema.safeParse(value).success; export { isModelPreferencesGetResult }; // Exported for focused test coverage. diff --git a/apps/extension/entrypoints/sidepanel/agents-session-controls.ts b/apps/extension/entrypoints/sidepanel/agents-session-controls.ts index 73041686c8..cefda8a17a 100644 --- a/apps/extension/entrypoints/sidepanel/agents-session-controls.ts +++ b/apps/extension/entrypoints/sidepanel/agents-session-controls.ts @@ -49,7 +49,9 @@ export const selectSessionCostUsd = ( liveUsd: number ): number => { const persisted = - typeof persistedMicrodollars === 'number' && Number.isFinite(persistedMicrodollars) + persistedMicrodollars !== null && + persistedMicrodollars !== undefined && + Number.isFinite(persistedMicrodollars) ? Math.max(0, persistedMicrodollars / 1_000_000) : 0; const live = Number.isFinite(liveUsd) ? Math.max(0, liveUsd) : 0; diff --git a/apps/extension/entrypoints/sidepanel/agents-session-list.tsx b/apps/extension/entrypoints/sidepanel/agents-session-list.tsx index 3499ac94ce..1de3addc35 100644 --- a/apps/extension/entrypoints/sidepanel/agents-session-list.tsx +++ b/apps/extension/entrypoints/sidepanel/agents-session-list.tsx @@ -37,9 +37,7 @@ export { activeSessionsQueryKey, sessionHistoryQueryKey, sessionSearchQueryKey } * section observe this query; an identical key plus input means React Query * serves them from one request. */ -const activeSessionsListInput = ( - organizationId: string | null -): { organizationId: string | null; includeCloudAgentSessions: boolean } => ({ +const activeSessionsListInput = (organizationId: string | null) => ({ includeCloudAgentSessions: true, organizationId, }); diff --git a/apps/extension/entrypoints/sidepanel/context-donut.tsx b/apps/extension/entrypoints/sidepanel/context-donut.tsx index cb90f449e9..5db7c5b92b 100644 --- a/apps/extension/entrypoints/sidepanel/context-donut.tsx +++ b/apps/extension/entrypoints/sidepanel/context-donut.tsx @@ -4,11 +4,11 @@ import { formatContextSummary, getContextRatio, getContextTone } from '@/src/sha import { formatSessionCost } from '@/src/shared/session-cost'; import { DESIGN_TOKENS } from './design-tokens'; -const toneStroke: Record<'danger' | 'safe' | 'warn', string> = { +const toneStroke = { danger: DESIGN_TOKENS.statusRed500, safe: DESIGN_TOKENS.statusGreen500, warn: DESIGN_TOKENS.statusYellow500, -}; +} satisfies Record<'danger' | 'safe' | 'warn', string>; const RADIUS = 6; const CIRCUMFERENCE = 2 * Math.PI * RADIUS; diff --git a/apps/extension/entrypoints/sidepanel/model-preferences-state.ts b/apps/extension/entrypoints/sidepanel/model-preferences-state.ts index c78bc7abda..28da672061 100644 --- a/apps/extension/entrypoints/sidepanel/model-preferences-state.ts +++ b/apps/extension/entrypoints/sidepanel/model-preferences-state.ts @@ -68,9 +68,11 @@ const awaitSettled = async (promise: Promise): Promise => { } }; -export const createSerialAsyncChain = (): { +interface SerialAsyncChain { readonly enqueue: (work: () => Promise) => Promise; -} => { +} + +export const createSerialAsyncChain = (): SerialAsyncChain => { let chain: Promise | undefined = undefined; return { diff --git a/apps/extension/entrypoints/sidepanel/organization-credit-account.tsx b/apps/extension/entrypoints/sidepanel/organization-credit-account.tsx index 99f56deecf..5ecf0b1500 100644 --- a/apps/extension/entrypoints/sidepanel/organization-credit-account.tsx +++ b/apps/extension/entrypoints/sidepanel/organization-credit-account.tsx @@ -15,13 +15,13 @@ const apiBaseUrl = getKiloApiBaseUrl(); const fetchFromWindow: FetchLike = (input, init) => fetch(input, init); const selectedOrganizationIdSchema = z.string(); -export const useOrganizationCreditAccount = ( - token: string -): { +interface UseOrganizationCreditAccountResult { organizationOptions: KiloOrganizationOption[]; selectOrganization: (organizationId: string) => void; selectedOrganizationId: string; -} => { +} + +export const useOrganizationCreditAccount = (token: string): UseOrganizationCreditAccountResult => { const [organizationOptions, setOrganizationOptions] = useState([]); const [selectedOrganizationId, setSelectedOrganizationId] = useState(''); const selectedOrganizationIdRef = useRef(''); diff --git a/apps/extension/entrypoints/sidepanel/pending-approval.ts b/apps/extension/entrypoints/sidepanel/pending-approval.ts index b2ffbf866b..bc2f44be06 100644 --- a/apps/extension/entrypoints/sidepanel/pending-approval.ts +++ b/apps/extension/entrypoints/sidepanel/pending-approval.ts @@ -9,7 +9,7 @@ import { savePendingAgentMemoryDraft, } from '@/src/shared/agent-memories-storage'; import type { AgentMemoriesStorageArea } from '@/src/shared/agent-memories-storage'; -import type { AgentWorkflowParam, PendingAgentWorkflowDraft } from '@/src/shared/agent-workflows'; +import type { AgentWorkflowInput, PendingAgentWorkflowDraft } from '@/src/shared/agent-workflows'; import { hashWorkflowScript } from '@/src/shared/agent-workflows'; import { addAgentWorkflow, @@ -160,16 +160,7 @@ export const applyApprovalDecision = async ( } const workflowDraft = draft; const approvedScriptHash = await hashWorkflowScript(workflowDraft.script); - const input: { - approvedScriptHash: string; - description: string; - name: string; - params?: AgentWorkflowParam[] | undefined; - pathPrefix?: string | undefined; - scopeOrigin: string; - script: string; - startUrl?: string | undefined; - } = { + const input: AgentWorkflowInput = { approvedScriptHash, description: workflowDraft.description, name: workflowDraft.name, diff --git a/apps/extension/entrypoints/sidepanel/pending-memory-save-card-state.ts b/apps/extension/entrypoints/sidepanel/pending-memory-save-card-state.ts index 53432b99eb..68d224783c 100644 --- a/apps/extension/entrypoints/sidepanel/pending-memory-save-card-state.ts +++ b/apps/extension/entrypoints/sidepanel/pending-memory-save-card-state.ts @@ -43,12 +43,7 @@ export const buildDeleteMemoryAriaLabel = (memory: { text: string; }): string => `Delete memory "${buildMemoryPreviewLabel(memory)}"`; -export const deriveNoteCharacterCount = ( - note: string -): { - count: number; - max: number; -} => ({ +export const deriveNoteCharacterCount = (note: string) => ({ count: note.length, max: MAX_MEMORY_NOTE_LENGTH, }); diff --git a/apps/extension/entrypoints/sidepanel/remote-mcp-client.ts b/apps/extension/entrypoints/sidepanel/remote-mcp-client.ts index b5738ebe92..389f0593d8 100644 --- a/apps/extension/entrypoints/sidepanel/remote-mcp-client.ts +++ b/apps/extension/entrypoints/sidepanel/remote-mcp-client.ts @@ -25,7 +25,7 @@ import { createRemoteMcpOAuthProvider } from './remote-mcp-oauth-provider'; type FetchLike = typeof fetch; -const buildAuthHeaders = (auth: RemoteMcpAuth): Record => { +const buildAuthHeaders = (auth: RemoteMcpAuth) => { if (auth.type === 'bearer' && auth.token !== undefined) { return { Authorization: `Bearer ${auth.token}` }; } @@ -61,7 +61,7 @@ const permissiveJsonSchemaValidator: jsonSchemaValidator = { * Transport parameter — the runtime object is identical. */ const asTransport = (transport: StreamableHTTPClientTransport): Transport => - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, anti-slop/no-chained-type-assertions -- exactOptionalPropertyTypes makes `sessionId?: string` incompatible with `string | undefined`; the runtime object is identical, only the optional-property strictness differs. transport as unknown as Transport; const makeAuthProvider = ( diff --git a/apps/extension/entrypoints/sidepanel/remote-mcp-settings-logic.ts b/apps/extension/entrypoints/sidepanel/remote-mcp-settings-logic.ts index d72149f821..f124d427ee 100644 --- a/apps/extension/entrypoints/sidepanel/remote-mcp-settings-logic.ts +++ b/apps/extension/entrypoints/sidepanel/remote-mcp-settings-logic.ts @@ -74,10 +74,15 @@ export const buildDraftFromForm = ( ...(fields.id === undefined ? {} : { id: fields.id }), }); +export interface ApplyUpsertResult { + store: RemoteMcpStore; + error: string | null; +} + export const applyUpsert = ( store: RemoteMcpStore, draft: RemoteMcpServerDraft -): { store: RemoteMcpStore; error: string | null } => { +): ApplyUpsertResult => { try { const nextStore = upsertRemoteMcpServer(store, draft); return { error: null, store: nextStore }; diff --git a/apps/extension/entrypoints/sidepanel/use-analytics-identity.ts b/apps/extension/entrypoints/sidepanel/use-analytics-identity.ts index 1805cec05c..0c3f942a17 100644 --- a/apps/extension/entrypoints/sidepanel/use-analytics-identity.ts +++ b/apps/extension/entrypoints/sidepanel/use-analytics-identity.ts @@ -22,7 +22,7 @@ export const resolveSignedInTransition = ( return null; } - if (typeof next.email !== 'string' || next.email.length === 0) { + if (next.email === undefined || next.email.length === 0) { return null; } @@ -59,7 +59,7 @@ export const createAnalyticsIdentityTracker = ({ */ previous = next; - if (decision !== 'identify' || typeof next.email !== 'string' || next.email.length === 0) { + if (decision !== 'identify' || next.email === undefined || next.email.length === 0) { return; } diff --git a/apps/extension/entrypoints/sidepanel/use-gateway-models.ts b/apps/extension/entrypoints/sidepanel/use-gateway-models.ts index caca5b2359..4d5e180e90 100644 --- a/apps/extension/entrypoints/sidepanel/use-gateway-models.ts +++ b/apps/extension/entrypoints/sidepanel/use-gateway-models.ts @@ -1,4 +1,5 @@ import { useQuery } from '@tanstack/react-query'; +import type { QueryObserverResult, RefetchOptions } from '@tanstack/react-query'; import { getKiloApiBaseUrl } from '@/src/shared/auth'; import type { StoredAuth } from '@/src/shared/auth'; import { fetchKiloGatewayModels } from '@/src/shared/kilo-api-client'; @@ -10,18 +11,22 @@ const emptyModelOptions: KiloGatewayModelOption[] = []; const fetchFromWindow = (input: string, init?: RequestInit): Promise => fetch(input, init); +interface UseGatewayModelsResult { + readonly modelLoadError: string | undefined; + readonly isLoading: boolean; + readonly modelOptions: KiloGatewayModelOption[]; + readonly refetchModels: ( + options?: RefetchOptions + ) => Promise>; +} + export const useGatewayModels = ({ auth, organizationId, }: { auth: StoredAuth; organizationId: string | undefined; -}): { - readonly modelLoadError: string | undefined; - readonly isLoading: boolean; - readonly modelOptions: KiloGatewayModelOption[]; - readonly refetchModels: () => Promise; -} => { +}): UseGatewayModelsResult => { const query = useQuery({ enabled: auth.token !== '', queryFn: ({ signal }) => diff --git a/apps/extension/entrypoints/sidepanel/use-model-preferences.ts b/apps/extension/entrypoints/sidepanel/use-model-preferences.ts index 65222d1247..b85e9bce26 100644 --- a/apps/extension/entrypoints/sidepanel/use-model-preferences.ts +++ b/apps/extension/entrypoints/sidepanel/use-model-preferences.ts @@ -1,4 +1,5 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; +import type { QueryObserverResult, RefetchOptions } from '@tanstack/react-query'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { getKiloApiBaseUrl } from '@/src/shared/auth'; import type { StoredAuth } from '@/src/shared/auth'; @@ -28,7 +29,9 @@ const queryKeyFingerprint = (queryKey: readonly string[]): string => queryKey.jo export interface UseModelPreferencesResult { readonly favorites: ReadonlySet; - readonly refetch: () => Promise; + readonly refetch: ( + options?: RefetchOptions + ) => Promise>; readonly status: ModelPreferencesStatus; readonly toggleError: boolean; readonly toggleFavorite: (model: KiloGatewayModelOption) => void; diff --git a/apps/extension/entrypoints/sidepanel/use-tab-debugger.ts b/apps/extension/entrypoints/sidepanel/use-tab-debugger.ts index 72adc92caf..6088e80a2a 100644 --- a/apps/extension/entrypoints/sidepanel/use-tab-debugger.ts +++ b/apps/extension/entrypoints/sidepanel/use-tab-debugger.ts @@ -1,6 +1,7 @@ import { browser } from '#imports'; import { useQuery } from '@tanstack/react-query'; import { useCallback, useEffect, useRef, useState } from 'react'; +import { z } from 'zod'; import { getTabListQueryKey } from '@/src/shared/side-panel-query-options'; import { deriveInspectableTabState } from '@/src/shared/tab-debugger-selection'; import { LIST_INSPECTABLE_TABS_MESSAGE, isTabDebuggerResponse } from '@/src/shared/tab-debugger'; @@ -32,28 +33,18 @@ interface TabsQueryApi { }) => Promise; } +const activeTabIdSchema = z.looseObject({ id: z.number().optional() }); + /** * Best-effort active-tab lookup for the side panel's own window. * Never throws: query failures and invalid ids degrade to `undefined`. */ export const getActiveTabId = async (tabsApi: TabsQueryApi): Promise => { try { - const tabs: unknown = await tabsApi.query({ active: true, currentWindow: true }); - - if (!Array.isArray(tabs) || tabs.length === 0) { - return undefined; - } - - const [firstTabCandidate] = tabs as unknown[]; - const firstTab: unknown = firstTabCandidate; - - if (typeof firstTab !== 'object' || firstTab === null || !('id' in firstTab)) { - return undefined; - } - - const { id } = firstTab; + const tabs = await tabsApi.query({ active: true, currentWindow: true }); + const firstTab = activeTabIdSchema.safeParse(tabs[0]); - return typeof id === 'number' ? id : undefined; + return firstTab.success ? firstTab.data.id : undefined; } catch { return undefined; } @@ -61,16 +52,7 @@ export const getActiveTabId = async (tabsApi: TabsQueryApi): Promise Promise; - readonly selectDefaultTab: () => void; - readonly selectTab: (tabId: number) => void; - readonly selectedTabId: number | undefined; - readonly tabDebuggerError: string | undefined; -} => { +export const useTabDebugger = () => { const [inspectableTabs, setInspectableTabs] = useState([]); const [selectedTabId, setSelectedTabId] = useState( rememberedSelectedTabId ?? undefined diff --git a/apps/extension/src/shared/agent-chat-placeholder.ts b/apps/extension/src/shared/agent-chat-placeholder.ts index b441686c9a..13022b3924 100644 --- a/apps/extension/src/shared/agent-chat-placeholder.ts +++ b/apps/extension/src/shared/agent-chat-placeholder.ts @@ -15,11 +15,14 @@ export interface AgentFooterControlDisplay { readonly thinkingLabel: string; } -const modelLabels: Record = { +const modelLabels = { 'Claude Opus 4': 'Opus 4', 'Claude Sonnet 4': 'Sonnet 4', 'GPT-5': 'GPT-5', -}; +} satisfies Record; + +const getModelLabel = (model: string): string | undefined => + Object.entries(modelLabels).find(([key]) => key === model)?.[1]; export const defaultMode = 'safe'; @@ -30,6 +33,6 @@ export const getFooterControlDisplay = ( modeIcon: footer.mode === 'safe' ? 'shield' : 'alert', modeIconTone: footer.mode === 'safe' ? 'safe' : 'danger', modeLabel: footer.mode === 'safe' ? 'Safe' : 'Danger', - modelLabel: modelLabels[footer.model] ?? footer.model, + modelLabel: getModelLabel(footer.model) ?? footer.model, thinkingLabel: thinkingEffortLabel(footer.thinkingEffort), }); diff --git a/apps/extension/src/shared/agent-context-compaction.ts b/apps/extension/src/shared/agent-context-compaction.ts index 073ba31513..fa2c7ea6f5 100644 --- a/apps/extension/src/shared/agent-context-compaction.ts +++ b/apps/extension/src/shared/agent-context-compaction.ts @@ -23,12 +23,17 @@ const SUMMARY_SYSTEM_PROMPT = const isUserMessage = (event: AgentConversationEvent): boolean => event.type === 'message' && event.role === 'user'; +interface CompactionSplit { + readonly toKeep: AgentConversationEvent[]; + readonly toSummarize: AgentConversationEvent[]; +} + // Keep complete exchanges only: cut just before the Nth-from-last user message so kept // Events always begin at a user turn and no tool-call/tool-result pair is split. export const splitEventsForCompaction = ( events: AgentConversationEvent[], keepRecentExchanges: number = KEEP_RECENT_EXCHANGES -): { toKeep: AgentConversationEvent[]; toSummarize: AgentConversationEvent[] } => { +): CompactionSplit => { const userIndexes = events .map((event, index) => (isUserMessage(event) ? index : -1)) .filter(index => index !== -1); @@ -63,6 +68,7 @@ const truncateToolText = (text: string): string => : `${text.slice(0, MAX_TOOL_TEXT_CHARS)}… [truncated ${text.length - MAX_TOOL_TEXT_CHARS} chars]`; const stringifyToolValue = (value: unknown): string => { + // oxlint-disable-next-line anti-slop/no-runtime-typeof -- generic tool-value stringifier; value is an already-typed conversation event field that can hold any tool payload shape if (typeof value === 'string') { return value; } diff --git a/apps/extension/src/shared/agent-conversation-persistence.ts b/apps/extension/src/shared/agent-conversation-persistence.ts index 634824c019..41a330b203 100644 --- a/apps/extension/src/shared/agent-conversation-persistence.ts +++ b/apps/extension/src/shared/agent-conversation-persistence.ts @@ -1,30 +1,31 @@ +import { z } from 'zod'; import type { AgentConversationEvent } from './agent-conversation'; type ToolCallEvent = Extract; type ToolResultEvent = Extract; +const viewportScreenshotValueSchema = z.looseObject({ + dataUrl: z.string().startsWith('data:image/'), + mediaType: z.string(), +}); + +// The persisted counterpart of a screenshot result: dataUrl stripped, mediaType + note kept. +const persistedScreenshotStubSchema = z + .looseObject({ + mediaType: z.string(), + note: z.string(), + }) + .refine(value => !('dataUrl' in value)); + export const isViewportScreenshotValue = ( value: unknown ): value is { readonly mediaType: string; readonly dataUrl: string } => - typeof value === 'object' && - value !== null && - 'dataUrl' in value && - typeof value.dataUrl === 'string' && - value.dataUrl.startsWith('data:image/') && - 'mediaType' in value && - typeof value.mediaType === 'string'; + viewportScreenshotValueSchema.safeParse(value).success; -// The persisted counterpart of a screenshot result: dataUrl stripped, mediaType + note kept. export const isPersistedScreenshotStub = ( value: unknown ): value is { readonly mediaType: string; readonly note: string } => - typeof value === 'object' && - value !== null && - !('dataUrl' in value) && - 'mediaType' in value && - typeof value.mediaType === 'string' && - 'note' in value && - typeof value.note === 'string'; + persistedScreenshotStubSchema.safeParse(value).success; const toPersistedToolResult = ( event: ToolResultEvent, diff --git a/apps/extension/src/shared/agent-llm-harness.ts b/apps/extension/src/shared/agent-llm-harness.ts index 66329faec9..ccff3aa312 100644 --- a/apps/extension/src/shared/agent-llm-harness.ts +++ b/apps/extension/src/shared/agent-llm-harness.ts @@ -1,4 +1,5 @@ /* eslint-disable max-lines */ +import { z } from 'zod'; import type { KiloGatewayChatMessage, KiloGatewayToolDefinition } from './kilo-api-client'; import type { KiloGatewayToolName } from './kilo-gateway-chat-client'; import { MAX_SNAPSHOT_TEXT_LENGTH } from './tab-debugger'; @@ -406,31 +407,16 @@ export const createWorkflowToolDefinitions = ({ const getProviderToolCallId = (toolCall: ToolCallEvent): string => 'source' in toolCall ? toolCall.id : (toolCall.providerToolCallId ?? toolCall.id); -const screenshotValueSchema = { - safeParse( - value: unknown - ): { success: true; data: { dataUrl: string; mediaType: string } } | { success: false } { - if ( - typeof value === 'object' && - value !== null && - 'dataUrl' in value && - typeof value.dataUrl === 'string' && - value.dataUrl.startsWith('data:image/') && - 'mediaType' in value && - typeof value.mediaType === 'string' - ) { - return { data: { dataUrl: value.dataUrl, mediaType: value.mediaType }, success: true }; - } - - return { success: false }; - }, -}; +const screenshotValueSchema = z.looseObject({ + dataUrl: z.string().startsWith('data:image/'), + mediaType: z.string(), +}); const getToolResultValue = ( event: ToolResultEvent, toolCall: ToolCallEvent, supportsImages: boolean -): unknown => { +) => { if (toolCall.name !== 'get_viewport_screenshot') { return event.value; } diff --git a/apps/extension/src/shared/agent-task-bench-scenarios.ts b/apps/extension/src/shared/agent-task-bench-scenarios.ts index 011282acc1..46d5ee9a05 100644 --- a/apps/extension/src/shared/agent-task-bench-scenarios.ts +++ b/apps/extension/src/shared/agent-task-bench-scenarios.ts @@ -220,6 +220,7 @@ const WEB_RESEARCH_SCENARIO: BenchTaskScenario = { useCase: 'research', }; +// oxlint-disable-next-line anti-slop/no-known-value-widening -- exported as an open dictionary; workflow-create-benchmark.ts looks scenarios up by a dynamic scenario id from CLI args export const TASK_BENCH_SCENARIOS: Readonly> = { 'action-cart': ACTION_CART_SCENARIO, 'action-login': ACTION_LOGIN_SCENARIO, diff --git a/apps/extension/src/shared/agent-task-bench-scoring.ts b/apps/extension/src/shared/agent-task-bench-scoring.ts index 5ebef62e19..4658f333bb 100644 --- a/apps/extension/src/shared/agent-task-bench-scoring.ts +++ b/apps/extension/src/shared/agent-task-bench-scoring.ts @@ -44,7 +44,7 @@ export const selectFinalAnswer = (events: readonly BenchEvent[]): string => { event !== undefined && event.type === 'message' && event.role === 'assistant' && - typeof event.text === 'string' && + event.text !== undefined && event.text.trim() !== '' ) { return event.text; @@ -89,7 +89,9 @@ export const scoreTaskCorrectness = ({ (exchange.call.name === 'run_workflow' && exchange.call.arguments['dryRun'] !== true)) ); - const predicates: Record = { + type BenchPredicateMap = Record; + + const predicates: BenchPredicateMap = { actionPerformed: predicate( actionOk, scenario.requiresAction diff --git a/apps/extension/src/shared/agent-tool-output.ts b/apps/extension/src/shared/agent-tool-output.ts index 2cfb7734e5..01ed8ed2ae 100644 --- a/apps/extension/src/shared/agent-tool-output.ts +++ b/apps/extension/src/shared/agent-tool-output.ts @@ -1,3 +1,4 @@ +import { z } from 'zod'; import type { AgentToolName } from './agent-conversation'; interface ViewportScreenshotResult { @@ -5,14 +6,13 @@ interface ViewportScreenshotResult { readonly mediaType: 'image/png'; } +const viewportScreenshotResultSchema = z.object({ + dataUrl: z.string().refine(value => value.startsWith('data:image/png;base64,')), + mediaType: z.literal('image/png'), +}); + const isViewportScreenshotResult = (value: unknown): value is ViewportScreenshotResult => - typeof value === 'object' && - value !== null && - 'dataUrl' in value && - typeof value.dataUrl === 'string' && - value.dataUrl.startsWith('data:image/png;base64,') && - 'mediaType' in value && - value.mediaType === 'image/png'; + viewportScreenshotResultSchema.safeParse(value).success; export const getViewportScreenshotDataUrl = ( toolName: AgentToolName, diff --git a/apps/extension/src/shared/agent-workflow-bench-scenarios.ts b/apps/extension/src/shared/agent-workflow-bench-scenarios.ts index 36b63febad..dabbc00b3d 100644 --- a/apps/extension/src/shared/agent-workflow-bench-scenarios.ts +++ b/apps/extension/src/shared/agent-workflow-bench-scenarios.ts @@ -388,6 +388,7 @@ const REMOTEOK_SCENARIO: BenchScenario = { usesDate: false, }; +// oxlint-disable-next-line anti-slop/no-known-value-widening -- exported as an open dictionary; workflow-create-benchmark.ts looks scenarios up by a dynamic scenario id from CLI args export const BENCH_SCENARIOS: Readonly> = { allrecipes: ALLRECIPES_SCENARIO, arxiv: ARXIV_SCENARIO, diff --git a/apps/extension/src/shared/agent-workflow-bench-scoring.ts b/apps/extension/src/shared/agent-workflow-bench-scoring.ts index 8168bcc285..97d83bb85c 100644 --- a/apps/extension/src/shared/agent-workflow-bench-scoring.ts +++ b/apps/extension/src/shared/agent-workflow-bench-scoring.ts @@ -10,6 +10,7 @@ import { ISO_DATE_RE, isoDateVariants } from './agent-workflow-bench-scenarios'; import type { BenchScenario } from './agent-workflow-bench-scenarios'; import { coerceWorkflowRunInput } from './agent-workflow-runner'; import { hashWorkflowScript, matchesWorkflowScope } from './agent-workflows'; +import { z } from 'zod'; export const BENCH_SPEED_LIMIT_SECONDS = 180; @@ -133,6 +134,7 @@ export interface BenchToolCorrelation { } const isRecord = (value: unknown): value is Record => + // oxlint-disable-next-line anti-slop/no-runtime-typeof -- generic payload walker over untyped JSON-ish tool call data; distinguishes plain objects from arrays for arbitrary structural recursion. value !== null && typeof value === 'object' && !Array.isArray(value); /** @@ -144,8 +146,10 @@ const isRecord = (value: unknown): value is Record => export const findStringValues = (value: unknown, excludeKeys?: ReadonlySet): string[] => { const output: string[] = []; const walk = (entry: unknown): void => { + // oxlint-disable-next-line anti-slop/no-runtime-typeof -- generic payload walker over untyped JSON-ish tool call/result data; classifies each leaf to decide how to stringify it. if (typeof entry === 'string') { output.push(entry); + // oxlint-disable-next-line anti-slop/no-runtime-typeof -- generic payload walker over untyped JSON-ish tool call/result data; classifies each leaf to decide how to stringify it. } else if (typeof entry === 'number' || typeof entry === 'boolean') { output.push(String(entry)); } else if (Array.isArray(entry)) { @@ -219,6 +223,7 @@ export const selectLastValidRun = ( }; const predicate = (pass: boolean, detail: string): BenchPredicate => ({ detail, pass }); +const workflowIdSchema = z.string(); const scoreStoredWorkflowPredicates = async ( scenario: BenchScenario, @@ -231,7 +236,7 @@ const scoreStoredWorkflowPredicates = async ( workflow !== undefined && matchesWorkflowScope(workflow, scenario.startUrl); const approvedHashValid = workflow !== undefined && - typeof workflow.approvedScriptHash === 'string' && + workflow.approvedScriptHash !== undefined && workflow.approvedScriptHash === (await hashWorkflowScript(workflow.script)); const params = workflow?.params ?? []; @@ -312,6 +317,11 @@ interface RunOutcome { readonly resultPass: boolean; } +interface ResolvedRunOutcome { + readonly outcome: RunOutcome; + readonly runPredicate: BenchPredicate; +} + const scoreEvidenceRun = ({ evidence, followUpValues, @@ -346,7 +356,7 @@ const scoreEvidenceRun = ({ const lengthOk = resultLength >= scenario.minResultChars; - const extraPredicates: Record = { + const extraPredicates = { resultContent: predicate( failedContent.length === 0, failedContent.length === 0 @@ -406,18 +416,20 @@ export const scoreWorkflowCorrectness = async ( * valid run (dry-run-only with no id, or no run at all), fall back to the * newest saved workflow, never the oldest. */ - const boundWorkflowId = (evidenceReal ?? evidenceDry)?.call.arguments['workflowId']; + const boundWorkflowId = workflowIdSchema.safeParse( + (evidenceReal ?? evidenceDry)?.call.arguments['workflowId'] + ); const workflow = - (typeof boundWorkflowId === 'string' - ? input.workflows.find(candidate => candidate.id === boundWorkflowId) + (boundWorkflowId.success + ? input.workflows.find(candidate => candidate.id === boundWorkflowId.data) : undefined) ?? input.workflows.at(-1); const predicates: Record = await scoreStoredWorkflowPredicates( scenario, workflow ); - const resolveOutcome = (): { outcome: RunOutcome; runPredicate: BenchPredicate } => { - const noRun = (detail: string): { outcome: RunOutcome; runPredicate: BenchPredicate } => ({ + const resolveOutcome = (): ResolvedRunOutcome => { + const noRun = (detail: string): ResolvedRunOutcome => ({ outcome: { extraPredicates: {}, resultCheck: 'none', resultPass: false }, runPredicate: predicate(false, detail), }); diff --git a/apps/extension/src/shared/agent-workflow-runner.ts b/apps/extension/src/shared/agent-workflow-runner.ts index 781c9ecd0f..e010b597be 100644 --- a/apps/extension/src/shared/agent-workflow-runner.ts +++ b/apps/extension/src/shared/agent-workflow-runner.ts @@ -24,6 +24,12 @@ export type WorkflowRunResult = dryRunActions?: { action: string; selector: string }[] | undefined; }; +// The value a workflow script carries across navigations via { navigate, state }. +interface WorkflowScriptState { + // eslint-disable-next-line typescript-eslint/consistent-indexed-object-style -- A named interface (not a Record alias) is the sanctioned owner contract for anti-slop/no-known-value-widening. + readonly [key: string]: unknown; +} + interface EvalTabOkResult { ok: true; value: unknown; @@ -56,18 +62,19 @@ const isNavigationDestroyedEval = (error: string): boolean => * shape gate. The benchmark scorer applies the same coercion when it binds a * verifying run's input, so a coerced run scores like a well-formed one. */ -export const coerceWorkflowRunInput = (input: unknown): unknown => { - if (typeof input !== 'string') { +export const coerceWorkflowRunInput = (input: unknown) => { + const asString = stringSchema.safeParse(input); + if (!asString.success) { return input; } - const trimmed = input.trim(); + const trimmed = asString.data.trim(); if (trimmed === '') { - return undefined; + return; } if (trimmed.startsWith('{')) { try { const parsed: unknown = JSON.parse(trimmed); - if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { + if (jsonRecordSchema.safeParse(parsed).success) { return parsed; } } catch { @@ -89,6 +96,9 @@ export const coerceWorkflowRunInput = (input: unknown): unknown => { return input; }; +const stringSchema = z.string(); +const jsonRecordSchema = z.record(z.string(), z.unknown()); + const scriptEnvelopeSchema = z.object({ dryRunActions: z .array(z.object({ action: z.string(), selector: z.string() })) @@ -419,12 +429,7 @@ const validateNavigationState = ( const nextState = innerValue['state']; // Reject null, undefined, primitives, and arrays as navigation state. - if ( - nextState === null || - nextState === undefined || - typeof nextState !== 'object' || - Array.isArray(nextState) - ) { + if (!jsonRecordSchema.safeParse(nextState).success) { return { errorResult: { error: @@ -516,10 +521,7 @@ export const runWorkflow = async ( // 2a. Tolerant string coercion; the shape gate below catches what does not parse. const coercedInput = coerceWorkflowRunInput(input); // 2b. Input shape gate — before any navigation. Name the declared params: a weak model that sent a bare string loops on a generic message (measured), but corrects when told the exact object to send. - if ( - coercedInput !== undefined && - (typeof coercedInput !== 'object' || coercedInput === null || Array.isArray(coercedInput)) - ) { + if (coercedInput !== undefined && !jsonRecordSchema.safeParse(coercedInput).success) { const params = workflow.params ?? []; const exampleParam = params.find(param => param.required === true) ?? params[0]; const example = exampleParam === undefined ? '{}' : `{"${exampleParam.name}": ""}`; @@ -602,7 +604,7 @@ export const runWorkflow = async ( // 3. Initialize before the loop. `state.input` mirrors `input` on the first // Page for scripts written against the old contract. - let state: unknown = { input: normalizedInput }; + let state: WorkflowScriptState = { input: normalizedInput }; let pagesVisited = 0; let navigationRecoveries = 0; const dryRunActions: { action: string; selector: string }[] = []; @@ -714,10 +716,7 @@ export const runWorkflow = async ( ); } - // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Value passed zod validation; runtime checks follow. - const innerValue = envelope.data.value as Record; - - if (innerValue === null || innerValue === undefined || typeof innerValue !== 'object') { + if (!jsonRecordSchema.safeParse(envelope.data.value).success) { /* Same reasoning as above: a dry-run script that falls through without a return value usually read post-action content that never rendered. */ if (dryRun && dryRunActions.length > 0) { @@ -736,12 +735,15 @@ export const runWorkflow = async ( } return resultWithActions( - { error: invalidValueError(innerValue, dryRun), ok: false, pageUrl: url }, + { error: invalidValueError(envelope.data.value, dryRun), ok: false, pageUrl: url }, dryRun, dryRunActions ); } + // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Preceding jsonRecordSchema check guarantees a plain object. + const innerValue = envelope.data.value as Record; + // Success: { done: true, result: } if (innerValue['done'] === true) { return resultWithActions( @@ -752,7 +754,8 @@ export const runWorkflow = async ( } // Navigation: { navigate: string, state: object } - if (typeof innerValue['navigate'] === 'string' && innerValue['navigate'].length > 0) { + const navigateValue = stringSchema.safeParse(innerValue['navigate']); + if (navigateValue.success && navigateValue.data.length > 0) { const validationResult = validateNavigationState(workflow, innerValue, url); if (validationResult.kind === 'error') { diff --git a/apps/extension/src/shared/analytics.ts b/apps/extension/src/shared/analytics.ts index 8db8126bc4..0f580dd9c0 100644 --- a/apps/extension/src/shared/analytics.ts +++ b/apps/extension/src/shared/analytics.ts @@ -62,7 +62,7 @@ let lifecycleEpoch = 0; const readApiKey = (): string | undefined => { const key = import.meta.env.VITE_POSTHOG_API_KEY; - return typeof key === 'string' && key.trim().length > 0 ? key.trim() : undefined; + return key !== undefined && key.trim().length > 0 ? key.trim() : undefined; }; const createPostHogClient = (apiKey: string): PostHog => { @@ -105,13 +105,19 @@ export const __setFirefoxPermissionsReaderForTests = (reader?: FirefoxPermission firefoxPermissionsReader = reader; }; -const readFirefoxPermissions = async (): Promise => { - if (firefoxPermissionsReader) { - return firefoxPermissionsReader(); - } +const readFirefoxPermissions = async (): Promise | null> => { + const raw = await (async () => { + if (firefoxPermissionsReader) { + return firefoxPermissionsReader(); + } + const { browser } = await import('wxt/browser'); + return browser.permissions.getAll(); + })(); - const { browser } = await import('wxt/browser'); - return browser.permissions.getAll(); + const parsed = permissionsGetAllSchema.safeParse(raw); + return parsed.success ? parsed.data : null; }; const isTruthyEnvFlag = (value: unknown): boolean => value === true || value === 'true'; @@ -126,13 +132,12 @@ export const getFirefoxUsageDataGranted = async (): Promise => { return true; } - const raw = await readFirefoxPermissions(); - const parsed = permissionsGetAllSchema.safeParse(raw); - if (!parsed.success) { + const parsed = await readFirefoxPermissions(); + if (parsed === null) { return false; } - const dataCollection = parsed.data.data_collection; + const dataCollection = parsed.data_collection; if (dataCollection === undefined) { // Firefox < 140: no built-in data_collection consent surface. return false; diff --git a/apps/extension/src/shared/auth.ts b/apps/extension/src/shared/auth.ts index c7e26c9394..d82021321b 100644 --- a/apps/extension/src/shared/auth.ts +++ b/apps/extension/src/shared/auth.ts @@ -77,7 +77,7 @@ const userResponseSchema = z.object({ export const getKiloApiBaseUrl = (): string => { const configuredUrl = import.meta.env.VITE_KILO_API_BASE_URL; - if (typeof configuredUrl === 'string' && configuredUrl.trim().length > 0) { + if (configuredUrl !== undefined && configuredUrl.trim().length > 0) { return trimTrailingSlash(configuredUrl.trim()); } diff --git a/apps/extension/src/shared/cloud-agent-config.ts b/apps/extension/src/shared/cloud-agent-config.ts index 85708af9d9..4954e35f86 100644 --- a/apps/extension/src/shared/cloud-agent-config.ts +++ b/apps/extension/src/shared/cloud-agent-config.ts @@ -17,7 +17,7 @@ const trimTrailingSlash = (value: string): string => value.replace(/\/+$/, ''); export const getCloudAgentWsUrl = (): string => { const configuredUrl = import.meta.env.VITE_CLOUD_AGENT_WS_URL; - if (typeof configuredUrl === 'string' && configuredUrl.trim().length > 0) { + if (configuredUrl !== undefined && configuredUrl.trim().length > 0) { return trimTrailingSlash(configuredUrl.trim()); } @@ -31,7 +31,7 @@ export const getCloudAgentWsUrl = (): string => { export const getSessionIngestWsUrl = (): string => { const configuredUrl = import.meta.env.VITE_SESSION_INGEST_WS_URL; - if (typeof configuredUrl === 'string' && configuredUrl.trim().length > 0) { + if (configuredUrl !== undefined && configuredUrl.trim().length > 0) { return trimTrailingSlash(configuredUrl.trim()); } diff --git a/apps/extension/src/shared/extension-agent-session-manager.ts b/apps/extension/src/shared/extension-agent-session-manager.ts index f2a5286945..216769c29e 100644 --- a/apps/extension/src/shared/extension-agent-session-manager.ts +++ b/apps/extension/src/shared/extension-agent-session-manager.ts @@ -1,5 +1,6 @@ /* eslint-disable max-lines -- factory function assembles all SDK callbacks; splitting would scatter related transport logic across files */ import type { createTRPCClient } from '@trpc/client'; +import { z } from 'zod'; import { createBrowserLifecycleHooks, createSessionManager } from '@kilocode/cloud-agent-sdk'; import type { CloudAgentSessionId, @@ -63,8 +64,15 @@ type ActiveSessionsResult = Awaited { - return typeof value === 'object' && value !== null; + return value instanceof Object; +} + +function asString(value: unknown): string | undefined { + const result = stringSchema.safeParse(value); + return result.success ? result.data : undefined; } /** @@ -78,8 +86,8 @@ export function readFetchSessionErrorCode(error: unknown): string | undefined { } const { data } = error; if (isObject(data)) { - const { code } = data; - if (typeof code === 'string') { + const code = asString(data['code']); + if (code !== undefined) { return code; } } @@ -87,17 +95,13 @@ export function readFetchSessionErrorCode(error: unknown): string | undefined { if (isObject(shape)) { const shapeData = shape['data']; if (isObject(shapeData)) { - const { code } = shapeData; - if (typeof code === 'string') { + const code = asString(shapeData['code']); + if (code !== undefined) { return code; } } } - const top = error['code']; - if (typeof top === 'string') { - return top; - } - return undefined; + return asString(error['code']); } // --------------------------------------------------------------------------- @@ -653,10 +657,10 @@ export function createExtensionAgentSessionManager({ sessionId: CloudAgentSessionId ): Promise<{ ticket: string; expiresAt: number }> => { const token = getToken(); - const body: Record = { cloudAgentSessionId: sessionId }; - if (organizationId !== null) { - body['organizationId'] = organizationId; - } + const body = { + cloudAgentSessionId: sessionId, + ...(organizationId === null ? {} : { organizationId }), + }; const response = await fetch(`${apiBaseUrl}/api/cloud-agent-next/sessions/stream-ticket`, { body: JSON.stringify(body), headers: { diff --git a/apps/extension/src/shared/kilo-api-client.ts b/apps/extension/src/shared/kilo-api-client.ts index 233a067ed4..0b2649cc9b 100644 --- a/apps/extension/src/shared/kilo-api-client.ts +++ b/apps/extension/src/shared/kilo-api-client.ts @@ -132,46 +132,21 @@ const compareModelOptions = ( return left.name.localeCompare(right.name); }; -const toGatewayModelOption = (model: ParsedGatewayModelOption): KiloGatewayModelOption => { - const option: { - contextLength?: number; - hasUserByokAvailable?: boolean; - id: string; - isFree?: boolean; - isPreferred: boolean; - mayTrainOnYourPrompts?: boolean; - name: string; - supportsImages?: boolean; - variants: string[]; - } = { - id: model.id, - isPreferred: model.isPreferred, - name: model.name, - variants: model.variants, - }; - - if (model.contextLength !== undefined) { - option.contextLength = model.contextLength; - } - - if (model.hasUserByokAvailable !== undefined) { - option.hasUserByokAvailable = model.hasUserByokAvailable; - } - - if (model.isFree !== undefined) { - option.isFree = model.isFree; - } - - if (model.mayTrainOnYourPrompts !== undefined) { - option.mayTrainOnYourPrompts = model.mayTrainOnYourPrompts; - } - - if (model.supportsImages !== undefined) { - option.supportsImages = model.supportsImages; - } - - return option; -}; +const toGatewayModelOption = (model: ParsedGatewayModelOption): KiloGatewayModelOption => ({ + id: model.id, + isPreferred: model.isPreferred, + name: model.name, + variants: model.variants, + ...(model.contextLength === undefined ? {} : { contextLength: model.contextLength }), + ...(model.hasUserByokAvailable === undefined + ? {} + : { hasUserByokAvailable: model.hasUserByokAvailable }), + ...(model.isFree === undefined ? {} : { isFree: model.isFree }), + ...(model.mayTrainOnYourPrompts === undefined + ? {} + : { mayTrainOnYourPrompts: model.mayTrainOnYourPrompts }), + ...(model.supportsImages === undefined ? {} : { supportsImages: model.supportsImages }), +}); export const parseKiloGatewayModelsResponse = (value: unknown): KiloGatewayModelOption[] => { const parsed = gatewayModelsResponseSchema.safeParse(value); diff --git a/apps/extension/src/shared/kilo-gateway-chat-stream-client.ts b/apps/extension/src/shared/kilo-gateway-chat-stream-client.ts index 56e8145b08..53aca76eff 100644 --- a/apps/extension/src/shared/kilo-gateway-chat-stream-client.ts +++ b/apps/extension/src/shared/kilo-gateway-chat-stream-client.ts @@ -80,16 +80,16 @@ interface StreamReaderContext { const trimTrailingSlash = (value: string): string => value.replace(/\/+$/, ''); const organizationHeaderName = 'x-kilocode-organizationid'; // Map exposed catalog variants to the gateway reasoning effort. `xhigh` and `max` both run at xhigh effort; `max` additionally requests maximum verbosity (handled in toReasoningRequest). -const variantToGatewayEffort: Record = { - high: 'high', - instant: 'none', - low: 'low', - max: 'xhigh', - medium: 'medium', - minimal: 'minimal', - none: 'none', - xhigh: 'xhigh', -}; +const variantToGatewayEffort = new Map([ + ['high', 'high'], + ['instant', 'none'], + ['low', 'low'], + ['max', 'xhigh'], + ['medium', 'medium'], + ['minimal', 'minimal'], + ['none', 'none'], + ['xhigh', 'xhigh'], +]); const toolArgumentsSchema = z.record(z.string(), z.unknown()); const streamingToolCallDeltaSchema = z.object({ function: z @@ -125,6 +125,12 @@ const streamDataSchema = z.object({ }); // Reasoning blocks stream incrementally like content: text accumulates while structural fields (type/signature/data/index) carry their final value. Providers may require these signed/encrypted blocks replayed verbatim on the assistant tool-call message or they reject the continuation. const appendableReasoningKeys = new Set(['data', 'summary', 'text']); +const numberValueSchema = z.number(); +const stringValueSchema = z.string(); +const isNumberValue = (value: unknown): value is number => + numberValueSchema.safeParse(value).success; +const isStringValue = (value: unknown): value is string => + stringValueSchema.safeParse(value).success; const mergeReasoningDetail = ( detailsByIndex: Map>, block: unknown, @@ -137,7 +143,7 @@ const mergeReasoningDetail = ( } const record = parsed.data; - const index = typeof record['index'] === 'number' ? record['index'] : fallbackIndex; + const index = isNumberValue(record['index']) ? record['index'] : fallbackIndex; const current = detailsByIndex.get(index) ?? {}; for (const [key, value] of Object.entries(record)) { @@ -145,9 +151,7 @@ const mergeReasoningDetail = ( const existing = current[key]; current[key] = - appendableReasoningKeys.has(key) && - typeof value === 'string' && - typeof existing === 'string' + appendableReasoningKeys.has(key) && isStringValue(value) && isStringValue(existing) ? existing + value : value; } @@ -158,7 +162,7 @@ const mergeReasoningDetail = ( const toReasoningRequest = ( variant: string | undefined ): { reasoning: { effort: string; enabled: boolean }; verbosity?: 'max' } | undefined => { - const gatewayEffort = variant === undefined ? undefined : variantToGatewayEffort[variant]; + const gatewayEffort = variant === undefined ? undefined : variantToGatewayEffort.get(variant); if (gatewayEffort === undefined) { return; @@ -169,6 +173,7 @@ const toReasoningRequest = ( ...(variant === 'max' ? { verbosity: 'max' } : {}), }; }; +// oxlint-disable-next-line anti-slop/no-unknown-returns -- generic SSE JSON parse helper; every call site validates the result with its own zod schema immediately after. const parseJson = (value: string): unknown => { try { return JSON.parse(value); @@ -176,8 +181,8 @@ const parseJson = (value: string): unknown => { throw new TypeError('Gateway stream JSON was invalid.'); } }; -const getString = (value: unknown, message: string): string => { - if (typeof value !== 'string' || value.length === 0) { +const getString = (value: string | undefined, message: string): string => { + if (value === undefined || value.length === 0) { throw new TypeError(message); } @@ -290,7 +295,7 @@ const applyStreamingData = ( const { cost, prompt_tokens: promptTokens } = parsed.data.usage; accumulator.usage = { promptTokens, - ...(typeof cost === 'number' ? { costUsd: cost } : {}), + ...(cost !== null && cost !== undefined ? { costUsd: cost } : {}), }; } @@ -300,7 +305,11 @@ const applyStreamingData = ( return; } - if (typeof choice.finish_reason === 'string' && choice.finish_reason !== '') { + if ( + choice.finish_reason !== null && + choice.finish_reason !== undefined && + choice.finish_reason !== '' + ) { accumulator.finishReason = choice.finish_reason; } @@ -310,12 +319,12 @@ const applyStreamingData = ( } const { content, reasoning, reasoning_details: reasoningDetails, tool_calls: toolCalls } = delta; - if (typeof content === 'string' && content.length > 0) { + if (content !== null && content !== undefined && content.length > 0) { accumulator.content += content; handlers.onContentDelta(content); } - if (typeof reasoning === 'string' && reasoning.length > 0) { + if (reasoning !== null && reasoning !== undefined && reasoning.length > 0) { accumulator.reasoning += reasoning; handlers.onReasoningDelta(reasoning); } @@ -468,14 +477,15 @@ export const fetchKiloGatewayChatCompletionStream = async ({ // Watchdog: a stalled response — before the first byte or mid-stream — surfaces as a typed, retriable error instead of hanging the turn forever. Each read races against the stall timer, so the guarantee holds even when the underlying fetch ignores its abort signal. const stallController = new AbortController(); - const stall: { + interface StallState { cancelReader?: () => void; error?: Error; reject?: (error: Error) => void; stalled: boolean; timer?: ReturnType; totalTimer?: ReturnType; - } = { stalled: false }; + } + const stall: StallState = { stalled: false }; // eslint-disable-next-line promise/avoid-new -- A deferred rejection has no promise-returning primitive to defer to. const stallPromise = new Promise((_resolve, reject) => { stall.reject = reject; diff --git a/apps/extension/src/shared/model-preferences-client.ts b/apps/extension/src/shared/model-preferences-client.ts index f2bfdda4e9..543a7f6819 100644 --- a/apps/extension/src/shared/model-preferences-client.ts +++ b/apps/extension/src/shared/model-preferences-client.ts @@ -49,12 +49,17 @@ const mutationDataSchema = z.object({ success: z.literal(true), }); -const successEnvelopeSchema = (dataSchema: Schema) => - z.object({ - result: z.object({ - data: dataSchema, - }), - }); +const favoritesSuccessEnvelopeSchema = z.object({ + result: z.object({ + data: favoritesDataSchema, + }), +}); + +const mutationSuccessEnvelopeSchema = z.object({ + result: z.object({ + data: mutationDataSchema, + }), +}); const errorEnvelopeSchema = z.object({ error: z.object({ @@ -107,15 +112,12 @@ const performModelPreferencesFetch = async ({ readonly token: string; readonly url: string; }): Promise => { - const headers: Record = { + const headers = { Accept: 'application/json', Authorization: `Bearer ${token}`, + ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), }; - if (body !== undefined) { - headers['Content-Type'] = 'application/json'; - } - try { return await fetch(url, { headers, @@ -132,22 +134,10 @@ const performModelPreferencesFetch = async ({ } }; -const readModelPreferencesPayload = async (response: Response): Promise => { - try { - return await response.json(); - } catch (error) { - throw new ModelPreferencesError('Model preferences response was not JSON', { - cause: error, - status: response.status, - trpcCode: null, - }); - } -}; - const requestModelPreferences = async ({ apiBaseUrl, body, - dataSchema, + successEnvelopeSchema, fetch, method, organizationId, @@ -157,7 +147,7 @@ const requestModelPreferences = async ({ }: { readonly apiBaseUrl: string; readonly body?: string; - readonly dataSchema: z.ZodType; + readonly successEnvelopeSchema: z.ZodType<{ result: { data: Data } }>; readonly fetch: FetchLike; readonly method: 'GET' | 'POST'; readonly organizationId?: string | undefined; @@ -178,7 +168,17 @@ const requestModelPreferences = async ({ ...(body === undefined ? {} : { body }), ...(signal === undefined ? {} : { signal }), }); - const payload = await readModelPreferencesPayload(response); + // eslint-disable-next-line unicorn/no-useless-undefined -- initialized in the try block below + let payload: unknown = undefined; + try { + payload = await response.json(); + } catch (error) { + throw new ModelPreferencesError('Model preferences response was not JSON', { + cause: error, + status: response.status, + trpcCode: null, + }); + } const errorEnvelope = errorEnvelopeSchema.safeParse(payload); if (errorEnvelope.success) { @@ -195,7 +195,7 @@ const requestModelPreferences = async ({ }); } - const successEnvelope = successEnvelopeSchema(dataSchema).safeParse(payload); + const successEnvelope = successEnvelopeSchema.safeParse(payload); if (!successEnvelope.success) { throw new ModelPreferencesError('Model preferences response envelope was malformed', { @@ -235,10 +235,10 @@ export const fetchModelPreferences = async ({ }> => { const data = await requestModelPreferences({ apiBaseUrl, - dataSchema: favoritesDataSchema, fetch, method: 'GET', procedure: 'modelPreferences.get', + successEnvelopeSchema: favoritesSuccessEnvelopeSchema, token, ...(organizationId === undefined ? {} : { organizationId }), ...(signal === undefined ? {} : { signal }), @@ -258,10 +258,10 @@ export const addModelFavorite = async ({ await requestModelPreferences({ apiBaseUrl, body: JSON.stringify({ model }), - dataSchema: mutationDataSchema, fetch, method: 'POST', procedure: 'modelPreferences.addFavorite', + successEnvelopeSchema: mutationSuccessEnvelopeSchema, token, ...(organizationId === undefined ? {} : { organizationId }), ...(signal === undefined ? {} : { signal }), @@ -279,10 +279,10 @@ export const removeModelFavorite = async ({ await requestModelPreferences({ apiBaseUrl, body: JSON.stringify({ model }), - dataSchema: mutationDataSchema, fetch, method: 'POST', procedure: 'modelPreferences.removeFavorite', + successEnvelopeSchema: mutationSuccessEnvelopeSchema, token, ...(organizationId === undefined ? {} : { organizationId }), ...(signal === undefined ? {} : { signal }), diff --git a/apps/extension/src/shared/remote-mcp-tools.ts b/apps/extension/src/shared/remote-mcp-tools.ts index 8924c8f3fb..687bdf7136 100644 --- a/apps/extension/src/shared/remote-mcp-tools.ts +++ b/apps/extension/src/shared/remote-mcp-tools.ts @@ -1,3 +1,4 @@ +import { z } from 'zod'; import type { AgentMode } from './agent-conversation'; import type { KiloGatewayToolDefinition } from './kilo-api-client'; import type { RemoteMcpCachedTool, RemoteMcpServer } from './remote-mcp'; @@ -24,12 +25,10 @@ const MAX_REMOTE_MCP_TOOLS = 128; export const MAX_REMOTE_MCP_RESULT_CHARS = 64 * 1024; const sourceNamePattern = /^[a-zA-Z0-9_-]+$/; +const jsonObjectSchemaShape = z.looseObject({ type: z.literal('object') }); + const isObjectSchema = (value: unknown): value is Record => - typeof value === 'object' && - value !== null && - !Array.isArray(value) && - 'type' in value && - value.type === 'object'; + jsonObjectSchemaShape.safeParse(value).success; const isModeAllowedServer = (mode: AgentMode, server: RemoteMcpServer): boolean => server.enabled && @@ -132,7 +131,7 @@ export const resolveRemoteMcpToolRoute = ( : { ok: true, route }; }; -export const capRemoteMcpToolResult = (value: unknown): unknown => { +export const capRemoteMcpToolResult = (value: unknown) => { const serialized = JSON.stringify(value); if (serialized === undefined || serialized.length <= MAX_REMOTE_MCP_RESULT_CHARS) { diff --git a/apps/extension/src/shared/side-panel-mode.ts b/apps/extension/src/shared/side-panel-mode.ts index b31756a744..3550c3d1fb 100644 --- a/apps/extension/src/shared/side-panel-mode.ts +++ b/apps/extension/src/shared/side-panel-mode.ts @@ -6,8 +6,10 @@ export type SidePanelMode = 'browser' | 'agents'; const sidePanelModeSchema = z.enum(['browser', 'agents']); +type MaybePromise = Promise | Value; + export interface SidePanelModeStorageArea { - getItem(key: typeof SIDE_PANEL_MODE_STORAGE_KEY): unknown; + getItem(key: typeof SIDE_PANEL_MODE_STORAGE_KEY): MaybePromise; setItem(key: typeof SIDE_PANEL_MODE_STORAGE_KEY, value: SidePanelMode): Promise | void; removeItem(key: typeof SIDE_PANEL_MODE_STORAGE_KEY): Promise | void; } diff --git a/apps/extension/src/shared/side-panel.ts b/apps/extension/src/shared/side-panel.ts index 459d407eb7..af945ea953 100644 --- a/apps/extension/src/shared/side-panel.ts +++ b/apps/extension/src/shared/side-panel.ts @@ -32,7 +32,7 @@ export type NativeContextMenusClickListener = ( ) => void; export interface NativeContextMenusApi { - create(options: NativeContextMenusCreateOptions): unknown; + create(options: NativeContextMenusCreateOptions): void; remove?(menuItemId: string): Promise | void; onClicked: { addListener(listener: NativeContextMenusClickListener): void; @@ -58,7 +58,7 @@ export const registerAddToMemoryMenu = async (menusApi?: NativeContextMenusApi): title: 'Add to memory', }; - if (typeof menusApi.remove === 'function') { + if (menusApi.remove !== undefined) { try { await menusApi.remove(ADD_TO_MEMORY_MENU_ID); } catch { diff --git a/apps/extension/src/shared/tab-debugger.ts b/apps/extension/src/shared/tab-debugger.ts index 8463c3f1ed..c90a12331f 100644 --- a/apps/extension/src/shared/tab-debugger.ts +++ b/apps/extension/src/shared/tab-debugger.ts @@ -35,11 +35,12 @@ export interface ChromeDebuggerApi { readonly attach: (target: ChromeDebuggerTarget, requiredVersion: string) => Promise | void; readonly detach: (target: ChromeDebuggerTarget) => Promise | void; readonly getTargets: () => Promise | ChromeDebuggerTargetInfo[]; + // The CDP response shape depends on `method`; callers validate it with a schema (see evalInTab). readonly sendCommand: ( target: ChromeDebuggerTarget, method: string, commandParams?: Record - ) => unknown; + ) => Promise | undefined> | (Record | undefined); } export interface BrowserTabInfo { @@ -75,9 +76,9 @@ export interface BrowserScriptingInjectionResult { } export interface BrowserScriptingApi { - readonly executeScript: (details: { + readonly executeScript: (details: { readonly args: string[]; - readonly func: (...args: string[]) => unknown; + readonly func: (...args: string[]) => Result; readonly target: { readonly tabId: number; readonly documentIds?: string[] }; readonly world: 'MAIN'; }) => Promise | BrowserScriptingInjectionResult[]; @@ -349,7 +350,7 @@ export const listInspectableTabs = async ( ( target ): target is ChromeDebuggerTargetInfo & { readonly tabId: number; readonly url: string } => - target.type === 'page' && typeof target.tabId === 'number' && isNormalPageUrl(target.url) + target.type === 'page' && target.tabId !== undefined && isNormalPageUrl(target.url) ) .map(target => { const title = target.title?.trim(); @@ -370,7 +371,7 @@ export const listInspectableTabsWithTabsApi = async ( return tabs .filter( (tab): tab is BrowserTabInfo & { readonly id: number; readonly url: string } => - typeof tab.id === 'number' && isNormalPageUrl(tab.url) + tab.id !== undefined && isNormalPageUrl(tab.url) ) .map(tab => { const title = tab.title?.trim(); @@ -383,8 +384,7 @@ export const listInspectableTabsWithTabsApi = async ( }); }; -const getTabId = (tab: BrowserTabInfo | undefined): number | undefined => - typeof tab?.id === 'number' ? tab.id : undefined; +const getTabId = (tab: BrowserTabInfo | undefined): number | undefined => tab?.id; const getPngDimensions = (dataUrl: string): { height: number; width: number } | undefined => { try { const bytes = Uint8Array.from( @@ -476,36 +476,32 @@ export const getViewportScreenshotWithTabsApi = ({ }; const getEvalExpression = (code: string): string => `(async () => { ${code} })()`; +const exceptionDetailsTextSchema = z.object({ text: z.string() }); const getExceptionMessage = (exceptionDetails: unknown): string => { - if ( - typeof exceptionDetails === 'object' && - exceptionDetails !== null && - 'text' in exceptionDetails && - typeof exceptionDetails.text === 'string' && - exceptionDetails.text.trim() !== '' - ) { - return `Page evaluation failed: ${exceptionDetails.text}`; - } + const parsed = exceptionDetailsTextSchema.safeParse(exceptionDetails); - return 'Page evaluation failed.'; + return parsed.success && parsed.data.text.trim() !== '' + ? `Page evaluation failed: ${parsed.data.text}` + : 'Page evaluation failed.'; }; +const injectionErrorStringSchema = z.string(); +const injectionErrorMessageSchema = z.object({ message: z.string() }); const extractInjectionErrorText = (error: unknown): string | undefined => { - if (typeof error === 'string' && error.trim() !== '') { - return error; + const asString = injectionErrorStringSchema.safeParse(error); + + if (asString.success) { + return asString.data.trim() === '' ? undefined : asString.data; } - if ( - typeof error === 'object' && - error !== null && - 'message' in error && - typeof error.message === 'string' && - error.message.trim() !== '' - ) { - return error.message; + const asMessage = injectionErrorMessageSchema.safeParse(error); + + if (asMessage.success) { + return asMessage.data.message.trim() === '' ? undefined : asMessage.data.message; } return undefined; }; +const evalStringValueSchema = z.string(); const toSerializableEvalResult = (value: unknown): EvalTabResult => { try { JSON.stringify(value); @@ -513,14 +509,16 @@ const toSerializableEvalResult = (value: unknown): EvalTabResult => { return { error: 'Eval result was not JSON-serializable.', ok: false }; } - if (typeof value === 'string' && value.length > maxEvalStringLength) { + const stringValue = evalStringValueSchema.safeParse(value); + + if (stringValue.success && stringValue.data.length > maxEvalStringLength) { return { ok: true, value: { - originalLength: value.length, + originalLength: stringValue.data.length, truncated: true, type: 'string', - value: value.slice(0, maxEvalStringLength), + value: stringValue.data.slice(0, maxEvalStringLength), }, }; } @@ -528,6 +526,7 @@ const toSerializableEvalResult = (value: unknown): EvalTabResult => { return { ok: true, value }; }; +// oxlint-disable-next-line anti-slop/no-unknown-returns -- injected function; runs arbitrary model-authored code in the page and cannot import a schema to type its result. const runInjectedEval = (code: string): unknown => // eslint-disable-next-line eslint/no-new-func, typescript-eslint/no-implied-eval, typescript-eslint/no-unsafe-call new Function(`return (async () => { ${code} })()`)(); @@ -621,13 +620,7 @@ const runInjectedPageSnapshot = ( }; const isRenderedTextNode = (textNode: Node): boolean => textNode.parentElement === null || isVisibleElement(textNode.parentElement); - const getPageText = (): { - fullText: string; - text: string; - start: number; - totalChars: number; - cutShort: boolean; - } => { + const getPageText = () => { // The full visible text is collected so a window can start at any offset and the search sees the whole page. A deadline mid-walk keeps what was collected instead of failing the snapshot. const root = document.body ?? document.documentElement; const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); @@ -660,9 +653,7 @@ const runInjectedPageSnapshot = ( totalChars: fullText.length, }; }; - const findTextMatches = ( - fullText: string - ): { matches: { excerpt: string; offset: number }[]; totalMatches: number } => { + const findTextMatches = (fullText: string) => { const needle = normalize(queryText ?? ''); const matches: { excerpt: string; offset: number }[] = []; let totalMatches = 0; @@ -738,6 +729,19 @@ const runInjectedPageSnapshot = ( return rect.width > 0 && rect.height > 0; }; + // Mutable draft of PageSnapshotNode: fields are filled in incrementally below, unlike the readonly exported shape. + interface SnapshotNodeDraft { + formAction?: string; + formMethod?: string; + href?: string; + id: string; + label?: string; + name?: string; + role: string; + state?: Record; + tag: string; + text?: string; + } const getPriority = (node: PageSnapshotNode): number => { if (node.role === 'button' || node.role === 'field') { return 0; @@ -782,18 +786,7 @@ const runInjectedPageSnapshot = ( state['checked'] = element.checked; } - const node: { - formAction?: string; - formMethod?: string; - href?: string; - id: string; - label?: string; - name?: string; - role: string; - state?: Record; - tag: string; - text?: string; - } = { + const node: SnapshotNodeDraft = { id: `node-${candidates.length + 1}`, role: getRole(element), tag, @@ -868,14 +861,19 @@ const runInjectedPageSnapshot = ( const runInjectedWebMcpDiscover = async (): Promise => { const { modelContext } = document as Document & { modelContext?: { - getTools?: () => Promise; + getTools?: () => Promise; }; }; const isArray = (value: unknown): value is unknown[] => Array.isArray(value); const isToolRecord = (value: unknown): value is Record => + // oxlint-disable-next-line anti-slop/no-runtime-typeof -- injected function reading an arbitrary third-party WebMCP tool object; cannot import a schema to parse it. typeof value === 'object' && value !== null; + const isString = (value: unknown): value is string => + // oxlint-disable-next-line anti-slop/no-runtime-typeof -- injected function reading arbitrary third-party WebMCP tool fields; cannot import a schema to parse them. + typeof value === 'string'; + const asString = (value: unknown): string => (isString(value) ? value : ''); - if (modelContext === undefined || typeof modelContext.getTools !== 'function') { + if (modelContext === undefined || modelContext.getTools === undefined) { return []; } @@ -888,11 +886,11 @@ const runInjectedWebMcpDiscover = async (): Promise => { for (const tool of tools) { const record = isToolRecord(tool) ? tool : {}; descriptors.push({ - description: typeof record['description'] === 'string' ? record['description'] : '', + description: asString(record['description']), inputSchema: record['inputSchema'], - name: typeof record['name'] === 'string' ? record['name'] : '', - origin: typeof record['origin'] === 'string' ? record['origin'] : '', - title: typeof record['title'] === 'string' ? record['title'] : '', + name: asString(record['name']), + origin: asString(record['origin']), + title: asString(record['title']), }); } @@ -903,21 +901,31 @@ const runInjectedWebMcpExecute = async ( toolNameText: string, argumentsText: string, definitionSignatureText: string + // oxlint-disable-next-line anti-slop/no-unknown-returns -- injected function; the third-party tool result shape is arbitrary and cannot be parsed without importing a schema. ): Promise => { const { modelContext } = document as Document & { modelContext?: { - getTools?: () => Promise; + getTools?: () => Promise; + // oxlint-disable-next-line anti-slop/no-unknown-returns -- injected function; the third-party tool result shape is arbitrary and cannot be parsed without importing a schema. executeTool?: (tool: unknown, argumentsText: string) => Promise; }; }; const isArray = (value: unknown): value is unknown[] => Array.isArray(value); const isToolRecord = (value: unknown): value is Record => + // oxlint-disable-next-line anti-slop/no-runtime-typeof -- injected function reading an arbitrary third-party WebMCP tool object; cannot import a schema to parse it. typeof value === 'object' && value !== null; + const isString = (value: unknown): value is string => + // oxlint-disable-next-line anti-slop/no-runtime-typeof -- injected function reading arbitrary third-party WebMCP tool fields; cannot import a schema to parse them. + typeof value === 'string'; + const asString = (value: unknown): string => (isString(value) ? value : ''); + const isPlainObject = (value: unknown): value is Record => + // oxlint-disable-next-line anti-slop/no-runtime-typeof -- injected function distinguishing a plain-object schema from a primitive/array in arbitrary third-party tool data; cannot import a schema to parse it. + typeof value === 'object' && value !== null && !Array.isArray(value); if ( modelContext === undefined || - typeof modelContext.getTools !== 'function' || - typeof modelContext.executeTool !== 'function' + modelContext.getTools === undefined || + modelContext.executeTool === undefined ) { throw new Error('WebMCP is not available in this document.'); } @@ -942,20 +950,19 @@ const runInjectedWebMcpExecute = async ( // Rebuild the ordered definition signature identically to web-mcp-tools.ts and reject a changed registration as a stale tool. const record = isToolRecord(tool) ? tool : {}; - const name = typeof record['name'] === 'string' ? record['name'] : ''; - const title = typeof record['title'] === 'string' ? record['title'] : ''; - const description = typeof record['description'] === 'string' ? record['description'] : ''; - const origin = typeof record['origin'] === 'string' ? record['origin'] : ''; + const name = asString(record['name']); + const title = asString(record['title']); + const description = asString(record['description']); + const origin = asString(record['origin']); let schema = record['inputSchema']; - if (typeof schema === 'string') { + if (isString(schema)) { try { schema = JSON.parse(schema) as unknown; } catch { schema = undefined; } } - const normalizedSchema = - typeof schema === 'object' && schema !== null && !Array.isArray(schema) ? schema : undefined; + const normalizedSchema = isPlainObject(schema) ? schema : undefined; const definitionSignature = JSON.stringify([name, title, description, origin, normalizedSchema]); if (definitionSignature !== definitionSignatureText) { @@ -1186,7 +1193,7 @@ export const discoverWebMcpToolsInTab = async ({ }; } - const documentId = typeof response?.documentId === 'string' ? response.documentId : ''; + const documentId = response?.documentId ?? ''; const tools = isWebMcpToolDescriptorArray(response?.result) ? response.result : []; // The browser must report the target document; without it the tools cannot be bound to a page. diff --git a/apps/extension/src/shared/web-mcp-tools.ts b/apps/extension/src/shared/web-mcp-tools.ts index 97a2ee78b9..f9d5094f9a 100644 --- a/apps/extension/src/shared/web-mcp-tools.ts +++ b/apps/extension/src/shared/web-mcp-tools.ts @@ -1,3 +1,4 @@ +import { z } from 'zod'; import type { KiloGatewayToolDefinition, WebMcpGatewayToolName } from './kilo-gateway-chat-client'; import type { WebMcpToolDescriptor } from './tab-debugger'; @@ -34,23 +35,28 @@ const RESERVED_GATEWAY_TOOL_NAMES = new Set([ 'web_search', ]); -const normalizeString = (value: unknown): string => (typeof value === 'string' ? value : ''); +const stringSchema = z.string(); +const jsonRecordSchema = z.record(z.string(), z.unknown()); -const isRecordObject = (value: unknown): value is Record => - typeof value === 'object' && value !== null && !Array.isArray(value); +const normalizeString = (value: unknown): string => { + const result = stringSchema.safeParse(value); + return result.success ? result.data : ''; +}; const normalizeInputSchema = (inputSchema: unknown): Record | undefined => { - let schema = inputSchema; + let schema: unknown = inputSchema; - if (typeof schema === 'string') { + const asString = stringSchema.safeParse(schema); + if (asString.success) { try { - schema = JSON.parse(schema) as unknown; + schema = JSON.parse(asString.data) as unknown; } catch { return undefined; } } - return isRecordObject(schema) ? schema : undefined; + const asRecord = jsonRecordSchema.safeParse(schema); + return asRecord.success ? asRecord.data : undefined; }; const isReservedName = (name: string): boolean => diff --git a/apps/extension/vitest.setup.ts b/apps/extension/vitest.setup.ts index 0a8f9b425c..f0384f5e55 100644 --- a/apps/extension/vitest.setup.ts +++ b/apps/extension/vitest.setup.ts @@ -3,6 +3,7 @@ import { afterEach } from 'vitest'; // With globals disabled, @testing-library/react cannot register its own cleanup. Renders would accumulate across tests. afterEach(async () => { + // oxlint-disable-next-line anti-slop/no-runtime-typeof -- environment probe: detects whether this test file runs in a DOM environment if (typeof document !== 'undefined') { const { cleanup } = await import('@testing-library/react'); cleanup(); diff --git a/apps/extension/wxt.config.ts b/apps/extension/wxt.config.ts index d7937311f2..0f871bddf1 100644 --- a/apps/extension/wxt.config.ts +++ b/apps/extension/wxt.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from 'wxt'; import tailwindcss from '@tailwindcss/vite'; const posthogApiKey = process.env['VITE_POSTHOG_API_KEY']; -if (typeof posthogApiKey !== 'string' || posthogApiKey.trim().length === 0) { +if (posthogApiKey === undefined || posthogApiKey.trim().length === 0) { console.warn( 'VITE_POSTHOG_API_KEY is not set; extension analytics will be disabled in this build.' ); diff --git a/apps/mobile/.oxlintrc.json b/apps/mobile/.oxlintrc.json index a024409baa..b2e2f3d055 100644 --- a/apps/mobile/.oxlintrc.json +++ b/apps/mobile/.oxlintrc.json @@ -2,8 +2,22 @@ "$schema": "../node_modules/oxlint/configuration_schema.json", "plugins": ["oxc", "typescript", "unicorn", "react", "import", "promise"], "jsPlugins": [ - { "name": "react-native", "specifier": "oxlint-plugin-react-native" }, - { "name": "sonarjs", "specifier": "eslint-plugin-sonarjs" } + { + "name": "react-native", + "specifier": "oxlint-plugin-react-native" + }, + { + "name": "sonarjs", + "specifier": "eslint-plugin-sonarjs" + }, + { + "name": "anti-slop", + "specifier": "../../tools/oxlint/anti-slop/index.ts" + }, + { + "name": "zod-utils", + "specifier": "../../tools/oxlint/zod-utils.mjs" + } ], "options": { "typeAware": true @@ -102,7 +116,12 @@ ], "react/no-is-mounted": "off", "react/jsx-no-useless-fragment": "off", - "react/jsx-max-depth": ["error", { "max": 10 }], + "react/jsx-max-depth": [ + "error", + { + "max": 10 + } + ], "react/rules-of-hooks": "error", "react/exhaustive-deps": "error", "react/react-in-jsx-scope": "off", @@ -125,14 +144,33 @@ "no-undef": "off", "no-eq-null": "off", "sort-keys": "off", - "sort-imports": ["error", { "ignoreCase": true, "ignoreDeclarationSort": true }], + "sort-imports": [ + "error", + { + "ignoreCase": true, + "ignoreDeclarationSort": true + } + ], "func-style": "off", - "eqeqeq": ["error", "always", { "null": "ignore" }], + "eqeqeq": [ + "error", + "always", + { + "null": "ignore" + } + ], "curly": "error", "no-promise-executor-return": "off", "capitalized-comments": "off", "id-length": "off", - "max-lines": ["error", { "max": 300, "skipBlankLines": true, "skipComments": true }], + "max-lines": [ + "error", + { + "max": 300, + "skipBlankLines": true, + "skipComments": true + } + ], "max-lines-per-function": "off", "max-statements": "off", "import/max-dependencies": "off", @@ -153,7 +191,15 @@ "@typescript-eslint/no-namespace": "off", "import/no-namespace": "off", "oxc/no-rest-spread-properties": "off", - "react/only-export-components": "off" + "react/only-export-components": "off", + "anti-slop/no-chained-type-assertions": "error", + "anti-slop/no-known-value-widening": "error", + "anti-slop/no-object-parameters": "error", + "anti-slop/no-reflect-apply": "error", + "anti-slop/no-reflect-get": "error", + "anti-slop/no-runtime-typeof": "error", + "anti-slop/no-unknown-returns": "error", + "zod-utils/no-inline-zod-schema": "error" }, "overrides": [ { @@ -185,6 +231,25 @@ "rules": { "react/style-prop-object": "off" } + }, + { + "files": [ + "**/*.test.ts", + "**/*.test.tsx", + "**/__tests__/**", + "**/*test-utils*", + "**/*test-helpers*" + ], + "rules": { + "anti-slop/no-chained-type-assertions": "off", + "anti-slop/no-known-value-widening": "off", + "anti-slop/no-object-parameters": "off", + "anti-slop/no-reflect-apply": "off", + "anti-slop/no-reflect-get": "off", + "anti-slop/no-runtime-typeof": "off", + "anti-slop/no-unknown-returns": "off", + "zod-utils/no-inline-zod-schema": "off" + } } ] } diff --git a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/[platform]/(edit)/repos.tsx b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/[platform]/(edit)/repos.tsx index 7c0d96a572..fccef81792 100644 --- a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/[platform]/(edit)/repos.tsx +++ b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/[platform]/(edit)/repos.tsx @@ -84,26 +84,26 @@ export default function ReposRoute() { const confirmedEmpty = !reposLoading && !reposError && !bitbucketNotReady && repoRows.length === 0; const orgScope = scope === PERSONAL_SCOPE ? undefined : scope; + // oxlint-disable-next-line anti-slop/no-known-value-widening -- deliberately partial (bitbucket has no entry); lookup must accept the full ReviewerPlatform key set const manageRepoAccessLabelByPlatform: Partial> = { github: 'repository access', gitlab: 'repository access', }; const manageRepoAccessLabel = manageRepoAccessLabelByPlatform[platform]; - const emptyStateCopyByPlatform: Record = - { - github: { - title: 'Install the GitHub app on repositories', - description: 'Grant the Kilo GitHub App access to the repositories you want reviewed.', - }, - gitlab: { - title: 'No repositories found', - description: 'You may need to grant access to more groups or projects on GitLab.', - }, - bitbucket: { - title: 'No repositories found', - description: 'No repositories are available in this Bitbucket workspace.', - }, - }; + const emptyStateCopyByPlatform = { + github: { + title: 'Install the GitHub app on repositories', + description: 'Grant the Kilo GitHub App access to the repositories you want reviewed.', + }, + gitlab: { + title: 'No repositories found', + description: 'You may need to grant access to more groups or projects on GitLab.', + }, + bitbucket: { + title: 'No repositories found', + description: 'No repositories are available in this Bitbucket workspace.', + }, + } satisfies Record; const emptyStateCopy = emptyStateCopyByPlatform[platform]; const setMode = (nextMode: 'all' | 'selected') => { diff --git a/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/settings/device-pairing.tsx b/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/settings/device-pairing.tsx index 6c33b31b03..f744817b18 100644 --- a/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/settings/device-pairing.tsx +++ b/apps/mobile/src/app/(app)/kiloclaw/[instance-id]/settings/device-pairing.tsx @@ -30,12 +30,12 @@ import { } from '@/lib/hooks/use-kiloclaw-queries'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; -const CHANNEL_LABELS: Record = { +const CHANNEL_LABELS = { telegram: 'Telegram', discord: 'Discord', slack: 'Slack', github: 'GitHub', -}; +} satisfies Record; export default function DevicePairingScreen() { const { 'instance-id': instanceId } = useLocalSearchParams<{ 'instance-id': string }>(); @@ -121,7 +121,9 @@ export default function DevicePairingScreen() { const hasAnyRequests = channelRequests.length > 0 || deviceRequests.length > 0; function handleApproveChannel(channel: string, code: string) { - const label = CHANNEL_LABELS[channel] ?? channel.charAt(0).toUpperCase() + channel.slice(1); + const label = Object.hasOwn(CHANNEL_LABELS, channel) + ? CHANNEL_LABELS[channel as keyof typeof CHANNEL_LABELS] + : channel.charAt(0).toUpperCase() + channel.slice(1); Alert.alert( 'Approve pairing request', `Allow ${label} (code: ${code}) to connect to your instance?`, @@ -197,8 +199,9 @@ export default function DevicePairingScreen() { )} - {CHANNEL_LABELS[request.channel] ?? - request.channel.charAt(0).toUpperCase() + request.channel.slice(1)} + {Object.hasOwn(CHANNEL_LABELS, request.channel) + ? CHANNEL_LABELS[request.channel as keyof typeof CHANNEL_LABELS] + : request.channel.charAt(0).toUpperCase() + request.channel.slice(1)} diff --git a/apps/mobile/src/components/agents/blocking-card-state.ts b/apps/mobile/src/components/agents/blocking-card-state.ts index 97fadaa41f..1dbdb69247 100644 --- a/apps/mobile/src/components/agents/blocking-card-state.ts +++ b/apps/mobile/src/components/agents/blocking-card-state.ts @@ -7,6 +7,8 @@ import { type Component, type RefObject } from 'react'; +import { readTrpcErrorField } from '@/lib/trpc-error'; + import { type BlockingInteraction } from './agent-interaction-policy'; type BlockingCardKind = 'question' | 'permission'; @@ -148,41 +150,6 @@ export function applyBlockingCardAppearance( return undefined; } -/** - * Extract the tRPC error code from a thrown value. The tRPC v11 client - * surfaces `data.code`; server-shaped errors expose `shape.data.code`. We - * also accept a top-level `code` field so future tRPC versions can't silently - * change the retryable/non-retryable boundary. - */ -function readTrpcErrorCode(error: unknown): string | undefined { - if (!error || typeof error !== 'object') { - return undefined; - } - const record = error as Record; - const data = record.data; - if (data && typeof data === 'object') { - const code = (data as Record).code; - if (typeof code === 'string') { - return code; - } - } - const shape = record.shape; - if (shape && typeof shape === 'object') { - const shapeData = (shape as Record).data; - if (shapeData && typeof shapeData === 'object') { - const code = (shapeData as Record).code; - if (typeof code === 'string') { - return code; - } - } - } - const top = record.code; - if (typeof top === 'string') { - return top; - } - return undefined; -} - /** * Classify a thrown submission failure into a retryable or non-retryable * blocking card error. The `action` argument lets callers distinguish a @@ -200,7 +167,7 @@ export function classifyBlockingSubmissionError( kind: BlockingCardKind, action: BlockingCardRetryAction = 'answer' ): BlockingCardSubmissionError { - const code = readTrpcErrorCode(error); + const code = readTrpcErrorField(error, 'code'); if (code === 'NOT_FOUND') { return { kind: 'non-retryable', diff --git a/apps/mobile/src/components/agents/chat-composer.tsx b/apps/mobile/src/components/agents/chat-composer.tsx index a9104a6768..e74698ec9b 100644 --- a/apps/mobile/src/components/agents/chat-composer.tsx +++ b/apps/mobile/src/components/agents/chat-composer.tsx @@ -249,7 +249,7 @@ export function ChatComposer({ // and writes through it during settle. The adapter routes every read and // write through the SubmitLock above, so the helper participates in the // same admission gate without introducing a second, racing authority. - const submissionLockRef: { current: boolean } = { + const submissionLockRef = { get current() { return sendLockRef.current.isLocked(); }, @@ -260,7 +260,7 @@ export function ChatComposer({ sendLockRef.current.release(); } }, - }; + } satisfies { current: boolean }; const upload = useAgentAttachmentUpload({ organizationId }); const measure = useTextHeight({ diff --git a/apps/mobile/src/components/agents/child-session-card-state.ts b/apps/mobile/src/components/agents/child-session-card-state.ts index a197949969..f2df7217d4 100644 --- a/apps/mobile/src/components/agents/child-session-card-state.ts +++ b/apps/mobile/src/components/agents/child-session-card-state.ts @@ -18,10 +18,12 @@ export type ChildSessionCardState = { }; function getStringProperty(obj: unknown, key: string): string | undefined { + // oxlint-disable-next-line anti-slop/no-runtime-typeof -- generic payload walker over heterogeneous tool inputs; no static shape to narrow against if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) { return undefined; } const value = (obj as Record)[key]; + // oxlint-disable-next-line anti-slop/no-runtime-typeof -- generic payload walker over heterogeneous tool inputs; no static shape to narrow against return typeof value === 'string' ? value : undefined; } @@ -100,6 +102,7 @@ export function getChildSessionCardState( } export function getChildSessionActivityLabel(activity: ChildSessionActivity | string): string { + // oxlint-disable-next-line anti-slop/no-runtime-typeof -- distinguishing the string-vs-object ChildSessionActivity variant has no non-typeof discriminant if (typeof activity === 'string') { return activity; } diff --git a/apps/mobile/src/components/agents/child-session-section.tsx b/apps/mobile/src/components/agents/child-session-section.tsx index a8ec2a0f75..4ca2f332fe 100644 --- a/apps/mobile/src/components/agents/child-session-section.tsx +++ b/apps/mobile/src/components/agents/child-session-section.tsx @@ -95,14 +95,17 @@ export function ChildSessionSection({ {taskName} - {typeof latestActivity === 'string' ? ( - latestActivity - ) : ( - <> - {latestActivity.tool} - {latestActivity.context ? ` ${latestActivity.context}` : ''} - - )} + { + // oxlint-disable-next-line anti-slop/no-runtime-typeof -- ChildSessionActivity has no discriminant field to narrow on, and `'tool' in latestActivity` would throw for the string variant. + typeof latestActivity === 'string' ? ( + latestActivity + ) : ( + <> + {latestActivity.tool} + {latestActivity.context ? ` ${latestActivity.context}` : ''} + + ) + } diff --git a/apps/mobile/src/components/agents/collect-copyable-text.ts b/apps/mobile/src/components/agents/collect-copyable-text.ts index afaae05f89..b84b8683ed 100644 --- a/apps/mobile/src/components/agents/collect-copyable-text.ts +++ b/apps/mobile/src/components/agents/collect-copyable-text.ts @@ -1,43 +1,27 @@ -type TextPartLike = { type: string; text: string; synthetic?: boolean }; - -type ReasoningPartLike = { type: string; text: string }; - -type ToolStateLike = - | { status: 'pending'; input: Record } - | { status: 'running'; input: Record; title?: string } - | { status: 'completed'; input: Record; output: string; title: string } - | { status: 'error'; input: Record; error: string }; - -type ToolPartLike = { - type: string; - tool: string; - state: ToolStateLike; -}; - -type CopyablePart = TextPartLike | ReasoningPartLike | ToolPartLike | { type: string }; +import { + type Part, + type ReasoningPart, + type TextPart, + type ToolPart, +} from '@kilocode/cloud-agent-sdk'; type CopyableMessage = { - parts: readonly CopyablePart[]; + parts: readonly Part[]; }; -function isTextPartLike(part: CopyablePart): part is TextPartLike { - return part.type === 'text' && typeof (part as TextPartLike).text === 'string'; +function isTextPart(part: Part): part is TextPart { + return part.type === 'text'; } -function isReasoningPartLike(part: CopyablePart): part is ReasoningPartLike { - return part.type === 'reasoning' && typeof (part as ReasoningPartLike).text === 'string'; +function isReasoningPart(part: Part): part is ReasoningPart { + return part.type === 'reasoning'; } -function isToolPartLike(part: CopyablePart): part is ToolPartLike { - return ( - part.type === 'tool' && - typeof (part as ToolPartLike).tool === 'string' && - typeof (part as ToolPartLike).state === 'object' && - typeof (part as ToolPartLike).state.status === 'string' - ); +function isToolPart(part: Part): part is ToolPart { + return part.type === 'tool'; } -function isSnapshotProgressText(part: TextPartLike): boolean { +function isSnapshotProgressText(part: TextPart): boolean { return part.synthetic === true && part.text.includes('Initializing snapshot'); } @@ -53,7 +37,7 @@ function formatToolInput(input: Record): string { } } -function collectToolPartText(part: ToolPartLike): string { +function collectToolPartText(part: ToolPart): string { const payload: string[] = []; const inputText = formatToolInput(part.state.input); if (inputText) { @@ -75,13 +59,13 @@ function collectToolPartText(part: ToolPartLike): string { export function collectCopyableText(message: CopyableMessage): string { return message.parts .map(part => { - if (isTextPartLike(part)) { + if (isTextPart(part)) { return isSnapshotProgressText(part) ? '' : part.text; } - if (isReasoningPartLike(part)) { + if (isReasoningPart(part)) { return part.text; } - if (isToolPartLike(part)) { + if (isToolPart(part)) { return collectToolPartText(part); } return ''; diff --git a/apps/mobile/src/components/agents/compute-status.ts b/apps/mobile/src/components/agents/compute-status.ts index b752a757a5..e45cd15c7a 100644 --- a/apps/mobile/src/components/agents/compute-status.ts +++ b/apps/mobile/src/components/agents/compute-status.ts @@ -2,7 +2,7 @@ import { type Part } from '@kilocode/cloud-agent-sdk'; import { isSnapshotProgressPart } from './part-types'; -const toolStatusMap: Record = { +const toolStatusMap = { read: 'Exploring', grep: 'Searching the codebase', glob: 'Searching the codebase', @@ -17,14 +17,16 @@ const toolStatusMap: Record = { todoread: 'Planning next steps', task: 'Delegating work', question: 'Asking a question', -}; +} satisfies Record; /** Matches CLI PROGRESS_INITIALIZING typography (U+2026 ellipsis). */ export const SNAPSHOT_PROGRESS_STATUS = 'Initializing snapshot…'; export function computeStatus(part: Part): string { if (part.type === 'tool') { - return toolStatusMap[part.tool] ?? 'Considering next steps'; + return Object.hasOwn(toolStatusMap, part.tool) + ? toolStatusMap[part.tool as keyof typeof toolStatusMap] + : 'Considering next steps'; } if (part.type === 'reasoning') { return 'Thinking'; diff --git a/apps/mobile/src/components/agents/context-usage-ring.tsx b/apps/mobile/src/components/agents/context-usage-ring.tsx index 8b98aa6c0d..9488b89ba6 100644 --- a/apps/mobile/src/components/agents/context-usage-ring.tsx +++ b/apps/mobile/src/components/agents/context-usage-ring.tsx @@ -16,12 +16,12 @@ type ContextUsageRingProps = { const DEFAULT_SIZE = 28; const DEFAULT_STROKE = 3; -const TONE_COLORS: Record> = { +const TONE_COLORS = { destructive: 'destructive', warning: 'warn', primary: 'primary', neutral: 'mutedForeground', -}; +} satisfies Record>; function toneColor(tone: ContextTone, colors: ReturnType): string { return colors[TONE_COLORS[tone]]; diff --git a/apps/mobile/src/components/agents/markdown-a11y.ts b/apps/mobile/src/components/agents/markdown-a11y.ts index 8e25e31f1f..332ae2f084 100644 --- a/apps/mobile/src/components/agents/markdown-a11y.ts +++ b/apps/mobile/src/components/agents/markdown-a11y.ts @@ -1,3 +1,6 @@ +/* oxlint-disable anti-slop/no-runtime-typeof -- walks arbitrary ReactNode + * trees and untyped component props at runtime; there is no shared + * discriminant to narrow string/number leaves or generic prop values. */ import { isValidElement, type ReactNode } from 'react'; /** diff --git a/apps/mobile/src/components/agents/markdown-html-image.ts b/apps/mobile/src/components/agents/markdown-html-image.ts index 5eeda2495d..93631ebaa1 100644 --- a/apps/mobile/src/components/agents/markdown-html-image.ts +++ b/apps/mobile/src/components/agents/markdown-html-image.ts @@ -7,18 +7,20 @@ export type HtmlImage = { aspectRatio: number | undefined; }; -const ENTITIES: Record = { +const ENTITIES = { '&': '&', '"': '"', ''': "'", '<': '<', '>': '>', -}; +} satisfies Record; const ENTITY_RE = /&(?:amp|quot|#39|lt|gt);/g; function decodeEntities(value: string): string { - return value.replace(ENTITY_RE, match => ENTITIES[match] ?? match); + return value.replace(ENTITY_RE, match => + Object.hasOwn(ENTITIES, match) ? ENTITIES[match as keyof typeof ENTITIES] : match + ); } const ATTR_SRC = /(?:^|\s)src\s*=\s*["']([^"']*)["']/i; diff --git a/apps/mobile/src/components/agents/markdown-link.ts b/apps/mobile/src/components/agents/markdown-link.ts index 1a1b5a9492..cb31b3f582 100644 --- a/apps/mobile/src/components/agents/markdown-link.ts +++ b/apps/mobile/src/components/agents/markdown-link.ts @@ -16,7 +16,7 @@ export function resolveLinkAccessibilityLabel( if (title?.trim()) { return title.trim(); } - if (typeof children === 'string' && children.trim()) { + if (!Array.isArray(children) && children.trim()) { return children.trim(); } return getUrlHost(href) ?? href; diff --git a/apps/mobile/src/components/agents/markdown-renderer.ts b/apps/mobile/src/components/agents/markdown-renderer.ts index c1780fb7a1..ef0ab72255 100644 --- a/apps/mobile/src/components/agents/markdown-renderer.ts +++ b/apps/mobile/src/components/agents/markdown-renderer.ts @@ -1,7 +1,6 @@ import { createElement, isValidElement, type ReactNode } from 'react'; import { type AccessibilityActionEvent, - type AccessibilityActionInfo, type AccessibilityRole, type GestureResponderEvent, type ImageStyle, @@ -73,10 +72,12 @@ function containsMeaningfulNonImageText(nodes: ReactNode[]): boolean { if (containsMeaningfulNonImageText(node as ReactNode[])) { return true; } + // oxlint-disable-next-line anti-slop/no-runtime-typeof -- ReactNode primitive-arm check; no non-typeof discriminant separates string/number in this union } else if (typeof node === 'string') { if (node.trim().length > 0) { return true; } + // oxlint-disable-next-line anti-slop/no-runtime-typeof -- ReactNode primitive-arm check; no non-typeof discriminant separates string/number in this union } else if (typeof node === 'number') { return true; } else if (isValidElement(node)) { @@ -146,7 +147,7 @@ export class MarkdownRenderer extends Renderer { } private textOrChildren(children: string | ReactNode[], styles?: TextStyle): ReactNode { - if (typeof children !== 'string' && children.length > 0 && containsMarkdownImage(children)) { + if (Array.isArray(children) && children.length > 0 && containsMarkdownImage(children)) { return children; } return this.textNode(children, styles); @@ -157,7 +158,7 @@ export class MarkdownRenderer extends Renderer { // the image keeps its own tap target and label. A heading that mixes an // image with real text keeps header semantics, so wrap it in a // header-role View that leaves the image reachable. - if (typeof text !== 'string' && text.length > 0 && containsMarkdownImage(text)) { + if (Array.isArray(text) && text.length > 0 && containsMarkdownImage(text)) { if (containsMeaningfulNonImageText(text)) { return createElement(View, { accessibilityRole: 'header', key: this.getKey() }, text); } @@ -198,7 +199,7 @@ export class MarkdownRenderer extends Renderer { title?: string ): ReactNode { const interactionProps = this.linkInteractionProps(children, href, title); - if (typeof children !== 'string' && children.length > 0 && containsMarkdownImage(children)) { + if (Array.isArray(children) && children.length > 0 && containsMarkdownImage(children)) { return createElement(Pressable, { ...interactionProps, key: this.getKey() }, children); } return createElement( @@ -214,22 +215,10 @@ export class MarkdownRenderer extends Renderer { } /** Interaction wiring shared by the Pressable (image) and Text link branches. */ - private linkInteractionProps( - children: string | ReactNode[], - href: string, - title?: string - ): { - accessibilityRole: 'link'; - accessibilityHint: string; - accessibilityLabel: string; - accessibilityActions: AccessibilityActionInfo[] | undefined; - onAccessibilityAction: (event: AccessibilityActionEvent) => void; - onLongPress: ((event: GestureResponderEvent) => void) | undefined; - onPress: () => void; - } { + private linkInteractionProps(children: string | ReactNode[], href: string, title?: string) { const accessibilityLabel = resolveLinkAccessibilityLabel(children, href, title); return { - accessibilityRole: 'link', + accessibilityRole: 'link' as const, accessibilityHint: LINK_ACCESSIBILITY_HINT, accessibilityLabel, accessibilityActions: getLinkAccessibilityActions(this.onLongPressLink !== undefined), @@ -290,7 +279,7 @@ export class MarkdownRenderer extends Renderer { } override html(text: string | ReactNode[], styles?: TextStyle): ReactNode { - if (typeof text !== 'string') { + if (Array.isArray(text)) { return this.textOrChildren(text, styles); } const images = parseHtmlImages(text); diff --git a/apps/mobile/src/components/agents/markdown-table.tsx b/apps/mobile/src/components/agents/markdown-table.tsx index 94da495ca8..987e16e72b 100644 --- a/apps/mobile/src/components/agents/markdown-table.tsx +++ b/apps/mobile/src/components/agents/markdown-table.tsx @@ -108,6 +108,7 @@ export function MarkdownTable({ palette, header, rows }: Readonly[]; const applyZoom = useCallback( diff --git a/apps/mobile/src/components/agents/message-copy-text.test.ts b/apps/mobile/src/components/agents/message-copy-text.test.ts index 7a7bfe16d8..141e64abfd 100644 --- a/apps/mobile/src/components/agents/message-copy-text.test.ts +++ b/apps/mobile/src/components/agents/message-copy-text.test.ts @@ -1,115 +1,147 @@ +import { + type FilePart, + type Part, + type ReasoningPart, + type TextPart, + type ToolPart, +} from '@kilocode/cloud-agent-sdk'; import { describe, expect, it } from 'vitest'; import { collectCopyableText } from './collect-copyable-text'; -type TestMessage = { - parts: { type: string; text?: string; url?: string; synthetic?: boolean }[]; -}; +function makeTextPart(overrides: Partial = {}): TextPart { + return { + id: 'part-1', + sessionID: 'session-1', + messageID: 'message-1', + type: 'text', + text: '', + ...overrides, + }; +} + +function makeReasoningPart(overrides: Partial = {}): ReasoningPart { + return { + id: 'part-1', + sessionID: 'session-1', + messageID: 'message-1', + type: 'reasoning', + text: '', + time: { start: 0 }, + ...overrides, + }; +} + +function makeFilePart(overrides: Partial = {}): FilePart { + return { + id: 'part-1', + sessionID: 'session-1', + messageID: 'message-1', + type: 'file', + mime: 'text/plain', + url: 'x', + ...overrides, + }; +} + +function makeToolPart(tool: string, state: ToolPart['state']): ToolPart { + return { + id: 'part-1', + sessionID: 'session-1', + messageID: 'message-1', + type: 'tool', + callID: 'call-1', + tool, + state, + }; +} + +function message(parts: Part[]): { parts: Part[] } { + return { parts }; +} describe('collectCopyableText', () => { it('joins text parts and ignores non-text parts', () => { - const message: TestMessage = { - parts: [ - { type: 'text', text: 'Hello' }, - { type: 'file', url: 'x' }, - { type: 'text', text: 'world' }, - ], - }; - expect(collectCopyableText(message)).toBe('Hello\n\nworld'); + expect( + collectCopyableText( + message([makeTextPart({ text: 'Hello' }), makeFilePart(), makeTextPart({ text: 'world' })]) + ) + ).toBe('Hello\n\nworld'); }); it('returns empty string when no text parts', () => { - const message: TestMessage = { - parts: [{ type: 'file', url: 'x' }], - }; - expect(collectCopyableText(message)).toBe(''); + expect(collectCopyableText(message([makeFilePart()]))).toBe(''); }); it('excludes synthetic snapshot-progress text parts from copy', () => { - const message: TestMessage = { - parts: [ - { type: 'text', text: '⠋ Initializing snapshot…', synthetic: true }, - { type: 'text', text: 'Real answer' }, - ], - }; - expect(collectCopyableText(message)).toBe('Real answer'); + expect( + collectCopyableText( + message([ + makeTextPart({ text: '⠋ Initializing snapshot…', synthetic: true }), + makeTextPart({ text: 'Real answer' }), + ]) + ) + ).toBe('Real answer'); }); it('keeps non-synthetic text that mentions Initializing snapshot', () => { - const message: TestMessage = { - parts: [{ type: 'text', text: 'Note: Initializing snapshot can take a while' }], - }; - expect(collectCopyableText(message)).toBe('Note: Initializing snapshot can take a while'); + expect( + collectCopyableText( + message([makeTextPart({ text: 'Note: Initializing snapshot can take a while' })]) + ) + ).toBe('Note: Initializing snapshot can take a while'); }); it('keeps synthetic user optimistic text parts', () => { - const message: TestMessage = { - parts: [{ type: 'text', text: 'User typed this', synthetic: true }], - }; - expect(collectCopyableText(message)).toBe('User typed this'); + expect( + collectCopyableText(message([makeTextPart({ text: 'User typed this', synthetic: true })])) + ).toBe('User typed this'); }); it('includes reasoning text parts', () => { - const message = { - parts: [ - { type: 'text', text: 'First' }, - { type: 'reasoning', text: 'I should think step by step.' }, - { type: 'text', text: 'Second' }, - ], - }; - expect(collectCopyableText(message)).toBe('First\n\nI should think step by step.\n\nSecond'); + expect( + collectCopyableText( + message([ + makeTextPart({ text: 'First' }), + makeReasoningPart({ text: 'I should think step by step.' }), + makeTextPart({ text: 'Second' }), + ]) + ) + ).toBe('First\n\nI should think step by step.\n\nSecond'); }); it('includes a bash tool part with command and output', () => { - const message = { - parts: [ - { - type: 'tool', - tool: 'bash', - state: { - status: 'completed', - input: { command: 'ls -la', description: 'List files' }, - output: 'total 0\ndrwxr-xr-x', - }, - }, - ], - }; - expect(collectCopyableText(message)).toBe( + const part = makeToolPart('bash', { + status: 'completed', + input: { command: 'ls -la', description: 'List files' }, + output: 'total 0\ndrwxr-xr-x', + title: 'bash', + metadata: {}, + time: { start: 0, end: 1 }, + }); + expect(collectCopyableText(message([part]))).toBe( 'bash\n{\n "command": "ls -la",\n "description": "List files"\n}\ntotal 0\ndrwxr-xr-x' ); }); it('includes a tool part with an error and no output', () => { - const message = { - parts: [ - { - type: 'tool', - tool: 'read', - state: { - status: 'error', - input: { filePath: '/missing.txt' }, - error: 'No such file or directory', - }, - }, - ], - }; - expect(collectCopyableText(message)).toBe( + const part = makeToolPart('read', { + status: 'error', + input: { filePath: '/missing.txt' }, + error: 'No such file or directory', + time: { start: 0, end: 1 }, + }); + expect(collectCopyableText(message([part]))).toBe( 'read\n{\n "filePath": "/missing.txt"\n}\nError: No such file or directory' ); }); it('skips a tool part with no input, output, or error', () => { - const message = { - parts: [ - { type: 'text', text: 'Hello' }, - { - type: 'tool', - tool: 'pending_tool', - state: { status: 'pending', input: {} }, - }, - { type: 'text', text: 'Goodbye' }, - ], - }; - expect(collectCopyableText(message)).toBe('Hello\n\nGoodbye'); + const part = makeToolPart('pending_tool', { status: 'pending', input: {}, raw: '' }); + expect( + collectCopyableText( + message([makeTextPart({ text: 'Hello' }), part, makeTextPart({ text: 'Goodbye' })]) + ) + ).toBe('Hello\n\nGoodbye'); }); }); diff --git a/apps/mobile/src/components/agents/message-model-label.ts b/apps/mobile/src/components/agents/message-model-label.ts index 7e354f81f7..8e169cf1c7 100644 --- a/apps/mobile/src/components/agents/message-model-label.ts +++ b/apps/mobile/src/components/agents/message-model-label.ts @@ -1,4 +1,5 @@ import { getStepFinishRoutedModel } from '@kilocode/cloud-agent-sdk/part-utils'; +import { z } from 'zod'; import { type StoredMessage } from '@kilocode/cloud-agent-sdk'; @@ -13,6 +14,10 @@ import { type StoredMessage } from '@kilocode/cloud-agent-sdk'; type ResolvedModel = { providerID: string; modelID: string }; +// Stored messages can predate `providerID`/`modelID` being required fields; +// tolerate legacy records that are missing or blank them. +const nonEmptyStringSchema = z.string().min(1); + /** * Pick the (providerID, modelID) that should be displayed for an assistant * message, preferring the routed model stamped on the last step-finish @@ -42,13 +47,9 @@ export function resolveMessageDisplayModel(message: StoredMessage): ResolvedMode } // Fall back to the info-level model the message was created with. - const { providerID, modelID } = message.info; - if ( - typeof providerID === 'string' && - providerID.length > 0 && - typeof modelID === 'string' && - modelID.length > 0 - ) { + const providerID = nonEmptyStringSchema.safeParse(message.info.providerID).data; + const modelID = nonEmptyStringSchema.safeParse(message.info.modelID).data; + if (providerID && modelID) { return { providerID, modelID }; } return null; diff --git a/apps/mobile/src/components/agents/mobile-session-diagnostics.ts b/apps/mobile/src/components/agents/mobile-session-diagnostics.ts index f9a24041bf..0c378016b8 100644 --- a/apps/mobile/src/components/agents/mobile-session-diagnostics.ts +++ b/apps/mobile/src/components/agents/mobile-session-diagnostics.ts @@ -21,6 +21,7 @@ type ErrorRecord = { }; function getRecord(value: unknown): Record | null { + // oxlint-disable-next-line anti-slop/no-runtime-typeof -- generic payload walker over an arbitrary caught error shape; no static shape to narrow against if (typeof value !== 'object' || value === null) { return null; } @@ -28,10 +29,12 @@ function getRecord(value: unknown): Record | null { } function getString(value: unknown): string | undefined { + // oxlint-disable-next-line anti-slop/no-runtime-typeof -- generic payload walker over an arbitrary caught error shape; no static shape to narrow against return typeof value === 'string' && value.length > 0 ? value : undefined; } function getNumber(value: unknown): number | undefined { + // oxlint-disable-next-line anti-slop/no-runtime-typeof -- generic payload walker over an arbitrary caught error shape; no static shape to narrow against return typeof value === 'number' ? value : undefined; } diff --git a/apps/mobile/src/components/agents/mobile-session-manager.ts b/apps/mobile/src/components/agents/mobile-session-manager.ts index 2765d03820..dd79b7422c 100644 --- a/apps/mobile/src/components/agents/mobile-session-manager.ts +++ b/apps/mobile/src/components/agents/mobile-session-manager.ts @@ -22,6 +22,7 @@ import { API_BASE_URL, CLOUD_AGENT_WS_URL, WEB_BASE_URL } from '@/lib/config'; import { SPAWNED_NOT_FOUND_MAX_ATTEMPTS } from '@/lib/spawned-not-found-retry'; import { trpcClient } from '@/lib/trpc'; import { getAuthTokenForRequest } from '@/lib/auth/token-owner'; +import { readTrpcErrorField } from '@/lib/trpc-error'; import { createNativeUserWebConnectionLifecycleHooks } from '@/lib/user-web-connection-lifecycle'; import { cacheToolAttachment } from '@/components/agents/tool-card-image-cache'; import { cacheFilePart } from '@/components/agents/file-part-cache'; @@ -38,32 +39,7 @@ const FETCH_SESSION_NOT_FOUND_RETRY_DELAY_MS = 1000; * session-detail route and blocking-card classifier use. */ export function readFetchSessionErrorCode(error: unknown): string | undefined { - if (!error || typeof error !== 'object') { - return undefined; - } - const record = error as Record; - const data = record.data; - if (data && typeof data === 'object') { - const code = (data as Record).code; - if (typeof code === 'string') { - return code; - } - } - const shape = record.shape; - if (shape && typeof shape === 'object') { - const shapeData = (shape as Record).data; - if (shapeData && typeof shapeData === 'object') { - const code = (shapeData as Record).code; - if (typeof code === 'string') { - return code; - } - } - } - const top = record.code; - if (typeof top === 'string') { - return top; - } - return undefined; + return readTrpcErrorField(error, 'code'); } /** @@ -215,10 +191,10 @@ export function createMobileAgentSessionManager({ ): Promise<{ ticket: string; expiresAt: number }> => { const result = await withCloudAgentDiagnostics('getTicket', organizationId, async () => { const token = await getAuthTokenForRequest(); - const body: Record = { cloudAgentSessionId: sessionId }; - if (organizationId) { - body.organizationId = organizationId; - } + const body = { + cloudAgentSessionId: sessionId, + ...(organizationId ? { organizationId } : {}), + }; const response = await fetch( `${API_BASE_URL}/api/cloud-agent-next/sessions/stream-ticket`, { diff --git a/apps/mobile/src/components/agents/mode-normalize.ts b/apps/mobile/src/components/agents/mode-normalize.ts index 1118430cbc..d65c16038b 100644 --- a/apps/mobile/src/components/agents/mode-normalize.ts +++ b/apps/mobile/src/components/agents/mode-normalize.ts @@ -151,7 +151,7 @@ export function resolvePinnedAgentModel(input: { slug: string; profileAgents?: ProfileAgent[]; runtimeAgents?: RuntimeAgent[]; -}): { model?: string; variant?: string; agentName?: string } { +}) { const profileAgent = input.profileAgents?.find(a => a.slug === input.slug); const runtimeAgent = input.runtimeAgents?.find(a => a.slug === input.slug); diff --git a/apps/mobile/src/components/agents/mode-options.ts b/apps/mobile/src/components/agents/mode-options.ts index ce3bc36e87..7ce6f3658c 100644 --- a/apps/mobile/src/components/agents/mode-options.ts +++ b/apps/mobile/src/components/agents/mode-options.ts @@ -23,13 +23,13 @@ export const MODE_OPTIONS: ModeOption[] = [ { value: 'ask', label: 'Ask', description: 'Get answers and explanations' }, ]; -const MODE_ICONS: Record = { +const MODE_ICONS = { code: Code, plan: NotebookPen, debug: Bug, orchestrator: Workflow, ask: HelpCircle, -}; +} satisfies Record; export function getModeIcon(mode: string | null | undefined): LucideIcon { const normalized = normalizeAgentMode(mode); diff --git a/apps/mobile/src/components/agents/model-selector.tsx b/apps/mobile/src/components/agents/model-selector.tsx index 395fd40f06..f0d690335c 100644 --- a/apps/mobile/src/components/agents/model-selector.tsx +++ b/apps/mobile/src/components/agents/model-selector.tsx @@ -72,12 +72,7 @@ export function ModelPickerSelectionScopeProvider({ } function toSessionModelOption(option: ModelOption | SessionModelOption): SessionModelOption { - if ( - 'displayId' in option && - typeof option.displayId === 'string' && - 'showGatewayMetadata' in option && - typeof option.showGatewayMetadata === 'boolean' - ) { + if ('displayId' in option && 'showGatewayMetadata' in option) { return { ...option, displayId: option.displayId, diff --git a/apps/mobile/src/components/agents/mono-scroll-block-model.ts b/apps/mobile/src/components/agents/mono-scroll-block-model.ts index 93ceec9cae..4d3348a293 100644 --- a/apps/mobile/src/components/agents/mono-scroll-block-model.ts +++ b/apps/mobile/src/components/agents/mono-scroll-block-model.ts @@ -2,10 +2,7 @@ * Caps long mono payloads for display. Returns a sliced copy and whether the * cap applied — never mutates the caller's string. */ -export function prepareMonoScrollContent( - content: string, - maxLength?: number -): { displayText: string; isTruncated: boolean } { +export function prepareMonoScrollContent(content: string, maxLength?: number) { if (maxLength === undefined || content.length <= maxLength) { return { displayText: content, isTruncated: false }; } diff --git a/apps/mobile/src/components/agents/read-tool-markdown.ts b/apps/mobile/src/components/agents/read-tool-markdown.ts index 456f8f8e7f..2056a7290c 100644 --- a/apps/mobile/src/components/agents/read-tool-markdown.ts +++ b/apps/mobile/src/components/agents/read-tool-markdown.ts @@ -1,4 +1,5 @@ import { type ToolPart } from '@kilocode/cloud-agent-sdk'; +import { z } from 'zod'; export type ReadFileDisplay = { path: string; @@ -29,39 +30,31 @@ export function isMarkdownPath(filePath: string): boolean { return /\.mdx?$/i.test(filePath.trim()); } +/** Zod's validation `.catch()` fallback, not a Promise catch. */ +function tolerant(schema: z.ZodType, fallback: T): z.ZodType { + // oxlint-disable-next-line promise/prefer-await-to-then -- zod schema fallback, not a Promise + return schema.catch(fallback); +} + +const readFileDisplaySchema = z.object({ + display: z.object({ + type: z.literal('file'), + text: z.string(), + path: tolerant(z.string(), ''), + lineStart: z.number(), + lineEnd: z.number(), + totalLines: z.number(), + truncated: tolerant(z.literal(true), false), + }), +}); + export function parseReadFileDisplay(metadata: unknown): ReadFileDisplay | undefined { - if (metadata === null || typeof metadata !== 'object') { - return undefined; - } - const display = (metadata as { display?: unknown }).display; - if (display === null || typeof display !== 'object') { - return undefined; - } - const d = display as Record; - if (d.type !== 'file') { + const parsed = readFileDisplaySchema.safeParse(metadata); + if (!parsed.success) { return undefined; } - if (typeof d.text !== 'string') { - return undefined; - } - if ( - typeof d.lineStart !== 'number' || - !Number.isFinite(d.lineStart) || - typeof d.lineEnd !== 'number' || - !Number.isFinite(d.lineEnd) || - typeof d.totalLines !== 'number' || - !Number.isFinite(d.totalLines) - ) { - return undefined; - } - return { - path: typeof d.path === 'string' ? d.path : '', - text: d.text, - lineStart: d.lineStart, - lineEnd: d.lineEnd, - totalLines: d.totalLines, - truncated: d.truncated === true, - }; + const { path, text, lineStart, lineEnd, totalLines, truncated } = parsed.data.display; + return { path, text, lineStart, lineEnd, totalLines, truncated }; } const LINE_PREFIX = /^(\d+): (.*)$/; @@ -113,10 +106,7 @@ function stripLinePrefixes(body: string): }; } -function trailerFields( - trailer: string | undefined, - lineEnd: number -): { totalLines: number; truncated: boolean } { +function trailerFields(trailer: string | undefined, lineEnd: number) { if (!trailer) { return { totalLines: lineEnd, truncated: false }; } diff --git a/apps/mobile/src/components/agents/session-context-metrics.tsx b/apps/mobile/src/components/agents/session-context-metrics.tsx index 409ea5fa25..11a9af717f 100644 --- a/apps/mobile/src/components/agents/session-context-metrics.tsx +++ b/apps/mobile/src/components/agents/session-context-metrics.tsx @@ -27,12 +27,12 @@ type SessionContextMetricsProps = { const RING_SIZE = 28; const RING_STROKE = 3; -const TONE_TEXT_CLASS: Record = { +const TONE_TEXT_CLASS = { destructive: 'text-destructive', warning: 'text-warn', primary: 'text-foreground', neutral: 'text-foreground', -}; +} satisfies Record; function toneTextClass(tone: ContextTone): string { return TONE_TEXT_CLASS[tone]; diff --git a/apps/mobile/src/components/agents/session-context-sheet.tsx b/apps/mobile/src/components/agents/session-context-sheet.tsx index ea740f2867..3e4c1be76e 100644 --- a/apps/mobile/src/components/agents/session-context-sheet.tsx +++ b/apps/mobile/src/components/agents/session-context-sheet.tsx @@ -45,12 +45,12 @@ type SessionContextSheetProps = { const SHEET_RING_SIZE = 96; const SHEET_RING_STROKE = 8; -const TONE_TEXT_CLASS: Record = { +const TONE_TEXT_CLASS = { destructive: 'text-destructive', warning: 'text-warn', primary: 'text-foreground', neutral: 'text-foreground', -}; +} satisfies Record; function toneTextClass(tone: ContextTone): string { return TONE_TEXT_CLASS[tone]; diff --git a/apps/mobile/src/components/agents/session-detail-content.tsx b/apps/mobile/src/components/agents/session-detail-content.tsx index e7b076cafc..7a5c6099c1 100644 --- a/apps/mobile/src/components/agents/session-detail-content.tsx +++ b/apps/mobile/src/components/agents/session-detail-content.tsx @@ -141,10 +141,10 @@ type SessionDetailContentProps = { spawnedMode?: string; }; -const COMPOSER_PLACEHOLDERS: Partial> = { +const COMPOSER_PLACEHOLDERS = { preparing: 'Setting up environment...', finalizing: 'Wrapping up...', -}; +} satisfies Partial>; const EMPTY_IDS: ReadonlySet = new Set(); @@ -791,7 +791,9 @@ export function SessionDetailContent({ hasModel: Boolean(pinned.model ?? currentModel), }); const composerPlaceholder = - (cloudStatus && COMPOSER_PLACEHOLDERS[cloudStatus.type]) ?? 'Message...'; + (cloudStatus && + COMPOSER_PLACEHOLDERS[cloudStatus.type as keyof typeof COMPOSER_PLACEHOLDERS]) ?? + 'Message...'; const keyboardContainerKind = getSessionKeyboardContainerKind(Platform.OS); const handleSend = useCallback( diff --git a/apps/mobile/src/components/agents/session-list-helpers.ts b/apps/mobile/src/components/agents/session-list-helpers.ts index 900a114c01..8754ad8a79 100644 --- a/apps/mobile/src/components/agents/session-list-helpers.ts +++ b/apps/mobile/src/components/agents/session-list-helpers.ts @@ -20,17 +20,17 @@ export type SessionSection = { data: StoredSession[]; }; -const platformExpansion: Record = { - 'cloud-agent': ['cloud-agent', 'cloud-agent-web'], - extension: ['vscode', 'agent-manager'], -}; +const platformExpansion: ReadonlyMap = new Map([ + ['cloud-agent', ['cloud-agent', 'cloud-agent-web']], + ['extension', ['vscode', 'agent-manager']], +]); function stripGitSuffix(value: string): string { return value.endsWith('.git') ? value.slice(0, -4) : value; } export function expandPlatformFilter(filter: string[]): string[] { - return filter.flatMap(p => platformExpansion[p] ?? [p]); + return filter.flatMap(p => platformExpansion.get(p) ?? [p]); } export function formatGitUrlProject(gitUrl: string): string { @@ -103,9 +103,9 @@ export function formatSessionTotalCost(microdollars: number | null | undefined): export function selectSessionCostInputs( persistedMicrodollars: number | null | undefined, liveUsd: number -): { totalMicrodollars: number | null; breakdownCostUsd: number } { +) { const persisted = - typeof persistedMicrodollars === 'number' && Number.isFinite(persistedMicrodollars) + persistedMicrodollars != null && Number.isFinite(persistedMicrodollars) ? Math.max(0, persistedMicrodollars) : 0; const live = Number.isFinite(liveUsd) ? Math.max(0, Math.round(liveUsd * 1_000_000)) : 0; diff --git a/apps/mobile/src/components/agents/session-message-list.tsx b/apps/mobile/src/components/agents/session-message-list.tsx index eb0b49f9a0..3b25558970 100644 --- a/apps/mobile/src/components/agents/session-message-list.tsx +++ b/apps/mobile/src/components/agents/session-message-list.tsx @@ -1,4 +1,4 @@ -import { FlashList, type FlashListRef, type ListRenderItem } from '@shopify/flash-list'; +import { FlashList, type ListRenderItem } from '@shopify/flash-list'; import { type OlderMessagesError } from '@kilocode/cloud-agent-sdk'; import { ChevronDown } from '@/components/ui/icons'; import { useCallback, useEffect, useMemo, useRef } from 'react'; @@ -143,10 +143,6 @@ export function SessionMessageList({ olderArrivalNewestKeyRef.current = nextNewestKey; }, [items, keyExtractor]); - // Defensive: the structural list ref is required by the hook but - // downstream types may infer it as nullable. - const listRefSafe = listRef as unknown as React.RefObject>; - // When the optional `contentBottomInset` is omitted we return the // original module-level `listContentContainerStyle` reference so the // default-prop path is behavior-identical (no allocation, no value @@ -163,7 +159,7 @@ export function SessionMessageList({ return ( - ref={listRefSafe} + ref={listRef} style={listStyle} contentContainerStyle={resolvedContentContainerStyle} data={items} diff --git a/apps/mobile/src/components/agents/session-platform-icon.tsx b/apps/mobile/src/components/agents/session-platform-icon.tsx index 1f9c9b2b47..047034245c 100644 --- a/apps/mobile/src/components/agents/session-platform-icon.tsx +++ b/apps/mobile/src/components/agents/session-platform-icon.tsx @@ -7,7 +7,7 @@ import { repoNameFromGitUrl } from './session-list-helpers'; type SessionPlatformIconKind = 'cloud' | 'terminal' | 'code' | 'slack' | 'github'; -const PLATFORM_TO_KIND: Readonly> = { +const PLATFORM_TO_KIND = { 'cloud-agent': 'cloud', 'cloud-agent-web': 'cloud', cli: 'terminal', @@ -15,7 +15,7 @@ const PLATFORM_TO_KIND: Readonly> = { 'agent-manager': 'code', slack: 'slack', github: 'github', -}; +} satisfies Record; /** * Map a backend `created_on_platform` string to a list/detail icon kind. @@ -28,7 +28,9 @@ export function sessionPlatformIconKind( if (platform == null || platform === '') { return null; } - return PLATFORM_TO_KIND[platform] ?? null; + return Object.hasOwn(PLATFORM_TO_KIND, platform) + ? PLATFORM_TO_KIND[platform as keyof typeof PLATFORM_TO_KIND] + : null; } type RowPlatformPresentationInput = Readonly<{ diff --git a/apps/mobile/src/components/agents/session-pr-badge-model.ts b/apps/mobile/src/components/agents/session-pr-badge-model.ts index 83051efba4..a2744c129f 100644 --- a/apps/mobile/src/components/agents/session-pr-badge-model.ts +++ b/apps/mobile/src/components/agents/session-pr-badge-model.ts @@ -14,7 +14,7 @@ type PrBadgeDescriptor = Readonly<{ accessibilityLabel: string; }>; -const STATE_ARIA_LABELS: Readonly> = { +const STATE_ARIA_LABELS = { open: 'open pull request', closed: 'closed pull request', merged: 'merged pull request', @@ -22,7 +22,7 @@ const STATE_ARIA_LABELS: Readonly> = { // Mobile keeps `unknown` on the open presentation (never mapped to closed), // so it speaks as an open PR. unknown: 'open pull request', -}; +} satisfies Readonly>; /** * Bucket a raw PR state string into a badge state. Unlike the web helper, diff --git a/apps/mobile/src/components/agents/session-pr-badge.tsx b/apps/mobile/src/components/agents/session-pr-badge.tsx index f2ccdb7b53..2af904721c 100644 --- a/apps/mobile/src/components/agents/session-pr-badge.tsx +++ b/apps/mobile/src/components/agents/session-pr-badge.tsx @@ -25,28 +25,28 @@ import { type PrBadgeIconKind, } from './session-pr-badge-model'; -const ICON_BY_KIND: Readonly> = { +const ICON_BY_KIND = { check: CircleCheck, x: CircleX, 'pull-request': GitPullRequest, draft: GitPullRequestDraft, merge: GitMerge, closed: GitPullRequestClosed, -}; +} satisfies Readonly>; -const ACCENT_TEXT_CLASS: Readonly> = { +const ACCENT_TEXT_CLASS = { good: 'text-good', warn: 'text-warn', muted: 'text-muted-foreground', destructive: 'text-destructive', -}; +} satisfies Readonly>; -const ACCENT_COLOR_KEY: Readonly> = { +const ACCENT_COLOR_KEY = { good: 'good', warn: 'warn', muted: 'mutedForeground', destructive: 'destructive', -}; +} satisfies Readonly>; export type SessionPrBadgeProps = Readonly<{ pr: AssociatedPrData | null; diff --git a/apps/mobile/src/components/agents/session-row-accessibility-label.ts b/apps/mobile/src/components/agents/session-row-accessibility-label.ts index 69de8542e5..870e222bf0 100644 --- a/apps/mobile/src/components/agents/session-row-accessibility-label.ts +++ b/apps/mobile/src/components/agents/session-row-accessibility-label.ts @@ -25,14 +25,14 @@ export function formatSpokenTimeAgo(timestamp: string): string { } const n = Number(match[1]); const unit = match[2]; - const singular: Record = { + const singular = { m: 'minute', h: 'hour', d: 'day', mo: 'month', y: 'year', - }; - const word = unit ? singular[unit] : undefined; + } satisfies Record; + const word = unit ? singular[unit as keyof typeof singular] : undefined; if (!word) { // Unrecognized unit — pass through so a future `timeAgo` unit added // without updating this helper doesn't get silently mangled. diff --git a/apps/mobile/src/components/agents/suggestion-card-state.ts b/apps/mobile/src/components/agents/suggestion-card-state.ts index 66745d2ed6..8459c2272c 100644 --- a/apps/mobile/src/components/agents/suggestion-card-state.ts +++ b/apps/mobile/src/components/agents/suggestion-card-state.ts @@ -12,10 +12,7 @@ export function resolveSuggestionPresentation( : 'compact'; } -export function createSuggestionActionLock(): { - tryAcquire: () => boolean; - release: () => void; -} { +export function createSuggestionActionLock() { let held = false; return { tryAcquire: () => { diff --git a/apps/mobile/src/components/agents/tool-card-display.ts b/apps/mobile/src/components/agents/tool-card-display.ts index 07a95751a4..4f75dca671 100644 --- a/apps/mobile/src/components/agents/tool-card-display.ts +++ b/apps/mobile/src/components/agents/tool-card-display.ts @@ -1,4 +1,5 @@ import { type ToolPart } from '@kilocode/cloud-agent-sdk'; +import { z } from 'zod'; import { getToolFileAttachments, getToolImageAttachments } from './tool-card-attachments'; import { @@ -20,6 +21,35 @@ function countResultRows(output: string, kind: 'grep' | 'glob'): number { return buildResultRowsModel(output, kind).rows.length; } +/** Zod's validation `.catch()` fallback, not a Promise catch. */ +function tolerant(schema: z.ZodType, fallback: T): z.ZodType { + // oxlint-disable-next-line promise/prefer-await-to-then -- zod schema fallback, not a Promise + return schema.catch(fallback); +} + +const optionalString = tolerant(z.string().optional(), undefined); +const optionalNumber = tolerant(z.number().optional(), undefined); + +/** + * Tool input arrives as arbitrary, tool-defined JSON. Each field below is + * independently tolerant: a wrong-typed or missing value falls back to + * `undefined` rather than rejecting the whole payload. + */ +const toolInputSchema = z.object({ + filePath: optionalString, + path: optionalString, + offset: optionalNumber, + limit: optionalNumber, + command: optionalString, + description: optionalString, + pattern: optionalString, + include: optionalString, + patchText: optionalString, + query: optionalString, + url: optionalString, + prompt: optionalString, +}); + /** * Pure row projection for a tool part. The strings and badge rules are copied * verbatim from the tool-card bodies so the fixed row renders exactly what the @@ -28,12 +58,13 @@ function countResultRows(output: string, kind: 'grep' | 'glob'): number { export function getToolDisplay(part: ToolPart): ToolDisplay { const input = part.state.input; const status = part.state.status; + const fields = toolInputSchema.parse(input); switch (part.tool) { case 'read': { - const filePath = typeof input.filePath === 'string' ? input.filePath : ''; - const offset = typeof input.offset === 'number' ? input.offset : undefined; - const limit = typeof input.limit === 'number' ? input.limit : undefined; + const filePath = fields.filePath ?? ''; + const offset = fields.offset; + const limit = fields.limit; const badgeParts: string[] = []; if (offset !== undefined) { @@ -47,29 +78,29 @@ export function getToolDisplay(part: ToolPart): ToolDisplay { return { title: 'read', subtitle: filePath ? getFilename(filePath) : 'read', badge }; } case 'edit': { - const filePath = typeof input.filePath === 'string' ? input.filePath : ''; + const filePath = fields.filePath ?? ''; return { title: 'edit', subtitle: filePath ? getFilename(filePath) : 'edit' }; } case 'write': { - const filePath = typeof input.filePath === 'string' ? input.filePath : ''; + const filePath = fields.filePath ?? ''; return { title: 'write', subtitle: filePath ? getFilename(filePath) : 'write' }; } case 'bash': { - const command = typeof input.command === 'string' ? input.command : ''; - const description = typeof input.description === 'string' ? input.description : undefined; + const command = fields.command ?? ''; + const description = fields.description; const subtitle = description ?? (command ? truncateText(command, 60) : 'bash'); return { title: 'bash', subtitle }; } case 'glob': { - const pattern = typeof input.pattern === 'string' ? input.pattern : ''; + const pattern = fields.pattern ?? ''; const output = status === 'completed' ? part.state.output : undefined; const matchCount = output ? countResultRows(output, 'glob') : undefined; const badge = matchCount !== undefined && matchCount > 0 ? `${matchCount} files` : undefined; return { title: 'glob', subtitle: pattern || 'glob', badge }; } case 'grep': { - const pattern = typeof input.pattern === 'string' ? input.pattern : ''; - const include = typeof input.include === 'string' ? input.include : undefined; + const pattern = fields.pattern ?? ''; + const include = fields.include; let subtitle = pattern || 'grep'; if (include) { subtitle += ` (${include})`; @@ -81,14 +112,14 @@ export function getToolDisplay(part: ToolPart): ToolDisplay { return { title: 'grep', subtitle, badge }; } case 'list': { - const filePath = typeof input.filePath === 'string' ? input.filePath : undefined; - const path = typeof input.path === 'string' ? input.path : undefined; + const filePath = fields.filePath; + const path = fields.path; const resolvedPath = filePath ?? path ?? ''; return { title: 'list', subtitle: resolvedPath ? getDirectoryName(resolvedPath) : 'list' }; } case 'patch': case 'apply_patch': { - const patchText = typeof input.patchText === 'string' ? input.patchText : ''; + const patchText = fields.patchText ?? ''; const files = patchText ? listPatchFilePaths(patchText) : []; let subtitle = 'patch'; if (files.length === 1) { @@ -101,8 +132,8 @@ export function getToolDisplay(part: ToolPart): ToolDisplay { case 'websearch': case 'codesearch': case 'webfetch': { - const query = typeof input.query === 'string' ? input.query : undefined; - const url = typeof input.url === 'string' ? input.url : undefined; + const query = fields.query; + const url = fields.url; // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- empty query must fall back to url; ?? would skip '' const search = query || url; return { title: part.tool, subtitle: search ? truncateText(search, 60) : part.tool }; @@ -114,8 +145,8 @@ export function getToolDisplay(part: ToolPart): ToolDisplay { return { title: part.tool, subtitle: 'Update todos' }; } case 'task': { - const description = typeof input.description === 'string' ? input.description : undefined; - const prompt = typeof input.prompt === 'string' ? input.prompt : undefined; + const description = fields.description; + const prompt = fields.prompt; const subtitle = description ?? (prompt ? truncateText(prompt, 60) : 'task'); return { title: 'task', subtitle }; } diff --git a/apps/mobile/src/components/agents/tool-card-image-attachments.tsx b/apps/mobile/src/components/agents/tool-card-image-attachments.tsx index c8b5d965af..e04f20e63a 100644 --- a/apps/mobile/src/components/agents/tool-card-image-attachments.tsx +++ b/apps/mobile/src/components/agents/tool-card-image-attachments.tsx @@ -2,6 +2,7 @@ import { type ToolPart } from '@kilocode/cloud-agent-sdk'; import { AlertCircle, ImageOff } from '@/components/ui/icons'; import { useState } from 'react'; import { Pressable, View } from 'react-native'; +import { z } from 'zod'; import { ImageViewerModal } from '@/components/image-viewer-modal'; import { Image } from '@/components/ui/image'; @@ -17,6 +18,8 @@ import { import { useToolCardImageUri } from './tool-card-image-cache'; import { getFilename } from './tool-card-utils'; +const toolInputFilePathSchema = z.object({ filePath: z.string() }); + function UnavailableRow({ icon: Icon, message, @@ -104,7 +107,7 @@ export function ToolCardImageAttachments({ part }: Readonly<{ part: ToolPart }>) // entries would show the same cached bytes instead of their own content. // Prefer the first attachment's filename; fall back to tool input filePath. const attachmentFilename = attachments[0]?.filename; - const filePath = typeof part.state.input.filePath === 'string' ? part.state.input.filePath : ''; + const filePath = toolInputFilePathSchema.safeParse(part.state.input).data?.filePath ?? ''; const label = attachmentFilename ?? getFilename(filePath === '' ? part.tool : filePath); return ( diff --git a/apps/mobile/src/components/agents/tool-card-utils.ts b/apps/mobile/src/components/agents/tool-card-utils.ts index 95d524cf1b..36e48fa138 100644 --- a/apps/mobile/src/components/agents/tool-card-utils.ts +++ b/apps/mobile/src/components/agents/tool-card-utils.ts @@ -1,3 +1,7 @@ +import { z } from 'zod'; + +const optionalStringSchema = z.string().optional(); + export function getFilename(filePath: string): string { return filePath.split('/').pop() ?? filePath; } @@ -24,8 +28,8 @@ export function getGenericToolTitle( return title; } if (tool === 'mcp') { - const serverName = typeof input.server_name === 'string' ? input.server_name.trim() : ''; - const toolName = typeof input.tool_name === 'string' ? input.tool_name.trim() : ''; + const serverName = (optionalStringSchema.safeParse(input.server_name).data ?? '').trim(); + const toolName = (optionalStringSchema.safeParse(input.tool_name).data ?? '').trim(); if (serverName && toolName) { return `${serverName}/${toolName}`; } diff --git a/apps/mobile/src/components/agents/tool-cards/bash-tool-card.tsx b/apps/mobile/src/components/agents/tool-cards/bash-tool-card.tsx index b47da6ecdb..897e79e515 100644 --- a/apps/mobile/src/components/agents/tool-cards/bash-tool-card.tsx +++ b/apps/mobile/src/components/agents/tool-cards/bash-tool-card.tsx @@ -1,6 +1,7 @@ import { View } from 'react-native'; import { Terminal } from '@/components/ui/icons'; import { type ToolPart } from '@kilocode/cloud-agent-sdk'; +import { z } from 'zod'; import { SelectableText } from '@/components/ui/selectable-text'; @@ -9,6 +10,8 @@ import { MonoScrollBlock } from '../mono-scroll-block'; import { useOpenPartDetail } from '../open-part-detail-context'; import { getToolDisplay, toolPartHasDetails } from '../tool-card-display'; +const bashToolInputSchema = z.object({ command: z.string() }); + /** * Sheet body for a bash tool part: the `$ command` block, the output block, * and the error. Renders only inside the detail sheet — attachments and the @@ -16,7 +19,7 @@ import { getToolDisplay, toolPartHasDetails } from '../tool-card-display'; */ export function BashToolCardBody({ part }: Readonly<{ part: ToolPart }>) { const input = part.state.input; - const command = typeof input.command === 'string' ? input.command : ''; + const command = bashToolInputSchema.safeParse(input).data?.command ?? ''; const commandText = `$ ${command}`; const output = part.state.status === 'completed' ? part.state.output : undefined; diff --git a/apps/mobile/src/components/agents/tool-cards/edit-tool-card.tsx b/apps/mobile/src/components/agents/tool-cards/edit-tool-card.tsx index b433f6daa7..ba16359b6d 100644 --- a/apps/mobile/src/components/agents/tool-cards/edit-tool-card.tsx +++ b/apps/mobile/src/components/agents/tool-cards/edit-tool-card.tsx @@ -2,6 +2,7 @@ import { useMemo } from 'react'; import { View } from 'react-native'; import { Pencil } from '@/components/ui/icons'; import { type ToolPart } from '@kilocode/cloud-agent-sdk'; +import { z } from 'zod'; import { SelectableText } from '@/components/ui/selectable-text'; @@ -12,6 +13,8 @@ import { getToolDisplay, toolPartHasDetails } from '../tool-card-display'; import { buildToolDiffModel } from '../tool-diff-model'; import { ToolDiffPreview } from '../tool-diff-preview'; +const optionalStringSchema = z.string().optional(); + function EditFallbackBody({ oldString, newString, @@ -44,8 +47,8 @@ function EditFallbackBody({ */ export function EditToolCardBody({ part }: Readonly<{ part: ToolPart }>) { const input = part.state.input; - const oldString = typeof input.oldString === 'string' ? input.oldString : ''; - const newString = typeof input.newString === 'string' ? input.newString : ''; + const oldString = optionalStringSchema.safeParse(input.oldString).data ?? ''; + const newString = optionalStringSchema.safeParse(input.newString).data ?? ''; const error = part.state.status === 'error' ? part.state.error : undefined; diff --git a/apps/mobile/src/components/agents/tool-cards/read-tool-card.tsx b/apps/mobile/src/components/agents/tool-cards/read-tool-card.tsx index 12f37e4680..4c92291edc 100644 --- a/apps/mobile/src/components/agents/tool-cards/read-tool-card.tsx +++ b/apps/mobile/src/components/agents/tool-cards/read-tool-card.tsx @@ -2,6 +2,7 @@ import { type ReactNode } from 'react'; import { View } from 'react-native'; import { Eye } from '@/components/ui/icons'; import { type ToolPart } from '@kilocode/cloud-agent-sdk'; +import { z } from 'zod'; import { SelectableText } from '@/components/ui/selectable-text'; import { Text } from '@/components/ui/text'; @@ -25,6 +26,8 @@ import { getToolDisplay, toolPartHasDetails } from '../tool-card-display'; // Truncated marker. 50k chars matches the markdown fence cap. const READ_CODE_CHARACTER_CAP = 50_000; +const readToolInputSchema = z.object({ filePath: z.string() }); + /** * The code body per the read precedence chain: a parseable display (empty * text → the muted empty line, else highlighted CodeBlock + footer), else the @@ -73,7 +76,7 @@ function renderReadCodeBody( */ export function ReadToolCardBody({ part }: Readonly<{ part: ToolPart }>) { const input = part.state.input; - const filePath = typeof input.filePath === 'string' ? input.filePath : ''; + const filePath = readToolInputSchema.safeParse(input).data?.filePath ?? ''; const output = part.state.status === 'completed' ? part.state.output : undefined; const error = part.state.status === 'error' ? part.state.error : undefined; diff --git a/apps/mobile/src/components/agents/tool-cards/write-tool-card.tsx b/apps/mobile/src/components/agents/tool-cards/write-tool-card.tsx index cc2021e026..a260c68019 100644 --- a/apps/mobile/src/components/agents/tool-cards/write-tool-card.tsx +++ b/apps/mobile/src/components/agents/tool-cards/write-tool-card.tsx @@ -1,6 +1,7 @@ import { View } from 'react-native'; import { FilePlus } from '@/components/ui/icons'; import { type ToolPart } from '@kilocode/cloud-agent-sdk'; +import { z } from 'zod'; import { SelectableText } from '@/components/ui/selectable-text'; import { Text } from '@/components/ui/text'; @@ -15,6 +16,8 @@ import { getToolDisplay, toolPartHasDetails } from '../tool-card-display'; const WRITE_CODE_CHARACTER_CAP = 50_000; +const optionalStringSchema = z.string().optional(); + /** * Sheet body for a write tool part: markdown or highlighted code from * `input.content`, plus the error. Diff preview is gone. Renders only inside @@ -23,8 +26,8 @@ const WRITE_CODE_CHARACTER_CAP = 50_000; */ export function WriteToolCardBody({ part }: Readonly<{ part: ToolPart }>) { const input = part.state.input; - const filePath = typeof input.filePath === 'string' ? input.filePath : ''; - const content = typeof input.content === 'string' ? input.content : ''; + const filePath = optionalStringSchema.safeParse(input.filePath).data ?? ''; + const content = optionalStringSchema.safeParse(input.content).data ?? ''; const error = part.state.status === 'error' ? part.state.error : undefined; const isFinal = part.state.status === 'completed' || part.state.status === 'error'; diff --git a/apps/mobile/src/components/agents/tool-diff-model.ts b/apps/mobile/src/components/agents/tool-diff-model.ts index 0939765899..be0cfbfce5 100644 --- a/apps/mobile/src/components/agents/tool-diff-model.ts +++ b/apps/mobile/src/components/agents/tool-diff-model.ts @@ -12,9 +12,25 @@ // tool inputs are not unified patches. import { type ToolPart } from '@kilocode/cloud-agent-sdk'; +import { z } from 'zod'; import { type ParsedDiffLine } from '@/lib/pr-review/diff/parse-patch'; import { languageForPath } from '@/lib/pr-review/diff/highlight'; +/** Zod's validation `.catch()` fallback, not a Promise catch. */ +function tolerant(schema: z.ZodType, fallback: T): z.ZodType { + // oxlint-disable-next-line promise/prefer-await-to-then -- zod schema fallback, not a Promise + return schema.catch(fallback); +} + +const editToolInputSchema = tolerant( + z.object({ + filePath: tolerant(z.string(), ''), + oldString: tolerant(z.string(), ''), + newString: tolerant(z.string(), ''), + }), + { filePath: '', oldString: '', newString: '' } +); + // Sized for the scrolling detail sheet: an order of magnitude above the old // transcript-preview caps (1000/2000) because the sheet scrolls and only one // body renders at a time. @@ -40,9 +56,7 @@ export function buildToolDiffModel(part: ToolPart): ToolDiffModel | null { const input = part.state.input as Record; if (tool === 'edit') { - const filePath = typeof input.filePath === 'string' ? input.filePath : ''; - const oldString = typeof input.oldString === 'string' ? input.oldString : ''; - const newString = typeof input.newString === 'string' ? input.newString : ''; + const { filePath, oldString, newString } = editToolInputSchema.parse(input); const slicedOld = oldString.slice(0, EDIT_CHARACTER_CAP); const slicedNew = newString.slice(0, EDIT_CHARACTER_CAP); diff --git a/apps/mobile/src/components/agents/tool-list-model.ts b/apps/mobile/src/components/agents/tool-list-model.ts index d877fb32a2..43e640e412 100644 --- a/apps/mobile/src/components/agents/tool-list-model.ts +++ b/apps/mobile/src/components/agents/tool-list-model.ts @@ -19,6 +19,7 @@ // per-render computations, so the tool-card bodies call them without a hook. import { type ToolPart } from '@kilocode/cloud-agent-sdk'; +import { z } from 'zod'; /** Rows kept per result output. Longer outputs flag `truncated`. */ export const RESULT_ROW_CAP = 500; @@ -123,19 +124,12 @@ export type TodoListModel = { truncated: boolean; }; -const TODO_STATUS: Record = { - pending: 'pending', - in_progress: 'in_progress', - completed: 'completed', - cancelled: 'cancelled', -}; +const todoStatusSchema = z.enum(['pending', 'in_progress', 'completed', 'cancelled']); +const todoItemSchema = z.object({ content: z.string(), status: z.unknown().optional() }); function mapTodoStatus(status: unknown): TodoTask['status'] { - return typeof status === 'string' ? (TODO_STATUS[status] ?? 'pending') : 'pending'; -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null; + const result = todoStatusSchema.safeParse(status); + return result.success ? result.data : 'pending'; } /** @@ -154,18 +148,19 @@ export function buildTodoListModel(part: ToolPart): TodoListModel | null { let truncated = false; const tasks: TodoTask[] = []; for (const item of todos) { - if (isRecord(item) && typeof item.content === 'string' && item.content.trim().length > 0) { + const parsedItem = todoItemSchema.safeParse(item); + if (parsedItem.success && parsedItem.data.content.trim().length > 0) { if (tasks.length >= TODO_TASK_CAP) { truncated = true; break; } - let content = item.content; + let content = parsedItem.data.content; if (content.length > TODO_CONTENT_CHARACTER_CAP) { content = content.slice(0, TODO_CONTENT_CHARACTER_CAP); truncated = true; } - tasks.push({ content, status: mapTodoStatus(item.status) }); + tasks.push({ content, status: mapTodoStatus(parsedItem.data.status) }); } } diff --git a/apps/mobile/src/components/agents/tool-patch-model.ts b/apps/mobile/src/components/agents/tool-patch-model.ts index b5dac98e8c..32a622fd31 100644 --- a/apps/mobile/src/components/agents/tool-patch-model.ts +++ b/apps/mobile/src/components/agents/tool-patch-model.ts @@ -43,6 +43,7 @@ // uses `listPatchFilePaths`, a cheap header scan, instead of this parser. import { type ToolPart } from '@kilocode/cloud-agent-sdk'; +import { z } from 'zod'; import { type ParsedDiffLine } from '@/lib/pr-review/diff/parse-patch'; import { languageForPath } from '@/lib/pr-review/diff/highlight'; @@ -53,7 +54,10 @@ const PATCH_TOTAL_LINE_CAP = 2000; const PATCH_FILE_HEADER_RE = /^\*\*\* (?:Add|Update|Delete) File: (.+)$/gm; +const optionalStringSchema = z.string().optional(); + /** First-character → row type for a chunk body line. */ +// oxlint-disable-next-line anti-slop/no-known-value-widening -- looked up by an arbitrary character from patch text, not a closed key set const CHUNK_LINE_TYPE: Record = { ' ': 'context', '-': 'del', @@ -121,8 +125,7 @@ export function buildToolPatchModel(part: ToolPart): ToolPatchModel | null { if (part.tool !== 'patch' && part.tool !== 'apply_patch') { return null; } - const patchText = - typeof part.state.input.patchText === 'string' ? part.state.input.patchText : ''; + const patchText = optionalStringSchema.safeParse(part.state.input.patchText).data ?? ''; if (!patchText) { return null; } diff --git a/apps/mobile/src/components/agents/tool-patch-preview.tsx b/apps/mobile/src/components/agents/tool-patch-preview.tsx index 810d0608d5..e52a4ae462 100644 --- a/apps/mobile/src/components/agents/tool-patch-preview.tsx +++ b/apps/mobile/src/components/agents/tool-patch-preview.tsx @@ -18,11 +18,11 @@ import { DiffLine } from '@/components/pr-review/diff/diff-line'; import { type ToolPatchFile, type ToolPatchModel } from './tool-patch-model'; -const OPERATION_LABEL: Record = { +const OPERATION_LABEL = { add: 'Added', delete: 'Deleted', update: 'Updated', -}; +} satisfies Record; type ToolPatchPreviewProps = { model: ToolPatchModel; diff --git a/apps/mobile/src/components/agents/use-continue-session.ts b/apps/mobile/src/components/agents/use-continue-session.ts index 87e80bed61..4e3a0a98a6 100644 --- a/apps/mobile/src/components/agents/use-continue-session.ts +++ b/apps/mobile/src/components/agents/use-continue-session.ts @@ -44,15 +44,7 @@ export function useContinueSession(args: { manager: ReturnType; models: SessionModelOption[]; modelsLoading: boolean; -}): { - continueSession: (input: { - gitUrl: string | null | undefined; - mode: string; - model: string; - variant: string; - }) => Promise; - isContinuing: boolean; -} { +}) { const router = useRouter(); const queryClient = useQueryClient(); const trpc = useTRPC(); diff --git a/apps/mobile/src/components/agents/use-history-backfill.ts b/apps/mobile/src/components/agents/use-history-backfill.ts index 08e415f338..2b38a4adff 100644 --- a/apps/mobile/src/components/agents/use-history-backfill.ts +++ b/apps/mobile/src/components/agents/use-history-backfill.ts @@ -15,6 +15,7 @@ type UseHistoryBackfillParams = { * intentionally discarded (fire-and-forget), and keeping this a prop lets * the mounted test drive it with a mock. */ + // oxlint-disable-next-line anti-slop/no-unknown-returns -- fire-and-forget callback; real callers pass a union of differently-shaped fetchNextPage functions (search vs. stored query), so a generic type param collapses to `void` at the call site instead of unifying. fetchNextPage: () => Promise; }; diff --git a/apps/mobile/src/components/agents/use-new-session-creator.ts b/apps/mobile/src/components/agents/use-new-session-creator.ts index 84ef1d747a..a5a6aacc66 100644 --- a/apps/mobile/src/components/agents/use-new-session-creator.ts +++ b/apps/mobile/src/components/agents/use-new-session-creator.ts @@ -35,6 +35,20 @@ type UseNewSessionCreatorInput = { profileId?: string | null; }; +type PrepareSessionInput = { + prompt: string; + initialMessageId: string; + mode: AgentMode; + model: string; + variant: string | undefined; + githubRepo: string; + autoCommit: boolean; + autoInitiate: boolean; + operationKey: string; + profileId?: string; + attachments?: AgentAttachmentWire; +}; + type UseNewSessionCreatorResult = { createSessionFromDraft: () => Promise; promptRef: RefObject; @@ -123,19 +137,7 @@ export function useNewSessionCreator({ try { const initialMessageId = generateMessageId(); - const baseInput: { - prompt: string; - initialMessageId: string; - mode: AgentMode; - model: string; - variant: string | undefined; - githubRepo: string; - autoCommit: boolean; - autoInitiate: boolean; - operationKey: string; - profileId?: string; - attachments?: AgentAttachmentWire; - } = { + const baseInput: PrepareSessionInput = { prompt, initialMessageId, mode, diff --git a/apps/mobile/src/components/agents/use-new-session-prefill.ts b/apps/mobile/src/components/agents/use-new-session-prefill.ts index c5d2a07cf9..c6814c704e 100644 --- a/apps/mobile/src/components/agents/use-new-session-prefill.ts +++ b/apps/mobile/src/components/agents/use-new-session-prefill.ts @@ -50,10 +50,7 @@ export type UseNewSessionPrefillTargetsInput = { * pattern in `agent-chat/new.tsx` — a same-component render-phase update * guarded by a ref. */ -export function useNewSessionPrefillTargets(input: UseNewSessionPrefillTargetsInput): { - selectedRepo: string; - setSelectedRepo: (value: string) => void; -} { +export function useNewSessionPrefillTargets(input: UseNewSessionPrefillTargetsInput) { const { repositories, reposSettled, models, modelsSettled } = input; const prefill = useNewSessionPrefill(); const [selectedRepo, setSelectedRepo] = useState(''); diff --git a/apps/mobile/src/components/agents/use-session-auto-scroll-state.ts b/apps/mobile/src/components/agents/use-session-auto-scroll-state.ts index 04c2de4678..df56516614 100644 --- a/apps/mobile/src/components/agents/use-session-auto-scroll-state.ts +++ b/apps/mobile/src/components/agents/use-session-auto-scroll-state.ts @@ -25,10 +25,7 @@ export function isSessionListAtBottom({ * to the latest message, so the floating "scroll to bottom" button * must never be visible until the user has actually scrolled away. */ -export function getInitialSessionListAutoScrollVisibility(): { - shouldAutoScroll: boolean; - isAtBottom: boolean; -} { +export function getInitialSessionListAutoScrollVisibility() { return { shouldAutoScroll: true, isAtBottom: true }; } diff --git a/apps/mobile/src/components/agents/use-session-config-sync.ts b/apps/mobile/src/components/agents/use-session-config-sync.ts index 39c1bd9b49..cd197fc816 100644 --- a/apps/mobile/src/components/agents/use-session-config-sync.ts +++ b/apps/mobile/src/components/agents/use-session-config-sync.ts @@ -52,7 +52,7 @@ export function resolveSessionConfigSelection({ selectedModel, selectedVariant, cloudAgentModelOverride = null, -}: ResolveSessionConfigSelectionOptions): { model: string; variant: string } { +}: ResolveSessionConfigSelectionOptions) { if (activeSessionType === 'remote') { return { model: selectedModel, variant: selectedVariant }; } diff --git a/apps/mobile/src/components/code-reviewer/manual-review-screen.tsx b/apps/mobile/src/components/code-reviewer/manual-review-screen.tsx index 125ef2ba2b..f7e1d496b7 100644 --- a/apps/mobile/src/components/code-reviewer/manual-review-screen.tsx +++ b/apps/mobile/src/components/code-reviewer/manual-review-screen.tsx @@ -31,10 +31,10 @@ import { cn } from '@/lib/utils'; const MANUAL_REVIEW_PLATFORMS = ['github', 'gitlab'] as const; type ManualReviewPlatform = (typeof MANUAL_REVIEW_PLATFORMS)[number]; -const URL_PLACEHOLDER: Record = { +const URL_PLACEHOLDER = { github: 'https://github.com/owner/repo/pull/123', gitlab: 'https://gitlab.com/group/project/-/merge_requests/123', -}; +} satisfies Record; // The shared suffix check (matchesCodeReviewUrlSuffix, ported from web's // code-review-links.ts) only looks at the end of the URL — it isn't @@ -46,10 +46,10 @@ const URL_PLACEHOLDER: Record = { // it — otherwise structure-free URLs like https://github.com/pull/123 would // pass. GitLab nests groups arbitrarily deep, so only the protocol is // anchored there; the shared suffix requires the /-/merge_requests/ tail. -const URL_HOST_PATTERN: Record = { +const URL_HOST_PATTERN = { github: /^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\//, gitlab: /^https:\/\//, -}; +} satisfies Record; function isValidManualReviewUrl(platform: ManualReviewPlatform, url: string): boolean { return URL_HOST_PATTERN[platform].test(url) && matchesCodeReviewUrlSuffix(platform, url); diff --git a/apps/mobile/src/components/code-reviewer/option-list.tsx b/apps/mobile/src/components/code-reviewer/option-list.tsx index 8c41755a42..149cf17fd4 100644 --- a/apps/mobile/src/components/code-reviewer/option-list.tsx +++ b/apps/mobile/src/components/code-reviewer/option-list.tsx @@ -8,13 +8,13 @@ import { ChoiceRow } from '@/components/ui/choice-row'; import { RadioGroup } from '@/components/ui/radio-group'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; -type OptionListProps = { +type OptionListProps = { title: string; options: readonly T[]; selected: T | undefined; /** Must resolve/reject once the save actually completes — the screen * navigates back only on confirmed success. */ - onSelect: (value: T) => Promise; + onSelect: (value: T) => Promise; /** Optional per-option caption below the label. */ descriptions?: Readonly>; /** Disables every row, e.g. while the config backing `selected` is still loading. */ @@ -22,14 +22,14 @@ type OptionListProps = { }; /** Full-screen single-select list. Selecting saves, then pops the screen only once the save confirms. */ -export function OptionList({ +export function OptionList({ title, options, selected, onSelect, descriptions, disabled, -}: Readonly>) { +}: Readonly>) { const router = useRouter(); const colors = useThemeColors(); const [pending, setPending] = useState(null); diff --git a/apps/mobile/src/components/code-reviewer/platform-list-screen.tsx b/apps/mobile/src/components/code-reviewer/platform-list-screen.tsx index 71fe4e3793..b1025a7742 100644 --- a/apps/mobile/src/components/code-reviewer/platform-list-screen.tsx +++ b/apps/mobile/src/components/code-reviewer/platform-list-screen.tsx @@ -16,11 +16,11 @@ import { } from '@/lib/hooks/use-code-reviewer'; import { useTRPC } from '@/lib/trpc'; -const PLATFORM_ICONS: Record = { +const PLATFORM_ICONS = { github: GitBranch, gitlab: GitMerge, bitbucket: GitPullRequest, -}; +} satisfies Record; const ALL_PLATFORMS = ['github', 'gitlab', 'bitbucket'] as const; diff --git a/apps/mobile/src/components/code-reviewer/provider-connect-card.tsx b/apps/mobile/src/components/code-reviewer/provider-connect-card.tsx index b4617cf824..1bc11e7d89 100644 --- a/apps/mobile/src/components/code-reviewer/provider-connect-card.tsx +++ b/apps/mobile/src/components/code-reviewer/provider-connect-card.tsx @@ -30,14 +30,14 @@ const PLATFORM_CONFIG = { }, } as const; -export function ProviderConnectCard({ +export function ProviderConnectCard({ scope, platform, onConnected, }: Readonly<{ scope: string; platform: 'github' | 'gitlab'; - onConnected: () => Promise; + onConnected: () => Promise; }>) { const colors = useThemeColors(); const [connecting, setConnecting] = useState(false); diff --git a/apps/mobile/src/components/code-reviewer/review-detail-sections.tsx b/apps/mobile/src/components/code-reviewer/review-detail-sections.tsx index 9ce22c8479..31dafd4aeb 100644 --- a/apps/mobile/src/components/code-reviewer/review-detail-sections.tsx +++ b/apps/mobile/src/components/code-reviewer/review-detail-sections.tsx @@ -15,12 +15,12 @@ type Review = Extract['review']; type CouncilResult = NonNullable; type CouncilFinding = CouncilResult['specialists'][number]['findings'][number]; -const SEVERITY_CLASS: Record = { +const SEVERITY_CLASS = { critical: 'text-destructive', warning: 'text-warn', suggestion: 'text-info', nitpick: 'text-muted-foreground', -}; +} satisfies Record; export function MetaRow({ label, @@ -44,7 +44,9 @@ export function FindingCard({ finding }: Readonly<{ finding: CouncilFinding }>) {finding.severity} diff --git a/apps/mobile/src/components/code-reviewer/review-list-screen.tsx b/apps/mobile/src/components/code-reviewer/review-list-screen.tsx index 259e6fa4ec..e117df3157 100644 --- a/apps/mobile/src/components/code-reviewer/review-list-screen.tsx +++ b/apps/mobile/src/components/code-reviewer/review-list-screen.tsx @@ -21,7 +21,7 @@ import { cn, parseTimestamp, timeAgo } from '@/lib/utils'; // Tone classes stay mobile-local; labels come from the shared // CODE_REVIEW_STATUS_LABELS map so they can't drift from web's copy. -const STATUS_CLASSNAME: Record = { +const STATUS_CLASSNAME = { pending: 'text-muted-foreground', queued: 'text-muted-foreground', running: 'text-info', @@ -29,12 +29,12 @@ const STATUS_CLASSNAME: Record = { failed: 'text-destructive', cancelled: 'text-muted-foreground', interrupted: 'text-warn', -}; +} satisfies Record; type ReviewListData = NonNullable['data']>; type Review = Extract['reviews'][number]; -export function statusMeta(status: string): { label: string; className: string } { +export function statusMeta(status: string) { if (!isCodeReviewStatus(status)) { return { label: status, className: 'text-muted-foreground' }; } diff --git a/apps/mobile/src/components/empty-state.tsx b/apps/mobile/src/components/empty-state.tsx index b64b96d302..30b698fb38 100644 --- a/apps/mobile/src/components/empty-state.tsx +++ b/apps/mobile/src/components/empty-state.tsx @@ -53,13 +53,16 @@ export function EmptyState({ {title} - {typeof description === 'string' ? ( - - {description} - - ) : ( - description - )} + { + // oxlint-disable-next-line anti-slop/no-runtime-typeof -- ReactNode has no non-typeof way to detect its plain-string variant + typeof description === 'string' ? ( + + {description} + + ) : ( + description + ) + } {action} diff --git a/apps/mobile/src/components/icons/index.ts b/apps/mobile/src/components/icons/index.ts index b5ea557511..e6baaa1de5 100644 --- a/apps/mobile/src/components/icons/index.ts +++ b/apps/mobile/src/components/icons/index.ts @@ -11,6 +11,7 @@ export { GmailIcon } from './gmail-icon'; export { GoogleIcon } from './google-icon'; /** Maps catalog entry IDs to brand icon components. */ +// oxlint-disable-next-line anti-slop/no-known-value-widening -- callers elsewhere index this by a plain runtime string export const CATALOG_ICONS: Partial> = { telegram: TelegramIcon, discord: DiscordIcon, diff --git a/apps/mobile/src/components/kilo-chat/conversation-list-groups.ts b/apps/mobile/src/components/kilo-chat/conversation-list-groups.ts index 100c16977d..c282a9675d 100644 --- a/apps/mobile/src/components/kilo-chat/conversation-list-groups.ts +++ b/apps/mobile/src/components/kilo-chat/conversation-list-groups.ts @@ -27,12 +27,12 @@ export function groupConversationsByActivity( const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); const yesterdayStart = todayStart - DAY_MS; const weekStart = todayStart - 6 * DAY_MS; - const groups: Record = { - Today: [], - Yesterday: [], - 'This Week': [], - Older: [], - }; + const groups = { + Today: [] as ConversationListItem[], + Yesterday: [] as ConversationListItem[], + 'This Week': [] as ConversationListItem[], + Older: [] as ConversationListItem[], + } satisfies Record; for (const conversation of conversations) { const timestamp = conversationTimestamp(conversation); diff --git a/apps/mobile/src/components/kilo-chat/hooks/mark-read-operation.ts b/apps/mobile/src/components/kilo-chat/hooks/mark-read-operation.ts index 562efb680b..8467bd3369 100644 --- a/apps/mobile/src/components/kilo-chat/hooks/mark-read-operation.ts +++ b/apps/mobile/src/components/kilo-chat/hooks/mark-read-operation.ts @@ -22,7 +22,7 @@ export async function markReadConversation({ return result; } -type ApplyBadgeClearResultInput = { +type ApplyBadgeClearResultInput = { badgeClear: MarkConversationReadResponse['badgeClear']; startBadgeFreshnessEpoch: number; currentBadgeFreshnessEpoch: number; @@ -31,7 +31,7 @@ type ApplyBadgeClearResultInput = { queryKey: readonly ['badges', string], updater: (badges: BadgeCountRow[] | undefined) => BadgeCountRow[] | undefined ) => void; - setBadgeCount: (badgeCount: number) => Promise; + setBadgeCount: (badgeCount: number) => Promise; }; export function filterClearedBadgeBucket( @@ -45,14 +45,14 @@ export function filterClearedBadgeBucket( return badges?.filter(row => row.badgeBucket !== badgeClear.badgeBucket); } -export function applyBadgeClearResult({ +export function applyBadgeClearResult({ badgeClear, startBadgeFreshnessEpoch, currentBadgeFreshnessEpoch, userId, updateBadgeRows, setBadgeCount, -}: ApplyBadgeClearResultInput): boolean { +}: ApplyBadgeClearResultInput): boolean { if (badgeClear === null) { return false; } diff --git a/apps/mobile/src/components/kilo-chat/message-actions.ts b/apps/mobile/src/components/kilo-chat/message-actions.ts index c90afabb8f..ba80e68089 100644 --- a/apps/mobile/src/components/kilo-chat/message-actions.ts +++ b/apps/mobile/src/components/kilo-chat/message-actions.ts @@ -30,12 +30,7 @@ export function buildMessageActionSheetOptions({ canDelete, canRetry = false, isPendingMessage = false, -}: BuildMessageActionSheetOptionsInput): { - actions: MessageAction[]; - options: string[]; - cancelButtonIndex: number; - destructiveButtonIndex?: number; -} { +}: BuildMessageActionSheetOptionsInput) { const actions: MessageAction[] = []; const canUseApiBackedActions = !isPendingMessage; if (canRetry) { diff --git a/apps/mobile/src/components/kiloclaw/access-required-screen.tsx b/apps/mobile/src/components/kiloclaw/access-required-screen.tsx index a1457192ac..a77cb8630d 100644 --- a/apps/mobile/src/components/kiloclaw/access-required-screen.tsx +++ b/apps/mobile/src/components/kiloclaw/access-required-screen.tsx @@ -35,7 +35,7 @@ type SubcaseContent = { tone: ToneKey; }; -const SUBCASE_CONTENT: Record = { +const SUBCASE_CONTENT = { trial_expired: { body: "To keep using KiloClaw, go to kilo.ai/claw from your browser. You can't subscribe in the app.", ctaLabel: 'Open kilo.ai/claw', @@ -84,7 +84,7 @@ const SUBCASE_CONTENT: Record = { title: 'Legacy plan detected', tone: 'warn', }, -}; +} satisfies Record; type AccessRequiredScreenProps = { subcase: AccessRequiredSubcase; diff --git a/apps/mobile/src/components/kiloclaw/billing-banner.tsx b/apps/mobile/src/components/kiloclaw/billing-banner.tsx index 0d16c6337f..f08a121ff1 100644 --- a/apps/mobile/src/components/kiloclaw/billing-banner.tsx +++ b/apps/mobile/src/components/kiloclaw/billing-banner.tsx @@ -15,11 +15,11 @@ type Severity = 'info' | 'warn' | 'danger'; // 'info' has no dedicated tone token yet — map to warn (amber). If a // true neutral-info tone is needed later, add a token pair in global.css. -const SEVERITY_TO_TONE: Record = { +const SEVERITY_TO_TONE = { info: 'warn', warn: 'warn', danger: 'danger', -}; +} satisfies Record; type BannerConfig = { icon: LucideIcon; diff --git a/apps/mobile/src/components/kiloclaw/changelog-list.tsx b/apps/mobile/src/components/kiloclaw/changelog-list.tsx index e87966fa19..ac8eff59be 100644 --- a/apps/mobile/src/components/kiloclaw/changelog-list.tsx +++ b/apps/mobile/src/components/kiloclaw/changelog-list.tsx @@ -10,7 +10,7 @@ import { cn } from '@/lib/utils'; type ChangelogEntry = NonNullable['data']>[number]; -const DEPLOY_HINTS: Record = { +const DEPLOY_HINTS = { redeploy_suggested: { label: 'Redeploy suggested', bgClass: 'bg-info-tile-bg', @@ -26,7 +26,7 @@ const DEPLOY_HINTS: Record; export function ChangelogList({ entries, diff --git a/apps/mobile/src/components/kiloclaw/instance-list-screen.tsx b/apps/mobile/src/components/kiloclaw/instance-list-screen.tsx index 173a39e3a4..3d71dd6bfc 100644 --- a/apps/mobile/src/components/kiloclaw/instance-list-screen.tsx +++ b/apps/mobile/src/components/kiloclaw/instance-list-screen.tsx @@ -33,14 +33,14 @@ type Props = { // Compact labels for the per-card banner. Kept separate from // access-required-screen's fuller copy, which is for the dedicated // full-screen surface, not a list row. -const ACCESS_ISSUE_LABELS: Record = { +const ACCESS_ISSUE_LABELS = { trial_expired: 'Trial ended, subscribe to keep using this instance', subscription_canceled: 'Subscription inactive, resubscribe to keep using this instance', subscription_past_due: 'Payment issue, update billing to keep using this instance', quarantined: 'Instance quarantined, needs manual review', multiple_current_conflict: 'Account needs review', non_canonical_earlybird: 'Legacy plan needs review', -}; +} satisfies Record; function splitInstances(instances: ClawInstance[]) { return { diff --git a/apps/mobile/src/components/kiloclaw/onboarding-flow.tsx b/apps/mobile/src/components/kiloclaw/onboarding-flow.tsx index 2fe8db3a19..b76db3eb85 100644 --- a/apps/mobile/src/components/kiloclaw/onboarding-flow.tsx +++ b/apps/mobile/src/components/kiloclaw/onboarding-flow.tsx @@ -16,6 +16,7 @@ import { X } from '@/components/ui/icons'; import { type ReactNode, useCallback, useEffect, useReducer } from 'react'; import { ActivityIndicator, Pressable, View } from 'react-native'; import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated'; +import { z } from 'zod'; import { AccessRequiredScreen } from '@/components/kiloclaw/access-required-screen'; import { resolveAccessRequiredSubcase } from '@/components/kiloclaw/empty-state-content'; @@ -73,6 +74,10 @@ function resolveHeaderTitle( return botName ?? ''; } +// `gatewayReady` returns an untyped platform response (Record); +// `status` is only meaningful when it is a number (an HTTP status code). +const gatewayReadyStatusSchema = z.number().optional(); + function resolveUserTimezone(): string | undefined { try { return new Intl.DateTimeFormat().resolvedOptions().timeZone || undefined; @@ -111,7 +116,7 @@ export function OnboardingFlow() { // may already exist, in which case we should redirect rather than block. const hasAccessWithInstance = (data.state === 'has_access' || data.state === 'pending_settlement') && - typeof data.instanceId === 'string'; + data.instanceId !== null; dispatch({ type: 'onboarding-state-loaded', eligible, @@ -140,7 +145,7 @@ export function OnboardingFlow() { type: 'gateway-readiness-changed', ready: gatewayReadyData.ready === true, settled: gatewayReadyData.settled === true, - status: typeof gatewayReadyData.status === 'number' ? gatewayReadyData.status : null, + status: gatewayReadyStatusSchema.safeParse(gatewayReadyData.status).data ?? null, nowMs: Date.now(), }); }, [gatewayReadyData]); diff --git a/apps/mobile/src/components/kiloclaw/onboarding/provisioning-step.tsx b/apps/mobile/src/components/kiloclaw/onboarding/provisioning-step.tsx index 980c6392d3..826161215c 100644 --- a/apps/mobile/src/components/kiloclaw/onboarding/provisioning-step.tsx +++ b/apps/mobile/src/components/kiloclaw/onboarding/provisioning-step.tsx @@ -45,10 +45,7 @@ const OVERALL_TIMEOUT_MS = 150_000; const PULSE_PEAK = 1.06; const PULSE_DURATION_MS = 1400; -const TERMINAL_CONTENT: Record< - ProvisioningTerminalReason, - { title: string; body: (name: string) => string } -> = { +const TERMINAL_CONTENT = { query_error: { title: "Couldn't check on setup", body: name => @@ -68,7 +65,7 @@ const TERMINAL_CONTENT: Record< body: name => `Setup for ${name} is taking longer than usual (over ${OVERALL_TIMEOUT_MS / 60_000} minutes).`, }, -}; +} satisfies Record string }>; function provisioningStageMessage(state: OnboardingState): string { if (state.instanceStatus === 'running' && state.gatewayReady && !state.gatewaySettled) { diff --git a/apps/mobile/src/components/kiloclaw/status-badge.tsx b/apps/mobile/src/components/kiloclaw/status-badge.tsx index b80659c923..5f3df15613 100644 --- a/apps/mobile/src/components/kiloclaw/status-badge.tsx +++ b/apps/mobile/src/components/kiloclaw/status-badge.tsx @@ -7,6 +7,7 @@ import { cn } from '@/lib/utils'; type StatusValue = InstanceStatus | GatewayState | null | undefined; +// oxlint-disable-next-line anti-slop/no-known-value-widening -- statusTone() must look up an arbitrary backend status string, not just the known keys const STATUS_TONES: Record = { running: 'good', stopped: 'muted', @@ -19,6 +20,7 @@ const STATUS_TONES: Record = { shutting_down: 'warn', }; +// oxlint-disable-next-line anti-slop/no-known-value-widening -- statusLabel() must look up an arbitrary backend status string, not just the known keys const STATUS_LABELS: Record = { running: 'RUNNING', stopped: 'STOPPED', diff --git a/apps/mobile/src/components/notifications-screen.tsx b/apps/mobile/src/components/notifications-screen.tsx index 12767e91b1..f202fb6c56 100644 --- a/apps/mobile/src/components/notifications-screen.tsx +++ b/apps/mobile/src/components/notifications-screen.tsx @@ -330,7 +330,7 @@ export function NotificationsScreen() { return undefined; } const next = variables[category]; - if (typeof next !== 'boolean') { + if (next === undefined) { return undefined; } return applyAgentPushOptimistic({ diff --git a/apps/mobile/src/components/organization/credit-activity-screen.tsx b/apps/mobile/src/components/organization/credit-activity-screen.tsx index e8c83beb19..dc903c6233 100644 --- a/apps/mobile/src/components/organization/credit-activity-screen.tsx +++ b/apps/mobile/src/components/organization/credit-activity-screen.tsx @@ -70,10 +70,10 @@ function CreditRow({ transaction }: Readonly<{ transaction: CreditTransaction }> } function firstSearchParam(value: string | string[] | undefined): string | undefined { - if (typeof value === 'string' && value.length > 0) { + if (!Array.isArray(value) && value !== undefined && value.length > 0) { return value; } - if (Array.isArray(value) && typeof value[0] === 'string' && value[0].length > 0) { + if (Array.isArray(value) && value[0] !== undefined && value[0].length > 0) { return value[0]; } return undefined; diff --git a/apps/mobile/src/components/organization/invoices-screen.tsx b/apps/mobile/src/components/organization/invoices-screen.tsx index 0a42302df0..9506afb313 100644 --- a/apps/mobile/src/components/organization/invoices-screen.tsx +++ b/apps/mobile/src/components/organization/invoices-screen.tsx @@ -25,15 +25,20 @@ import { } from '@/lib/organization-invoice-download'; import { cn, firstNonEmpty, formatDate } from '@/lib/utils'; -const STATUS_META: Record = { +const STATUS_META = { paid: { label: 'Paid', pillClass: 'bg-good', textClass: 'text-good-foreground' }, open: { label: 'Open', pillClass: 'bg-warn', textClass: 'text-warn-foreground' }, void: { label: 'Void', pillClass: 'bg-muted', textClass: 'text-muted-foreground' }, -}; +} satisfies Record; + +/** Looks up a possibly-unknown key in a literal dictionary without widening its type. */ +function lookup(dictionary: Readonly>, key: string): V | undefined { + return (dictionary as Readonly>)[key]; +} function statusMeta(status: string): { label: string; pillClass: string; textClass: string } { return ( - STATUS_META[status] ?? { + lookup(STATUS_META, status) ?? { label: status.charAt(0).toUpperCase() + status.slice(1), pillClass: 'bg-muted', textClass: 'text-muted-foreground', diff --git a/apps/mobile/src/components/organization/member-row.tsx b/apps/mobile/src/components/organization/member-row.tsx index 49bfe5ffb0..da4ed29a75 100644 --- a/apps/mobile/src/components/organization/member-row.tsx +++ b/apps/mobile/src/components/organization/member-row.tsx @@ -20,12 +20,12 @@ type MemberRowProps = { last?: boolean; }; -export const ROLE_LABEL: Record = { +export const ROLE_LABEL = { owner: 'Owner', admin: 'Admin', member: 'Member', billing_manager: 'Billing manager', -}; +} satisfies Record; const ROLE_OPTIONS: OrgRole[] = ['owner', 'admin', 'member', 'billing_manager']; diff --git a/apps/mobile/src/components/organization/org-kilo-pass-row-state.ts b/apps/mobile/src/components/organization/org-kilo-pass-row-state.ts index 0dc0429995..bbe235c0e4 100644 --- a/apps/mobile/src/components/organization/org-kilo-pass-row-state.ts +++ b/apps/mobile/src/components/organization/org-kilo-pass-row-state.ts @@ -56,11 +56,11 @@ export type OrgKiloPassRowState = { loading: boolean; }; -const TIER_LABELS: Record<'tier_19' | 'tier_49' | 'tier_199', string> = { +const TIER_LABELS = { tier_19: '$19', tier_49: '$49', tier_199: '$199', -}; +} satisfies Record<'tier_19' | 'tier_49' | 'tier_199', string>; function paidSeatsLabel(count: number): string { return `${count} paid ${count === 1 ? 'seat' : 'seats'}`; diff --git a/apps/mobile/src/components/pr-review/diff/diff-font-metrics.ts b/apps/mobile/src/components/pr-review/diff/diff-font-metrics.ts index 4f724c0474..1a45a234b7 100644 --- a/apps/mobile/src/components/pr-review/diff/diff-font-metrics.ts +++ b/apps/mobile/src/components/pr-review/diff/diff-font-metrics.ts @@ -75,7 +75,7 @@ export function computeBoundedDiffFontMetrics( // Treat invalid / negative / non-finite input as 1.0 (no scaling). // `useWindowDimensions().fontScale` can be 1.0 on platforms that report // no accessibility preference; we should never produce sub-1 sizes. - const raw = typeof fontScale === 'number' && Number.isFinite(fontScale) ? fontScale : 1; + const raw = fontScale != null && Number.isFinite(fontScale) ? fontScale : 1; const scale = Math.min(Math.max(raw, 1), DIFF_MAX_FONT_SCALE); return { scale, diff --git a/apps/mobile/src/components/pr-review/diff/pr-diff-file-list-header.tsx b/apps/mobile/src/components/pr-review/diff/pr-diff-file-list-header.tsx index 217e8c0cec..2847ea75c2 100644 --- a/apps/mobile/src/components/pr-review/diff/pr-diff-file-list-header.tsx +++ b/apps/mobile/src/components/pr-review/diff/pr-diff-file-list-header.tsx @@ -166,10 +166,7 @@ function ViewModeButton({ * mode plus a setter. Default is `unified`. The toggle is local state * (not persisted) per the S6c spec. */ -export function useDiffViewMode(): { - viewMode: DiffViewMode; - setViewMode: (mode: DiffViewMode) => void; -} { +export function useDiffViewMode() { const [viewMode, setViewMode] = useState('unified'); return { viewMode, setViewMode }; } diff --git a/apps/mobile/src/components/pr-review/diff/pr-diff-file-list-render.tsx b/apps/mobile/src/components/pr-review/diff/pr-diff-file-list-render.tsx index cba7efaa30..b565be9feb 100644 --- a/apps/mobile/src/components/pr-review/diff/pr-diff-file-list-render.tsx +++ b/apps/mobile/src/components/pr-review/diff/pr-diff-file-list-render.tsx @@ -128,7 +128,7 @@ export function useDiffRenderItem({ language={item.language} keyId={item.lineKeyId} onTap={ - isSelectable && typeof lineNumber === 'number' && hunk + isSelectable && lineNumber !== undefined && hunk ? () => { onLineTap({ filePath: item.filePath, @@ -145,7 +145,7 @@ export function useDiffRenderItem({ selection !== null && selection.filePath === item.filePath && selection.side === side && - typeof lineNumber === 'number' && + lineNumber !== undefined && lineNumber >= selection.startLine && lineNumber <= selection.line } diff --git a/apps/mobile/src/components/pr-review/diff/pr-diff-navigator-file-row.tsx b/apps/mobile/src/components/pr-review/diff/pr-diff-navigator-file-row.tsx index d2e1ea2670..e58984c22c 100644 --- a/apps/mobile/src/components/pr-review/diff/pr-diff-navigator-file-row.tsx +++ b/apps/mobile/src/components/pr-review/diff/pr-diff-navigator-file-row.tsx @@ -11,7 +11,7 @@ import { Text } from '@/components/ui/text'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { type PrReviewFile } from '@/lib/pr-review/diff/pr-review-file-types'; -function splitPath(path: string): { dir: string; basename: string } { +function splitPath(path: string) { const slash = path.lastIndexOf('/'); if (slash === -1) { return { dir: '', basename: path }; diff --git a/apps/mobile/src/components/pr-review/diff/use-diff-selection.ts b/apps/mobile/src/components/pr-review/diff/use-diff-selection.ts index 14946b59da..a8fb1246c8 100644 --- a/apps/mobile/src/components/pr-review/diff/use-diff-selection.ts +++ b/apps/mobile/src/components/pr-review/diff/use-diff-selection.ts @@ -68,7 +68,7 @@ export function useDiffSelection({ const map = new Map(); for (const hunkLine of args.hunk.lines) { const key = args.side === 'LEFT' ? hunkLine.oldLine : hunkLine.newLine; - if (typeof key === 'number') { + if (key !== undefined) { map.set(key, hunkLine.text); } } diff --git a/apps/mobile/src/components/pr-review/discussion/comment-row.tsx b/apps/mobile/src/components/pr-review/discussion/comment-row.tsx index f58e06f184..7ae185761d 100644 --- a/apps/mobile/src/components/pr-review/discussion/comment-row.tsx +++ b/apps/mobile/src/components/pr-review/discussion/comment-row.tsx @@ -53,19 +53,19 @@ type ModerationFailure = | { kind: 'terminal'; message: string } | { kind: 'retryable'; message: string }; -const TERMINAL_MESSAGES: Record = { +const TERMINAL_MESSAGES = { 'report-content': "This comment can't be reported.", 'report-user': "This user can't be reported.", mute: "This user can't be muted.", block: "This user can't be blocked.", -}; +} satisfies Record; -const RETRYABLE_MESSAGES: Record = { +const RETRYABLE_MESSAGES = { 'report-content': "Couldn't report this comment. Check your connection and try again.", 'report-user': "Couldn't report this user. Check your connection and try again.", mute: "Couldn't mute this user. Check your connection and try again.", block: "Couldn't block this user. Check your connection and try again.", -}; +} satisfies Record; /** Terminal moderation failures must not be retried; everything else is retryable. */ export function moderationFailure(action: ModerationAction, error: unknown): ModerationFailure { diff --git a/apps/mobile/src/components/pr-review/discussion/discussion-thread.tsx b/apps/mobile/src/components/pr-review/discussion/discussion-thread.tsx index 9dd7244e2e..0c487d6ca4 100644 --- a/apps/mobile/src/components/pr-review/discussion/discussion-thread.tsx +++ b/apps/mobile/src/components/pr-review/discussion/discussion-thread.tsx @@ -250,24 +250,24 @@ type BadgeProps = { readonly label: string; }; -const BADGE_TONE_CLASS: Record = { +const BADGE_TONE_CLASS = { good: 'bg-secondary text-good', warn: 'bg-secondary text-warn', destructive: 'bg-secondary text-destructive', muted: 'bg-secondary text-muted-foreground', -}; +} satisfies Record; function Badge({ tone, icon: Icon, label }: Readonly) { const colors = useThemeColors(); const toneClass = BADGE_TONE_CLASS[tone]; // Native Lucide icons don't resolve NativeWind text classes, so set the // icon color explicitly per tone from the theme tokens. - const iconColor: Record = { + const iconColor = { good: colors.good, warn: colors.warn, destructive: colors.destructive, muted: colors.mutedForeground, - }; + } satisfies Record; return ( {Icon ? : null} diff --git a/apps/mobile/src/components/pr-review/merge/pr-merge-icons.tsx b/apps/mobile/src/components/pr-review/merge/pr-merge-icons.tsx index 089b142400..275b205917 100644 --- a/apps/mobile/src/components/pr-review/merge/pr-merge-icons.tsx +++ b/apps/mobile/src/components/pr-review/merge/pr-merge-icons.tsx @@ -20,7 +20,7 @@ import { type PrOverviewRepoSettings, } from '@/lib/pr-review/merge/merge-blocked-reasons'; -const BLOCKED_REASON_ICON: Record = { +const BLOCKED_REASON_ICON = { conflicts: XCircle, 'required-reviews': ShieldAlert, 'failing-checks': AlertTriangle, @@ -28,7 +28,7 @@ const BLOCKED_REASON_ICON: Record = { 'unstable-checks': AlertTriangle, draft: GitPullRequest, 'unknown-state': AlertTriangle, -}; +} satisfies Record; export function mergeBlockedReasonIcon(kind: MergeBlockedReasonId): LucideIcon { return BLOCKED_REASON_ICON[kind]; diff --git a/apps/mobile/src/components/pr-review/merge/pr-merge-sheet-parts.tsx b/apps/mobile/src/components/pr-review/merge/pr-merge-sheet-parts.tsx index c4f8101f31..f2ec085882 100644 --- a/apps/mobile/src/components/pr-review/merge/pr-merge-sheet-parts.tsx +++ b/apps/mobile/src/components/pr-review/merge/pr-merge-sheet-parts.tsx @@ -25,11 +25,11 @@ import { PrReviewReconnectNotice } from '@/components/pr-review/pr-review-reconn const NO_ENABLED_METHODS_MESSAGE = 'This repository has no enabled merge methods. Ask a repository admin to enable merge, squash, or rebase merging.'; -const SHORT_METHOD_LABELS: Record = { +const SHORT_METHOD_LABELS = { merge: 'Merge', squash: 'Squash', rebase: 'Rebase', -}; +} satisfies Record; function MethodPicker({ methodOptions, diff --git a/apps/mobile/src/components/pr-review/pr-review-checks-section.tsx b/apps/mobile/src/components/pr-review/pr-review-checks-section.tsx index b8cd7d2d4f..73174cf6ab 100644 --- a/apps/mobile/src/components/pr-review/pr-review-checks-section.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-checks-section.tsx @@ -71,23 +71,23 @@ function classifyCheckTone(status: string, conclusion: string | null): CheckTone } } -const TONE_COLOR: Record> = { +const TONE_COLOR = { success: 'good', failure: 'destructive', pending: 'mutedForeground', skipped: 'mutedForeground', neutral: 'mutedForeground', warning: 'warn', -}; +} satisfies Record>; -const TONE_ICON: Record = { +const TONE_ICON = { success: CheckCircle2, failure: XCircle, pending: Loader2, skipped: MinusCircle, neutral: Circle, warning: AlertTriangle, -}; +} satisfies Record; function CheckRow({ run }: Readonly<{ run: CheckRun }>) { const colors = useThemeColors(); diff --git a/apps/mobile/src/components/pr-review/pr-review-overview-parts.tsx b/apps/mobile/src/components/pr-review/pr-review-overview-parts.tsx index 5b0c037716..ade532faad 100644 --- a/apps/mobile/src/components/pr-review/pr-review-overview-parts.tsx +++ b/apps/mobile/src/components/pr-review/pr-review-overview-parts.tsx @@ -52,12 +52,12 @@ export function describePrState(args: { // `bg-good/10` don't work on them. The chip uses a flat muted background // and lets the foreground color carry the tone so it stays legible in // both themes without needing per-tone backgrounds. -const TONE_FG_CLASS: Record = { +const TONE_FG_CLASS = { good: 'text-good', warn: 'text-warn', destructive: 'text-destructive', muted: 'text-muted-foreground', -}; +} satisfies Record; export function PrStateChip({ descriptor }: Readonly<{ descriptor: PrStateChipDescriptor }>) { const Icon = descriptor.icon; diff --git a/apps/mobile/src/components/profile-screen.tsx b/apps/mobile/src/components/profile-screen.tsx index 083de88371..154107d563 100644 --- a/apps/mobile/src/components/profile-screen.tsx +++ b/apps/mobile/src/components/profile-screen.tsx @@ -43,7 +43,7 @@ import { import { getSecurityAgentPath } from '@/lib/security-agent'; import { useTRPC } from '@/lib/trpc'; -const PROVIDER_LABELS: Record = { +const PROVIDER_LABELS = { anaconda: 'Anaconda', apple: 'Apple', discord: 'Discord', @@ -54,10 +54,15 @@ const PROVIDER_LABELS: Record = { google: 'Google', linkedin: 'LinkedIn', workos: 'Enterprise SSO', -}; +} satisfies Record; + +/** Looks up a possibly-unknown key in a literal dictionary without widening its type. */ +function lookup(dictionary: Readonly>, key: string): V | undefined { + return (dictionary as Readonly>)[key]; +} function providerLabel(provider: string) { - return PROVIDER_LABELS[provider] ?? provider; + return lookup(PROVIDER_LABELS, provider) ?? provider; } export function ProfileScreen() { diff --git a/apps/mobile/src/components/query-error.tsx b/apps/mobile/src/components/query-error.tsx index 8be67880da..a30b4679c9 100644 --- a/apps/mobile/src/components/query-error.tsx +++ b/apps/mobile/src/components/query-error.tsx @@ -14,10 +14,7 @@ import { Text } from '@/components/ui/text'; export type QueryErrorVariant = 'neutral' | 'offline' | 'permission' | 'not-found' | 'server'; -const VARIANT_META: Record< - QueryErrorVariant, - { icon: LucideIcon; title: string; description: string } -> = { +const VARIANT_META = { neutral: { icon: AlertCircle, title: 'Something went wrong', @@ -43,7 +40,7 @@ const VARIANT_META: Record< title: 'Could not load', description: 'Something went wrong on our end. Please try again.', }, -}; +} satisfies Record; type QueryErrorProps = { variant?: QueryErrorVariant; diff --git a/apps/mobile/src/components/rename-modal.tsx b/apps/mobile/src/components/rename-modal.tsx index cbb6e105d1..3bb65f3913 100644 --- a/apps/mobile/src/components/rename-modal.tsx +++ b/apps/mobile/src/components/rename-modal.tsx @@ -9,25 +9,25 @@ import { cn } from '@/lib/utils'; const SAVE_UI_DEADLINE_MS = 15_000; -type RenameModalProps = { +type RenameModalProps = { title: string; placeholder: string; initialValue: string; - onSave: (name: string) => Promise; + onSave: (name: string) => Promise; onClose: () => void; maxLength?: number; }; // Mount this component only while the modal should be open (e.g. `{visible && }`) // so each open gets fresh state: current initialValue, a reset canSave, and a re-armed Android autofocus. -export function RenameModal({ +export function RenameModal({ title, placeholder, initialValue, onSave, onClose, maxLength = 50, -}: Readonly) { +}: Readonly>) { const colors = useThemeColors(); const nameRef = useRef(initialValue); const inputRef = useRef(null); diff --git a/apps/mobile/src/components/security-agent/dashboard-screen.tsx b/apps/mobile/src/components/security-agent/dashboard-screen.tsx index 35d2070e9e..bcb240afab 100644 --- a/apps/mobile/src/components/security-agent/dashboard-screen.tsx +++ b/apps/mobile/src/components/security-agent/dashboard-screen.tsx @@ -40,11 +40,11 @@ import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { getSecurityAgentPath } from '@/lib/security-agent'; import { cn, parseTimestamp, timeAgo } from '@/lib/utils'; -const METRIC_TONE_CLASS: Record = { +const METRIC_TONE_CLASS = { danger: 'text-destructive', warning: 'text-warn', neutral: 'text-muted-foreground', -}; +} satisfies Record; export function DashboardScreen({ scope }: Readonly<{ scope: string }>) { const router = useRouter(); diff --git a/apps/mobile/src/components/security-agent/finding-detail-screen.tsx b/apps/mobile/src/components/security-agent/finding-detail-screen.tsx index 472f2b390e..99831762ca 100644 --- a/apps/mobile/src/components/security-agent/finding-detail-screen.tsx +++ b/apps/mobile/src/components/security-agent/finding-detail-screen.tsx @@ -34,14 +34,14 @@ const TABS: { key: FindingTab; label: string }[] = [ // Server-verified security_agent_ui_interaction enum values (schemas.ts:28-31) // — one per tab, matching web's handleTabChange in FindingDetailDialog.tsx. -const TAB_INTERACTIONS: Record< - FindingTab, - 'finding_triage_viewed' | 'finding_analysis_viewed' | 'finding_remediation_viewed' -> = { +const TAB_INTERACTIONS = { details: 'finding_triage_viewed', analysis: 'finding_analysis_viewed', remediation: 'finding_remediation_viewed', -}; +} satisfies Record< + FindingTab, + 'finding_triage_viewed' | 'finding_analysis_viewed' | 'finding_remediation_viewed' +>; type FindingDetailScreenProps = { scope: string; diff --git a/apps/mobile/src/components/security-agent/finding-row.tsx b/apps/mobile/src/components/security-agent/finding-row.tsx index 72e1832f50..d694974c85 100644 --- a/apps/mobile/src/components/security-agent/finding-row.tsx +++ b/apps/mobile/src/components/security-agent/finding-row.tsx @@ -19,12 +19,17 @@ import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { getSecurityAgentPath, type SecurityFinding } from '@/lib/security-agent'; import { capitalize, cn } from '@/lib/utils'; -const SEVERITY_TEXT_CLASS: Record = { +const SEVERITY_TEXT_CLASS = { critical: 'text-destructive', high: 'text-warn', medium: 'text-muted-foreground', low: 'text-muted-foreground', -}; +} satisfies Record; + +/** Looks up a possibly-unknown key in a literal dictionary without widening its type. */ +function lookup(dictionary: Readonly>, key: string): V | undefined { + return (dictionary as Readonly>)[key]; +} // Clearest next action for this finding, mirroring the priority order in // apps/web/src/components/security-agent/SecurityFindingRow.tsx — but as a @@ -183,7 +188,7 @@ export function FindingRow({ {capitalize(finding.severity)} diff --git a/apps/mobile/src/components/security-agent/finding-tone.ts b/apps/mobile/src/components/security-agent/finding-tone.ts index 2e6e9d0ed1..8a8f065563 100644 --- a/apps/mobile/src/components/security-agent/finding-tone.ts +++ b/apps/mobile/src/components/security-agent/finding-tone.ts @@ -20,7 +20,7 @@ import { type ThemeColors } from '@/lib/hooks/use-theme-colors'; // finding-row.tsx and the finding detail panels so tone styling stays // consistent everywhere a FindingTone is rendered. -export const FINDING_ICONS: Record = { +export const FINDING_ICONS = { loader: Loader2, 'x-circle': XCircle, eye: Eye, @@ -31,24 +31,21 @@ export const FINDING_ICONS: Record = { 'check-circle': CheckCircle2, clock: Clock3, 'alert-triangle': AlertTriangle, -}; +} satisfies Record; -export const FINDING_TONE_TEXT_CLASS: Record = { +export const FINDING_TONE_TEXT_CLASS = { success: 'text-good', warning: 'text-warn', danger: 'text-destructive', neutral: 'text-muted-foreground', -}; +} satisfies Record; -export const FINDING_TONE_TO_KV_ROW_TONE: Record< - FindingTone, - 'default' | 'good' | 'warn' | 'danger' | 'muted' -> = { +export const FINDING_TONE_TO_KV_ROW_TONE = { success: 'good', warning: 'warn', danger: 'danger', neutral: 'muted', -}; +} satisfies Record; export function findingToneColor(colors: ThemeColors, tone: FindingTone): string { switch (tone) { diff --git a/apps/mobile/src/components/security-agent/security-agent-setup.tsx b/apps/mobile/src/components/security-agent/security-agent-setup.tsx index 03aa4940ec..dce4f6b8a5 100644 --- a/apps/mobile/src/components/security-agent/security-agent-setup.tsx +++ b/apps/mobile/src/components/security-agent/security-agent-setup.tsx @@ -9,22 +9,22 @@ import { Text } from '@/components/ui/text'; import { useTabBarBottomPadding } from '@/components/tab-screen'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; -type SecurityAgentSetupProps = { +type SecurityAgentSetupProps = { title: string; description: string; buttonLabel: string; url: string; /** Awaited in `finally` so permission/config/repository queries refresh after the browser closes. */ - onConnected: () => Promise; + onConnected: () => Promise; }; -export function SecurityAgentSetup({ +export function SecurityAgentSetup({ title, description, buttonLabel, url, onConnected, -}: Readonly) { +}: Readonly>) { const colors = useThemeColors(); const tabBarPadding = useTabBarBottomPadding(); const [connecting, setConnecting] = useState(false); diff --git a/apps/mobile/src/components/security-agent/sla-settings-screen.tsx b/apps/mobile/src/components/security-agent/sla-settings-screen.tsx index 4ca03e6bbb..f47f9c008b 100644 --- a/apps/mobile/src/components/security-agent/sla-settings-screen.tsx +++ b/apps/mobile/src/components/security-agent/sla-settings-screen.tsx @@ -171,12 +171,12 @@ export function SlaSettingsScreen({ scope }: Readonly<{ scope: string }>) { // hidden by the toggle, an invalid day count can't block saving. If a // field is invalid at the moment it's hidden, fall back to its last // persisted value instead of sending an invalid one. - const daysValid: Record = { + const daysValid = { critical: isValidDayCount(slaDays.critical), high: isValidDayCount(slaDays.high), medium: isValidDayCount(slaDays.medium), low: isValidDayCount(slaDays.low), - }; + } satisfies Record; const valid = !slaEnabled || Object.values(daysValid).every(Boolean); const patch = { slaEnabled, diff --git a/apps/mobile/src/components/ui/action-button.tsx b/apps/mobile/src/components/ui/action-button.tsx index 6844c0ef8e..5fe9e78620 100644 --- a/apps/mobile/src/components/ui/action-button.tsx +++ b/apps/mobile/src/components/ui/action-button.tsx @@ -18,19 +18,19 @@ type ActionButtonProps = { className?: string; }; -const TONE_TEXT: Record = { +const TONE_TEXT = { accent: 'text-foreground', warn: 'text-warn', danger: 'text-destructive', neutral: 'text-foreground', -}; +} satisfies Record; -const TONE_ICON: Record = { +const TONE_ICON = { accent: 'foreground', warn: 'warn', danger: 'destructive', neutral: 'foreground', -}; +} satisfies Record; /** * Flex-1 outlined action button for dashboard grids. diff --git a/apps/mobile/src/components/ui/bubble.tsx b/apps/mobile/src/components/ui/bubble.tsx index 8e4b7556cc..1100c57b7c 100644 --- a/apps/mobile/src/components/ui/bubble.tsx +++ b/apps/mobile/src/components/ui/bubble.tsx @@ -25,7 +25,10 @@ export function Bubble({ side, children, className }: Readonly) { )} > - {typeof children === 'string' ? {children} : children} + { + // oxlint-disable-next-line anti-slop/no-runtime-typeof -- ReactNode has no non-typeof way to detect its plain-string variant + typeof children === 'string' ? {children} : children + } ); @@ -38,7 +41,10 @@ export function Bubble({ side, children, className }: Readonly) { )} > - {typeof children === 'string' ? {children} : children} + { + // oxlint-disable-next-line anti-slop/no-runtime-typeof -- ReactNode has no non-typeof way to detect its plain-string variant + typeof children === 'string' ? {children} : children + } ); diff --git a/apps/mobile/src/components/ui/form-field.tsx b/apps/mobile/src/components/ui/form-field.tsx index 96a4f86312..bf52634398 100644 --- a/apps/mobile/src/components/ui/form-field.tsx +++ b/apps/mobile/src/components/ui/form-field.tsx @@ -49,7 +49,7 @@ function FormField({ }: Readonly) { const colors = useThemeColors(); const [validationError, setValidationError] = useState(null); - const valueRef = useRef(typeof defaultValue === 'string' ? defaultValue : ''); + const valueRef = useRef(defaultValue ?? ''); const displayedError = validate ? validationError : error; return ( diff --git a/apps/mobile/src/components/ui/kv-row.tsx b/apps/mobile/src/components/ui/kv-row.tsx index 4b1b6c44bc..b5e6f272f4 100644 --- a/apps/mobile/src/components/ui/kv-row.tsx +++ b/apps/mobile/src/components/ui/kv-row.tsx @@ -26,20 +26,20 @@ type KvRowProps = { selectable?: boolean; }; -const VALUE_TONE: Record, string> = { +const VALUE_TONE = { default: 'text-foreground', good: 'text-good', warn: 'text-warn', danger: 'text-destructive', muted: 'text-muted-foreground', -}; +} satisfies Record, string>; -const DOT_TONE: Record = { +const DOT_TONE = { good: 'bg-good', warn: 'bg-warn', danger: 'bg-destructive', muted: 'bg-muted-foreground', -}; +} satisfies Record; /** Label-left / mono-value-right row with hair-soft bottom divider. */ export function KvRow({ diff --git a/apps/mobile/src/components/ui/status-dot.tsx b/apps/mobile/src/components/ui/status-dot.tsx index b95ad90887..ac4f0bd01c 100644 --- a/apps/mobile/src/components/ui/status-dot.tsx +++ b/apps/mobile/src/components/ui/status-dot.tsx @@ -24,12 +24,12 @@ type StatusDotProps = { // Solid inner-dot and outer-halo classes per tone. The halo uses the // pre-tinted Focus tile tokens because `/opacity` does not work with our // CSS-variable theme tokens. -const TONE: Record = { +const TONE = { good: { dot: 'bg-good', halo: 'bg-good-tile-bg' }, warn: { dot: 'bg-warn', halo: 'bg-warn-tile-bg' }, danger: { dot: 'bg-destructive', halo: 'bg-danger-tile-bg' }, muted: { dot: 'bg-muted-soft', halo: 'bg-neutral-500/20' }, -}; +} satisfies Record; // Soft breathe range and cadence. Mirrors the provisioning-step pulse // pattern: 1.0 (fully visible) down to ~0.45 (faded), reversed, looping. diff --git a/apps/mobile/src/components/ui/text.tsx b/apps/mobile/src/components/ui/text.tsx index 7c3e4d0fa0..e9a9fe79d1 100644 --- a/apps/mobile/src/components/ui/text.tsx +++ b/apps/mobile/src/components/ui/text.tsx @@ -33,19 +33,19 @@ type TextVariantProps = VariantProps; type TextVariant = NonNullable; -const ROLE: Partial> = { +const ROLE = { h1: 'heading', h2: 'heading', h3: 'heading', h4: 'heading', -}; +} satisfies Partial>; -const ARIA_LEVEL: Partial> = { +const ARIA_LEVEL = { h1: '1', h2: '2', h3: '3', h4: '4', -}; +} satisfies Partial>; const TextClassContext = React.createContext(undefined); @@ -64,8 +64,8 @@ function Text({ return ( ); diff --git a/apps/mobile/src/components/voice-input-control.tsx b/apps/mobile/src/components/voice-input-control.tsx index 488c4e2393..70a8c32b48 100644 --- a/apps/mobile/src/components/voice-input-control.tsx +++ b/apps/mobile/src/components/voice-input-control.tsx @@ -18,13 +18,7 @@ type VoiceInputButtonProps = { // Visual class and hitSlop travel as a coupled pair so the effective touch // target stays >=44pt (visual size + 2 * hitSlop per side) at every size. -const SIZE_STYLES: Record< - VoiceInputButtonSize, - { - className: string; - hitSlop: { top: number; bottom: number; left: number; right: number }; - } -> = { +const SIZE_STYLES = { sm: { className: 'h-8 w-8 rounded-full', hitSlop: { top: 6, bottom: 6, left: 6, right: 6 }, @@ -33,7 +27,13 @@ const SIZE_STYLES: Record< className: 'h-9 w-9 rounded-full', hitSlop: { top: 4, bottom: 4, left: 4, right: 4 }, }, -}; +} satisfies Record< + VoiceInputButtonSize, + { + className: string; + hitSlop: { top: number; bottom: number; left: number; right: number }; + } +>; // Default (no prop) preserves the original kilo-chat look. const DEFAULT_STYLE = { diff --git a/apps/mobile/src/lib/active-session-order.ts b/apps/mobile/src/lib/active-session-order.ts index 1c7d19348e..1db34a8536 100644 --- a/apps/mobile/src/lib/active-session-order.ts +++ b/apps/mobile/src/lib/active-session-order.ts @@ -13,7 +13,7 @@ type Decorated = { * Never treat NaN as epoch 0. */ function parseCreatedAtMs(session: ActiveSession): number | null { - if (typeof session.createdAt !== 'string') { + if (session.createdAt === undefined) { return null; } const ms = parseTimestamp(session.createdAt).getTime(); diff --git a/apps/mobile/src/lib/active-sessions-live.ts b/apps/mobile/src/lib/active-sessions-live.ts index ad43f6690a..85e001ced4 100644 --- a/apps/mobile/src/lib/active-sessions-live.ts +++ b/apps/mobile/src/lib/active-sessions-live.ts @@ -182,20 +182,17 @@ type PreservedFields = { function readEnrichment(current: CachedActiveSession | undefined): PreservedFields { return { - createdOnPlatform: - typeof current?.createdOnPlatform === 'string' ? current.createdOnPlatform : undefined, - createdAt: typeof current?.createdAt === 'string' ? current.createdAt : undefined, - updatedAt: typeof current?.updatedAt === 'string' ? current.updatedAt : undefined, - lastActivityAt: - typeof current?.lastActivityAt === 'string' ? current.lastActivityAt : undefined, - // Pass through as-is: `null` means "the server said personal". Do not - // collapse with a `typeof === 'string'` guard — that would hide every - // personal row from the personal tray (see filter helper). + // Every field here is already `T | undefined` on ActiveSession (the + // router declares them `.optional()`), so a direct optional-chain read + // is the field's exact contract — no narrowing needed. `organizationId` + // is the one exception worth calling out: `null` means "the server said + // personal" and must pass through as-is, never collapsed to undefined. + createdOnPlatform: current?.createdOnPlatform, + createdAt: current?.createdAt, + updatedAt: current?.updatedAt, + lastActivityAt: current?.lastActivityAt, organizationId: current?.organizationId, - totalCostMicrodollars: - typeof current?.totalCostMicrodollars === 'number' - ? current.totalCostMicrodollars - : undefined, + totalCostMicrodollars: current?.totalCostMicrodollars, }; } @@ -386,7 +383,7 @@ export function removeActiveSessionsForConnection( * never carry it, and the enrichment-retry cadence must not shift. */ export function isEnriched(row: CachedActiveSession): boolean { - return ENRICHMENT_FIELDS.some(field => typeof row[field] === 'string'); + return ENRICHMENT_FIELDS.some(field => row[field] !== undefined); } export function hasUnenrichedLiveId(rows: readonly CachedActiveSession[]): boolean { diff --git a/apps/mobile/src/lib/agent-attachments/agent-attachment-types.ts b/apps/mobile/src/lib/agent-attachments/agent-attachment-types.ts index fcab59c64c..eefd16b320 100644 --- a/apps/mobile/src/lib/agent-attachments/agent-attachment-types.ts +++ b/apps/mobile/src/lib/agent-attachments/agent-attachment-types.ts @@ -71,7 +71,7 @@ export type AgentAttachmentSubmissionPayload = { * be retried. Anything else the upload task throws (network, timeout, * 408/429/5xx, generic PUT failure) is retryable. */ -export function classifyUploadFailure(error: unknown): { retryable: boolean; reason: string } { +export function classifyUploadFailure(error: unknown) { // The mutation throws `TRPCClientError` for // BAD_REQUEST / FORBIDDEN / UNPROCESSABLE_CONTENT — those are TERMINAL. // Any other thrown object (network, timeout, expiry, etc.) is RETRYABLE. diff --git a/apps/mobile/src/lib/agent-attachments/validate.ts b/apps/mobile/src/lib/agent-attachments/validate.ts index 269f810c90..7e2fefeb5a 100644 --- a/apps/mobile/src/lib/agent-attachments/validate.ts +++ b/apps/mobile/src/lib/agent-attachments/validate.ts @@ -175,10 +175,16 @@ export function classifyAttachment(candidate: AttachmentCandidate): ClassifiedAt }; } +type AttachmentAcceptance = { + ok: boolean; + acceptedCount: number; + truncated?: boolean; +}; + export function canAddAttachments( currentCount: number, incomingCount: number -): { ok: boolean; acceptedCount: number; truncated?: boolean } { +): AttachmentAcceptance { const remaining = AGENT_ATTACHMENT_MAX_FILES - currentCount; if (remaining <= 0) { return { ok: false, acceptedCount: 0 }; diff --git a/apps/mobile/src/lib/agent-session-filters.ts b/apps/mobile/src/lib/agent-session-filters.ts index 17e5ea6992..05dad301be 100644 --- a/apps/mobile/src/lib/agent-session-filters.ts +++ b/apps/mobile/src/lib/agent-session-filters.ts @@ -1,3 +1,5 @@ +import { z } from 'zod'; + import { type AgentSessionSortBy, parseAgentSessionSortBy } from './agent-session-sort'; /** @@ -19,17 +21,29 @@ export function createDefaultAgentSessionFilters(): AgentSessionFilters { }; } -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); +/** Zod's validation `.catch()` fallback, not a Promise catch. */ +function tolerant(schema: z.ZodType, fallback: T): z.ZodType { + // oxlint-disable-next-line promise/prefer-await-to-then -- zod schema fallback, not a Promise + return schema.catch(fallback); } -function readStringArray(value: unknown): string[] { - if (!Array.isArray(value)) { - return []; - } - return value.filter((item): item is string => typeof item === 'string'); +const stringItemSchema = z.string(); + +function isStringItem(item: unknown): item is string { + return stringItemSchema.safeParse(item).success; } +/** Keeps only string entries; a non-array or wholly-bad value collapses to `[]`. */ +const tolerantStringArraySchema = tolerant(z.array(z.unknown()), []).transform(items => + items.filter((item): item is string => isStringItem(item)) +); + +const storedAgentSessionFiltersSchema = z.object({ + platformFilter: tolerantStringArraySchema, + projectFilter: tolerantStringArraySchema, + sortBy: z.unknown().optional(), +}); + /** * Parse the raw SecureStore JSON for the agent-session filter record. Returns * `null` only when the JSON itself is malformed or not an object — in every @@ -42,21 +56,22 @@ export function parseStoredAgentSessionFilters(raw: string | null): AgentSession return null; } - let parsed: unknown = null; + let parsed = null; try { parsed = JSON.parse(raw); } catch { return null; } - if (!isRecord(parsed)) { + const result = storedAgentSessionFiltersSchema.safeParse(parsed); + if (!result.success) { return null; } return { - platformFilter: readStringArray(parsed.platformFilter), - projectFilter: readStringArray(parsed.projectFilter), - sortBy: parseAgentSessionSortBy(parsed.sortBy), + platformFilter: result.data.platformFilter, + projectFilter: result.data.projectFilter, + sortBy: parseAgentSessionSortBy(result.data.sortBy), }; } diff --git a/apps/mobile/src/lib/agent-session-input.ts b/apps/mobile/src/lib/agent-session-input.ts index abce00d806..3ae42c1d59 100644 --- a/apps/mobile/src/lib/agent-session-input.ts +++ b/apps/mobile/src/lib/agent-session-input.ts @@ -87,8 +87,6 @@ export function buildAgentSessionSearchInput(options: { * same context must produce the *same* query key — the live-sync owner writes WS * payloads straight into that key, so a mismatch would silently split the cache. */ -export function buildActiveSessionsInput(organizationId: string | null | undefined): { - organizationId: string | null; -} { +export function buildActiveSessionsInput(organizationId: string | null | undefined) { return { organizationId: organizationId ?? null }; } diff --git a/apps/mobile/src/lib/agent-session-sort.ts b/apps/mobile/src/lib/agent-session-sort.ts index ec4aca07aa..7fcc7cdb12 100644 --- a/apps/mobile/src/lib/agent-session-sort.ts +++ b/apps/mobile/src/lib/agent-session-sort.ts @@ -1,3 +1,5 @@ +import * as z from 'zod'; + /** * The set of fields the agent-sessions list/search endpoints accept as the * `orderBy` argument. Today they both default to `updated_at` server-side; @@ -10,7 +12,7 @@ export type AgentSessionSortBy = (typeof AGENT_SESSION_SORT_OPTIONS)[number]; export const DEFAULT_AGENT_SESSION_SORT: AgentSessionSortBy = 'updated_at'; -const SORT_BY_SET = new Set(AGENT_SESSION_SORT_OPTIONS); +const agentSessionSortBySchema = z.enum(AGENT_SESSION_SORT_OPTIONS); /** * Coerce arbitrary persisted/legacy/unknown input into a known sort value. @@ -18,10 +20,8 @@ const SORT_BY_SET = new Set(AGENT_SESSION_SORT_OPTIONS); * SecureStore record can never crash the list. */ export function parseAgentSessionSortBy(value: unknown): AgentSessionSortBy { - if (typeof value === 'string' && SORT_BY_SET.has(value)) { - return value as AgentSessionSortBy; - } - return DEFAULT_AGENT_SESSION_SORT; + const result = agentSessionSortBySchema.safeParse(value); + return result.success ? result.data : DEFAULT_AGENT_SESSION_SORT; } type AgentSessionTimestamps = { created_at: string; updated_at: string }; diff --git a/apps/mobile/src/lib/analytics/posthog.ts b/apps/mobile/src/lib/analytics/posthog.ts index f3b44a2ec8..f9c9c883de 100644 --- a/apps/mobile/src/lib/analytics/posthog.ts +++ b/apps/mobile/src/lib/analytics/posthog.ts @@ -128,8 +128,10 @@ async function chainDiscard(prev: Promise, next: Promise): Promise { - const props: Record = {}; +type AppVersionProperties = { app_version?: string; app_build?: string }; + +function appVersionProperties(): AppVersionProperties { + const props: AppVersionProperties = {}; if (Application.nativeApplicationVersion) { props.app_version = Application.nativeApplicationVersion; } @@ -288,6 +290,10 @@ export async function discardPostHog(): Promise { const completion = (async () => { const c = client; + // A bare/partial client (e.g. an incomplete test double) can reach here + // without `setPersistedProperty`, despite the `PostHog` type promising it + // always exists — treat that the same as no client. + // oxlint-disable-next-line anti-slop/no-runtime-typeof -- see comment above: guards against a client that violates its own type's contract if (typeof c?.setPersistedProperty !== 'function') { client = null; notifyPostHogReady(); diff --git a/apps/mobile/src/lib/appsflyer.ts b/apps/mobile/src/lib/appsflyer.ts index cf12e78fd0..758cf59fe1 100644 --- a/apps/mobile/src/lib/appsflyer.ts +++ b/apps/mobile/src/lib/appsflyer.ts @@ -5,6 +5,7 @@ import appsFlyer, { AppsFlyerPurchaseConnector, StoreKitVersion, } from 'react-native-appsflyer'; +import { z } from 'zod'; import { captureEvent } from '@/lib/analytics/posthog'; import { APPSFLYER_APP_ID, APPSFLYER_DEV_KEY } from '@/lib/config'; @@ -37,22 +38,26 @@ function handleError(message: string) { }; } +const ErrorRecordSchema = z.looseObject({ + code: z.string().optional(), + message: z.string().optional(), +}); + +const rejectionStringSchema = z.string(); + function rejectionText(error: unknown): string { - if (typeof error === 'string') { - return error; + const asString = rejectionStringSchema.safeParse(error); + if (asString.success) { + return asString.data; } if (error instanceof Error) { return error.message; } - if (typeof error === 'object' && error !== null) { - const record = error as { code?: unknown; message?: unknown }; - const parts: string[] = []; - if (typeof record.code === 'string') { - parts.push(record.code); - } - if (typeof record.message === 'string') { - parts.push(record.message); - } + const record = ErrorRecordSchema.safeParse(error); + if (record.success) { + const parts = [record.data.code, record.data.message].filter( + (part): part is string => part !== undefined + ); if (parts.length > 0) { return parts.join(' '); } @@ -64,12 +69,12 @@ function isConnectorAlreadyConfigured(error: unknown): boolean { if (error == null) { return false; } - if (typeof error === 'object') { - const record = error as { code?: unknown; message?: unknown }; - if (record.code === CONNECTOR_ALREADY_CONFIGURED) { + const record = ErrorRecordSchema.safeParse(error); + if (record.success) { + if (record.data.code === CONNECTOR_ALREADY_CONFIGURED) { return true; } - if (record.message === CONNECTOR_ALREADY_CONFIGURED) { + if (record.data.message === CONNECTOR_ALREADY_CONFIGURED) { return true; } } @@ -162,6 +167,7 @@ export function initAppsFlyer(): void { // Resume the SDK if it was stopped by a prior reset. Native stop may throw // synchronously — catch it so initSdk still proceeds. try { + // oxlint-disable-next-line anti-slop/no-runtime-typeof -- environment probe: a bare test/native mock may omit `stop` even though the shipped SDK types always declare it if (typeof (appsFlyer as Record).stop === 'function') { appsFlyer.stop(false); } @@ -242,6 +248,7 @@ export function resetAppsFlyerState(): void { pendingEvents.length = 0; try { + // oxlint-disable-next-line anti-slop/no-runtime-typeof -- environment probe: a bare test/native mock may omit `stop` even though the shipped SDK types always declare it if (typeof (appsFlyer as Record).stop === 'function') { appsFlyer.stop(true); } diff --git a/apps/mobile/src/lib/auth/admission.ts b/apps/mobile/src/lib/auth/admission.ts index e4077596b1..4bc74f0598 100644 --- a/apps/mobile/src/lib/auth/admission.ts +++ b/apps/mobile/src/lib/auth/admission.ts @@ -2,6 +2,7 @@ import * as AppIntegrity from '@expo/app-integrity'; import { CryptoDigestAlgorithm, CryptoEncoding, digestStringAsync } from 'expo-crypto'; import * as SecureStore from 'expo-secure-store'; import { Platform } from 'react-native'; +import * as z from 'zod'; import { API_BASE_URL, PLAY_INTEGRITY_PROJECT_NUMBER } from '@/lib/config'; import { ATTEST_KEY_ID_KEY } from '@/lib/storage-keys'; @@ -51,13 +52,10 @@ async function requestChallenge(): Promise<{ challenge: string }> { return response.json() as Promise<{ challenge: string }>; } +const invalidKeyErrorSchema = z.object({ code: z.literal('ERR_APP_INTEGRITY_INVALID_KEY') }); + function isInvalidKeyError(error: unknown): boolean { - return ( - typeof error === 'object' && - error !== null && - 'code' in error && - (error as { code: unknown }).code === 'ERR_APP_INTEGRITY_INVALID_KEY' - ); + return invalidKeyErrorSchema.safeParse(error).success; } /** diff --git a/apps/mobile/src/lib/auth/auth-fetch.ts b/apps/mobile/src/lib/auth/auth-fetch.ts index 97d14bd3ab..ea89fda422 100644 --- a/apps/mobile/src/lib/auth/auth-fetch.ts +++ b/apps/mobile/src/lib/auth/auth-fetch.ts @@ -1,7 +1,11 @@ +import { z } from 'zod'; + import { API_BASE_URL } from '@/lib/config'; import { clearAttestKeyOnRefusal } from '@/lib/auth/admission'; import { parseAuthErrorCode } from '@/lib/auth/native-auth-contract'; +const stringCodeErrorSchema = z.object({ code: z.string() }); + /** * Minimal fetch helper for auth endpoints. Returns success with parsed body * or failure with an optional error code. @@ -41,10 +45,5 @@ export async function postAuth( } export function hasStringCode(error: unknown): error is { code: string } { - return ( - typeof error === 'object' && - error !== null && - 'code' in error && - typeof (error as { code: unknown }).code === 'string' - ); + return stringCodeErrorSchema.safeParse(error).success; } diff --git a/apps/mobile/src/lib/auth/native-auth-contract.ts b/apps/mobile/src/lib/auth/native-auth-contract.ts index c53115a4ea..7a3ab51a5e 100644 --- a/apps/mobile/src/lib/auth/native-auth-contract.ts +++ b/apps/mobile/src/lib/auth/native-auth-contract.ts @@ -60,11 +60,8 @@ export type DeviceAuthCodeResult = { verificationUrl: string; }; -export function buildDeviceAuthPollRequest(deviceCode: string): { - deviceCode: string; - supportsRefresh: true; -} { - return { deviceCode, supportsRefresh: true }; +export function buildDeviceAuthPollRequest(deviceCode: string) { + return { deviceCode, supportsRefresh: true as const }; } export function shouldRefreshBeforeRequest( diff --git a/apps/mobile/src/lib/auth/use-native-auth.ts b/apps/mobile/src/lib/auth/use-native-auth.ts index d08a891d32..6d31702a13 100644 --- a/apps/mobile/src/lib/auth/use-native-auth.ts +++ b/apps/mobile/src/lib/auth/use-native-auth.ts @@ -21,7 +21,7 @@ import { selectChallengeId, } from '@/lib/auth/native-auth-contract'; -export const AUTH_ERROR_MESSAGES: Record = { +export const AUTH_ERROR_MESSAGES = { 'EMAIL-ALREADY-USED': "An account with this email already exists with a different sign-in method. Try another method or use 'More sign-in options'.", 'DIFFERENT-OAUTH': @@ -40,14 +40,17 @@ export const AUTH_ERROR_MESSAGES: Record = { // Admission: server refuses the device under enforce mode — non-retryable. ADMISSION_REQUIRED: "Your device can't be verified. Use 'More sign-in options' to sign in on another device or through the web.", -}; +} satisfies Record; export const DEFAULT_ERROR_MESSAGE = 'Something went wrong. Please try again.'; export const RETRYABLE_ADMISSION_ERROR = 'We could not verify this device. Check your connection and try again.'; export function mapError(errorCode: string | undefined): string { - return (errorCode && AUTH_ERROR_MESSAGES[errorCode]) ?? DEFAULT_ERROR_MESSAGE; + return ( + (errorCode && AUTH_ERROR_MESSAGES[errorCode as keyof typeof AUTH_ERROR_MESSAGES]) ?? + DEFAULT_ERROR_MESSAGE + ); } export async function resolveAdmission(): Promise< @@ -214,27 +217,21 @@ export function useNativeAuth(): NativeAuthResult { return; } - const body: Record = { - provider: 'google', - supportsRefresh: true, - }; - - if (serverAuthCode) { - body.serverAuthCode = serverAuthCode; - body.googleClientId = GOOGLE_WEB_CLIENT_ID; - } else { - body.idToken = idToken; - } - let admissionBody: Record = {}; try { admissionBody = await resolveAdmission(); } catch { return; } - Object.assign(body, admissionBody); - const result = await postAuth('/api/auth/native/token', body); + const result = await postAuth('/api/auth/native/token', { + provider: 'google', + supportsRefresh: true, + ...(serverAuthCode + ? { serverAuthCode, googleClientId: GOOGLE_WEB_CLIENT_ID } + : { idToken }), + ...admissionBody, + }); if (result.ok) { const parsed = parseTokenPair(result.data); diff --git a/apps/mobile/src/lib/badge-hydration.ts b/apps/mobile/src/lib/badge-hydration.ts index 916712c227..74cc9db6d5 100644 --- a/apps/mobile/src/lib/badge-hydration.ts +++ b/apps/mobile/src/lib/badge-hydration.ts @@ -1,22 +1,22 @@ import { type BadgeCountRow } from '@kilocode/notifications'; -type ReconcileHydratedBadgeCountInput = { +type ReconcileHydratedBadgeCountInput = { badgeRows: BadgeCountRow[]; startBadgeFreshnessEpoch: number; currentBadgeFreshnessEpoch: number; - setBadgeCount: (badgeCount: number) => Promise; + setBadgeCount: (badgeCount: number) => Promise; }; export function totalBadgeCount(badgeRows: BadgeCountRow[]): number { return badgeRows.reduce((total, row) => total + row.badgeCount, 0); } -export function reconcileHydratedBadgeCount({ +export function reconcileHydratedBadgeCount({ badgeRows, startBadgeFreshnessEpoch, currentBadgeFreshnessEpoch, setBadgeCount, -}: ReconcileHydratedBadgeCountInput): boolean { +}: ReconcileHydratedBadgeCountInput): boolean { if (currentBadgeFreshnessEpoch !== startBadgeFreshnessEpoch) { return false; } diff --git a/apps/mobile/src/lib/code-reviewer-config.ts b/apps/mobile/src/lib/code-reviewer-config.ts index a0817f4c6a..4f42366d5d 100644 --- a/apps/mobile/src/lib/code-reviewer-config.ts +++ b/apps/mobile/src/lib/code-reviewer-config.ts @@ -16,17 +16,7 @@ export type ReviewerPlatform = CodeReviewPlatform; export const PERSONAL_SCOPE = 'personal'; -export const PLATFORM_CAPABILITIES: Record< - ReviewerPlatform, - { - scopes: 'all' | 'org'; - selectionModePicker: boolean; - gateRow: boolean; - reviewMd: boolean; - manualReview: boolean; - label: string; - } -> = { +export const PLATFORM_CAPABILITIES = { github: { scopes: 'all', selectionModePicker: true, @@ -51,7 +41,17 @@ export const PLATFORM_CAPABILITIES: Record< manualReview: false, label: 'Bitbucket', }, -}; +} satisfies Record< + ReviewerPlatform, + { + scopes: 'all' | 'org'; + selectionModePicker: boolean; + gateRow: boolean; + reviewMd: boolean; + manualReview: boolean; + label: string; + } +>; const REVIEWER_PLATFORMS = Object.keys(PLATFORM_CAPABILITIES) as ReviewerPlatform[]; diff --git a/apps/mobile/src/lib/code-reviewer-status.test.ts b/apps/mobile/src/lib/code-reviewer-status.test.ts index c921ceb220..10ab842527 100644 --- a/apps/mobile/src/lib/code-reviewer-status.test.ts +++ b/apps/mobile/src/lib/code-reviewer-status.test.ts @@ -43,13 +43,13 @@ describe('classifyProviderState', () => { isFetching: true, connected: undefined, hasData: false, - refetch: vi.fn(), + refetch: vi.fn<() => void>(), }) ).toEqual({ status: 'loading' }); }); it('is an error on initial-load failure (no cached data)', () => { - const refetch = vi.fn(); + const refetch = vi.fn<() => void>(); const result = classifyProviderState({ isLoading: false, isError: true, @@ -74,7 +74,7 @@ describe('classifyProviderState', () => { isFetching: false, connected: false, hasData: false, - refetch: vi.fn(), + refetch: vi.fn<() => void>(), }); expect(result.status).toBe('error'); expect(result.status).not.toBe('disconnected'); @@ -88,7 +88,7 @@ describe('classifyProviderState', () => { isFetching: false, connected: true, hasData: true, - refetch: vi.fn(), + refetch: vi.fn<() => void>(), }) ).toEqual({ status: 'connected' }); }); @@ -101,7 +101,7 @@ describe('classifyProviderState', () => { isFetching: false, connected: true, hasData: true, - refetch: vi.fn(), + refetch: vi.fn<() => void>(), }) ).toEqual({ status: 'connected' }); }); @@ -114,7 +114,7 @@ describe('classifyProviderState', () => { isFetching: false, connected: false, hasData: true, - refetch: vi.fn(), + refetch: vi.fn<() => void>(), }) ).toEqual({ status: 'disconnected' }); }); @@ -131,7 +131,7 @@ describe('classifyProviderState', () => { isFetching: false, connected: undefined, hasData: false, - refetch: vi.fn(), + refetch: vi.fn<() => void>(), }) ).toEqual({ status: 'loading' }); }); @@ -146,7 +146,7 @@ describe('classifyPermission', () => { isError: true, isFetching: true, role: undefined, - refetch: vi.fn(), + refetch: vi.fn<() => void>(), }) ).toEqual({ status: 'ready', canEdit: true }); }); @@ -159,13 +159,13 @@ describe('classifyPermission', () => { isError: false, isFetching: true, role: undefined, - refetch: vi.fn(), + refetch: vi.fn<() => void>(), }) ).toEqual({ status: 'loading' }); }); it('is an error when the org list query fails', () => { - const refetch = vi.fn(); + const refetch = vi.fn<() => void>(); const result = classifyPermission({ isPersonal: false, isLoading: false, @@ -190,7 +190,7 @@ describe('classifyPermission', () => { isError: false, isFetching: false, role, - refetch: vi.fn(), + refetch: vi.fn<() => void>(), }) ).toEqual({ status: 'ready', canEdit: true }); } @@ -204,7 +204,7 @@ describe('classifyPermission', () => { isError: false, isFetching: false, role: 'member', - refetch: vi.fn(), + refetch: vi.fn<() => void>(), }) ).toEqual({ status: 'ready', canEdit: false }); @@ -215,7 +215,7 @@ describe('classifyPermission', () => { isError: false, isFetching: false, role: undefined, - refetch: vi.fn(), + refetch: vi.fn<() => void>(), }) ).toEqual({ status: 'ready', canEdit: false }); }); diff --git a/apps/mobile/src/lib/code-reviewer-status.ts b/apps/mobile/src/lib/code-reviewer-status.ts index 7baee2a0f9..c763d39c50 100644 --- a/apps/mobile/src/lib/code-reviewer-status.ts +++ b/apps/mobile/src/lib/code-reviewer-status.ts @@ -19,10 +19,7 @@ type ProviderErrorVariant = 'server' | 'permission' | 'not-found'; * `permanent` (rendered with the permission/not-found QueryError variant and * no retry); anything else is a transient server error. */ -export function classifyProviderErrorCode(errorCode: string | undefined): { - permanent: boolean; - variant: ProviderErrorVariant; -} { +export function classifyProviderErrorCode(errorCode: string | undefined) { const permanent = errorCode === 'FORBIDDEN' || errorCode === 'UNAUTHORIZED' || errorCode === 'NOT_FOUND'; let variant: ProviderErrorVariant = 'server'; @@ -53,7 +50,7 @@ export function classifyProviderState(input: { isFetching: boolean; connected: boolean | undefined; hasData: boolean; - refetch: () => unknown; + refetch: () => void; /** TRPC error code (error.data?.code) when isError, for permanent-vs-transient classification. */ errorCode?: string; }): ProviderState { @@ -67,7 +64,7 @@ export function classifyProviderState(input: { const { permanent, variant } = classifyProviderErrorCode(input.errorCode); return { status: 'error', - refetch: () => void input.refetch(), + refetch: input.refetch, isRetrying: input.isFetching, permanent, variant, @@ -97,7 +94,7 @@ export function classifyPermission(input: { isError: boolean; isFetching: boolean; role: string | undefined; - refetch: () => unknown; + refetch: () => void; }): PermissionState { if (input.isPersonal) { return { status: 'ready', canEdit: true }; @@ -106,7 +103,7 @@ export function classifyPermission(input: { return { status: 'loading' }; } if (input.isError) { - return { status: 'error', refetch: () => void input.refetch(), isRetrying: input.isFetching }; + return { status: 'error', refetch: input.refetch, isRetrying: input.isFetching }; } return { status: 'ready', canEdit: canManageOrganizationBilling(input.role) }; } diff --git a/apps/mobile/src/lib/github-pr-url.ts b/apps/mobile/src/lib/github-pr-url.ts index f7ca0811ea..bbc67fbdfa 100644 --- a/apps/mobile/src/lib/github-pr-url.ts +++ b/apps/mobile/src/lib/github-pr-url.ts @@ -20,7 +20,7 @@ function isValidIdentifier(value: string): boolean { * host, non-GitHub hosts, or malformed input. */ export function parseGitHubPrUrl(href: string): GitHubPrUrl | null { - if (typeof href !== 'string' || href.length === 0) { + if (href.length === 0) { return null; } const match = GITHUB_PR_PATTERN.exec(href); @@ -50,7 +50,7 @@ export function parseGitHubPrUrl(href: string): GitHubPrUrl | null { * do not hide the URL. Returns `null` when no token parses as a PR URL. */ export function findFirstGitHubPrUrl(text: string): GitHubPrUrl | null { - if (typeof text !== 'string' || text.length === 0) { + if (text.length === 0) { return null; } const trimmed = text.trim(); diff --git a/apps/mobile/src/lib/hooks/agent-model-preference.ts b/apps/mobile/src/lib/hooks/agent-model-preference.ts index 362b638579..f27fa40cc1 100644 --- a/apps/mobile/src/lib/hooks/agent-model-preference.ts +++ b/apps/mobile/src/lib/hooks/agent-model-preference.ts @@ -1,8 +1,13 @@ +import { z } from 'zod'; + import { type ModelOption } from '@/lib/hooks/use-available-models'; export type ModelPreferenceEntry = { model: string; variant: string }; export type StoredModelPreference = Record; +const modelPreferenceEntrySchema = z.object({ model: z.string(), variant: z.string() }); +const rawStoredModelPreferenceSchema = z.record(z.string(), z.unknown()); + export function contextKey(organizationId?: string): string { return organizationId ?? 'personal'; } @@ -12,25 +17,17 @@ export function parseStoredModelPreference(raw: string | null): StoredModelPrefe return {}; } try { - const parsed = JSON.parse(raw) as unknown; - if (typeof parsed !== 'object' || parsed === null) { + const parsed: unknown = JSON.parse(raw); + const shape = rawStoredModelPreferenceSchema.safeParse(parsed); + if (!shape.success) { return {}; } - const result: StoredModelPreference = {}; - for (const [key, value] of Object.entries(parsed)) { - if ( - typeof value === 'object' && - value !== null && - typeof (value as ModelPreferenceEntry).model === 'string' && - typeof (value as ModelPreferenceEntry).variant === 'string' - ) { - result[key] = { - model: (value as ModelPreferenceEntry).model, - variant: (value as ModelPreferenceEntry).variant, - }; - } - } - return result; + return Object.fromEntries( + Object.entries(shape.data).flatMap<[string, ModelPreferenceEntry]>(([key, value]) => { + const entry = modelPreferenceEntrySchema.safeParse(value); + return entry.success ? [[key, entry.data]] : []; + }) + ); } catch { return {}; } diff --git a/apps/mobile/src/lib/hooks/remote-instance-spawn-classifier.ts b/apps/mobile/src/lib/hooks/remote-instance-spawn-classifier.ts index a4a7c93aca..599fbc3082 100644 --- a/apps/mobile/src/lib/hooks/remote-instance-spawn-classifier.ts +++ b/apps/mobile/src/lib/hooks/remote-instance-spawn-classifier.ts @@ -257,8 +257,14 @@ function generateCreationKey(): string { // opaque per-attempt bookkeeping identifier, never parsed as a UUID by // anything, so a plain random fallback string is sufficient on the rare // environment without it. + // `globalThis.crypto` is typed as always present (lib.dom.d.ts), but that + // static type does not reflect every real Hermes/RN runtime. Reading it + // through `Reflect.get` keeps the existence check honest instead of a typed + // access that TypeScript (wrongly, for this runtime) would treat as always + // true and flag the fallback branch below as unreachable. + // oxlint-disable-next-line anti-slop/no-reflect-get -- see comment above: a typed access would make TS treat the runtime fallback as dead code const cryptoApi = Reflect.get(globalThis, 'crypto') as { randomUUID?: () => string } | undefined; - if (cryptoApi && typeof cryptoApi.randomUUID === 'function') { + if (cryptoApi?.randomUUID !== undefined) { return cryptoApi.randomUUID(); } return `spawn-${Date.now()}-${Math.random().toString(36).slice(2)}`; diff --git a/apps/mobile/src/lib/hooks/use-auto-select-model.ts b/apps/mobile/src/lib/hooks/use-auto-select-model.ts index aa854bef10..ab6b505873 100644 --- a/apps/mobile/src/lib/hooks/use-auto-select-model.ts +++ b/apps/mobile/src/lib/hooks/use-auto-select-model.ts @@ -7,10 +7,7 @@ import { usePersistedAgentModel } from '@/lib/hooks/use-persisted-agent-model'; const NO_SELECTION = { model: '', variant: '' }; -export function useAutoSelectModel( - models: ModelOption[], - organizationId: string | undefined -): { model: string; variant: string } { +export function useAutoSelectModel(models: ModelOption[], organizationId: string | undefined) { const { lastSelected, isLoading } = useModelPreferences(organizationId); const { defaultModel: orgDefaultModel, isLoading: orgDefaultIsLoading } = useOrgDefaultModel(organizationId); diff --git a/apps/mobile/src/lib/hooks/use-code-reviewer.ts b/apps/mobile/src/lib/hooks/use-code-reviewer.ts index 4da6865a93..141f7c561b 100644 --- a/apps/mobile/src/lib/hooks/use-code-reviewer.ts +++ b/apps/mobile/src/lib/hooks/use-code-reviewer.ts @@ -251,12 +251,16 @@ export function useSaveReviewConfig(scope: string, platform: ReviewerPlatform) { // still be a real edit and could clobber stored values. const narrowedSelectedRepositoryIds = rawSelectedRepositoryIds !== undefined - ? rawSelectedRepositoryIds.filter((id): id is number => typeof id === 'number') + ? rawSelectedRepositoryIds.filter( + // oxlint-disable-next-line anti-slop/no-runtime-typeof -- distinguishing a number id from a string id in a mixed primitive union has no non-typeof narrowing + (id): id is number => typeof id === 'number' + ) : undefined; const narrowedRepositoryModelOverrides = rawRepositoryModelOverrides !== undefined ? rawRepositoryModelOverrides.filter( (override): override is typeof override & { repositoryId: number } => + // oxlint-disable-next-line anti-slop/no-runtime-typeof -- distinguishing a number id from a string id in a mixed primitive union has no non-typeof narrowing typeof override.repositoryId === 'number' ) : undefined; diff --git a/apps/mobile/src/lib/hooks/use-current-user-id.ts b/apps/mobile/src/lib/hooks/use-current-user-id.ts index d05c3cc77a..70bcac3341 100644 --- a/apps/mobile/src/lib/hooks/use-current-user-id.ts +++ b/apps/mobile/src/lib/hooks/use-current-user-id.ts @@ -6,13 +6,7 @@ type UseCurrentUserIdOptions = { readonly enabled?: boolean; }; -export function useCurrentUserId(options: UseCurrentUserIdOptions = {}): { - userId: string | undefined; - email: string | undefined; - isLoading: boolean; - isError: boolean; - refetch: () => void; -} { +export function useCurrentUserId(options: UseCurrentUserIdOptions = {}) { const trpc = useTRPC(); const { data, isLoading, isError, refetch } = useQuery({ ...trpc.user.getMe.queryOptions(), diff --git a/apps/mobile/src/lib/hooks/use-instance-model-catalog.ts b/apps/mobile/src/lib/hooks/use-instance-model-catalog.ts index 9b1e64d0db..77f0cc3e43 100644 --- a/apps/mobile/src/lib/hooks/use-instance-model-catalog.ts +++ b/apps/mobile/src/lib/hooks/use-instance-model-catalog.ts @@ -2,7 +2,6 @@ import { useQuery } from '@tanstack/react-query'; import { type InstanceModelCatalogResult, listInstanceModels, - type RemoteModelCatalogV1, } from '@kilocode/cloud-agent-sdk/instance-model-catalog'; import { useUserWebConnection } from '@/components/agents/user-web-connection-provider'; @@ -23,10 +22,7 @@ import { useUserWebConnection } from '@/components/agents/user-web-connection-pr * gateway fallback therefore means "this instance has never answered", not * "the last read failed". */ -export function useInstanceModelCatalog(connectionId: string | null): { - catalog: RemoteModelCatalogV1 | null; - isLoading: boolean; -} { +export function useInstanceModelCatalog(connectionId: string | null) { const connection = useUserWebConnection(); const { data, isPending } = useQuery({ queryKey: ['instance-model-catalog', connectionId], diff --git a/apps/mobile/src/lib/hooks/use-kiloclaw-mutations.ts b/apps/mobile/src/lib/hooks/use-kiloclaw-mutations.ts index d614a5ecba..71c67d40d3 100644 --- a/apps/mobile/src/lib/hooks/use-kiloclaw-mutations.ts +++ b/apps/mobile/src/lib/hooks/use-kiloclaw-mutations.ts @@ -1,5 +1,6 @@ /* eslint-disable max-lines */ import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { z } from 'zod'; import { announcingToast } from '@/lib/a11y/announcing-toast'; import { type ClawInstance } from '@/lib/hooks/use-instance-context'; @@ -11,19 +12,17 @@ const onMutationError = (error: { message: string }) => { announcingToast.error(error.message || 'Something went wrong'); }; +const TrpcHttpStatusErrorSchema = z.looseObject({ + data: z.looseObject({ httpStatus: z.number().optional() }), +}); + /** * Extract the tRPC `data.httpStatus` field without an `as` cast. Returns * `undefined` for any shape that doesn't match the tRPC error envelope. */ function getTrpcHttpStatus(error: unknown): number | undefined { - if (error === null || typeof error !== 'object' || !('data' in error)) { - return undefined; - } - const data = error.data; - if (data === null || typeof data !== 'object' || !('httpStatus' in data)) { - return undefined; - } - return typeof data.httpStatus === 'number' ? data.httpStatus : undefined; + const parsed = TrpcHttpStatusErrorSchema.safeParse(error); + return parsed.success ? parsed.data.data.httpStatus : undefined; } /** @@ -148,8 +147,8 @@ export function useKiloClawMutations(organizationId?: string | null) { // Extracts mutationFn from personal or org path and injects organizationId type AnyMutPath = { - mutationOptions: (opts: object) => { - // eslint-disable-next-line typescript-eslint/no-explicit-any -- wrapping arbitrary tRPC mutations + mutationOptions: (opts: Record) => { + // oxlint-disable-next-line typescript-eslint/no-explicit-any, anti-slop/no-unknown-returns -- wrapping arbitrary tRPC mutations, type-erased across ~15 heterogeneous procedures; each useMutation call site below supplies its own TData mutationFn?: ((...args: any[]) => Promise) | undefined; mutationKey: unknown[]; }; @@ -161,11 +160,13 @@ export function useKiloClawMutations(organizationId?: string | null) { const personalFn = personalOpts.mutationFn ?? asyncNoop; const orgFn = orgOpts.mutationFn ?? asyncNoop; + // oxlint-disable-next-line anti-slop/no-unknown-returns -- type-erased across ~15 heterogeneous tRPC mutation procedures; each useMutation call site below supplies its own TData let mutationFn: (...args: unknown[]) => Promise = asyncNoop; if (isResolved && isOrg) { - // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule + // oxlint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule mutationFn = (input: unknown) => orgFn( + // oxlint-disable-next-line anti-slop/no-runtime-typeof -- generic payload merge across heterogeneous tRPC mutation inputs; no static shape to narrow against input && typeof input === 'object' ? { ...input, organizationId } : { organizationId } ); } else if (isResolved) { diff --git a/apps/mobile/src/lib/hooks/use-persisted-agent-session-filters.ts b/apps/mobile/src/lib/hooks/use-persisted-agent-session-filters.ts index e0daf2c05e..bbcf688c3d 100644 --- a/apps/mobile/src/lib/hooks/use-persisted-agent-session-filters.ts +++ b/apps/mobile/src/lib/hooks/use-persisted-agent-session-filters.ts @@ -72,14 +72,14 @@ export function usePersistedAgentSessionFilters() { }, [filters, hasLoaded]); const setFilters = useCallback((updater: FiltersUpdater) => { - setFiltersState(prev => (typeof updater === 'function' ? updater(prev) : updater)); + setFiltersState(prev => ('sortBy' in updater ? updater : updater(prev))); }, []); const setPlatformFilter = useCallback( (updater: StringArrayUpdater) => { setFilters(prev => ({ ...prev, - platformFilter: typeof updater === 'function' ? updater(prev.platformFilter) : updater, + platformFilter: Array.isArray(updater) ? updater : updater(prev.platformFilter), })); }, [setFilters] @@ -89,7 +89,7 @@ export function usePersistedAgentSessionFilters() { (updater: StringArrayUpdater) => { setFilters(prev => ({ ...prev, - projectFilter: typeof updater === 'function' ? updater(prev.projectFilter) : updater, + projectFilter: Array.isArray(updater) ? updater : updater(prev.projectFilter), })); }, [setFilters] diff --git a/apps/mobile/src/lib/hooks/use-remote-instance-spawn.ts b/apps/mobile/src/lib/hooks/use-remote-instance-spawn.ts index 34213e6449..ff4ecaaccf 100644 --- a/apps/mobile/src/lib/hooks/use-remote-instance-spawn.ts +++ b/apps/mobile/src/lib/hooks/use-remote-instance-spawn.ts @@ -54,14 +54,7 @@ export type RemoteInstanceSpawnStatus = * * Explicit `opts.orgId` on `spawn()` still wins when the caller sets it. */ -export function useRemoteInstanceSpawn(organizationId?: string | null): { - status: RemoteInstanceSpawnStatus; - spawn: ( - connectionId: string, - opts?: CreateRemoteSessionInput, - options?: CreateSessionSpawnOptions - ) => Promise; -} { +export function useRemoteInstanceSpawn(organizationId?: string | null) { const connection = useUserWebConnection(); const { organizationId: contextOrganizationId } = useOrganization(); const resolvedOrganizationId = resolveSpawnOrganizationId(organizationId, contextOrganizationId); diff --git a/apps/mobile/src/lib/hooks/use-security-agent-mutations.ts b/apps/mobile/src/lib/hooks/use-security-agent-mutations.ts index 6f593d5e1e..1b7228c800 100644 --- a/apps/mobile/src/lib/hooks/use-security-agent-mutations.ts +++ b/apps/mobile/src/lib/hooks/use-security-agent-mutations.ts @@ -88,17 +88,17 @@ export function isSecuritySyncRetryable(error: unknown): boolean { return classifyPrReviewMutationError(error).kind === 'retryable'; } -function mapOperationInProgress(error: unknown, copy: string): unknown { +function mapOperationInProgress(error: unknown, copy: string) { return isOperationInProgress(error) ? new Error(copy) : error; } /** Maps the raw in-progress marker onto retryable sync copy; others pass through. */ -export function mapSecuritySyncOperationError(error: unknown): unknown { +export function mapSecuritySyncOperationError(error: unknown) { return mapOperationInProgress(error, SECURITY_SYNC_IN_PROGRESS_COPY); } /** Same marker, dismissal copy: the dismiss sheet must not talk about a sync. */ -export function mapSecurityDismissOperationError(error: unknown): unknown { +export function mapSecurityDismissOperationError(error: unknown) { return mapOperationInProgress(error, SECURITY_DISMISS_IN_PROGRESS_COPY); } diff --git a/apps/mobile/src/lib/hooks/use-security-agent.ts b/apps/mobile/src/lib/hooks/use-security-agent.ts index 59c0592785..b92daaf3d1 100644 --- a/apps/mobile/src/lib/hooks/use-security-agent.ts +++ b/apps/mobile/src/lib/hooks/use-security-agent.ts @@ -139,13 +139,7 @@ function useSecurityAgentOrgRoleQuery(scope: string) { // Discriminated capability state for consumers (e.g. audit-report access) // that must distinguish "still loading"/"failed to load" from "resolved: // no access" instead of treating an undefined role as permission-denied. -export function useSecurityAgentCapability(scope: string): { - canManage: boolean; - isLoading: boolean; - isError: boolean; - isFetching: boolean; - refetch: () => unknown; -} { +export function useSecurityAgentCapability(scope: string) { const { role, isLoading, isError, isFetching, refetch } = useSecurityAgentOrgRoleQuery(scope); return { canManage: canManageSecurityAgent(scope, role), @@ -161,13 +155,7 @@ export function useSecurityAgentCapability(scope: string): { // `isLoading`/`isError` are exposed alongside the counts so callers can tell // "still loading" and "failed to load" apart from "loaded: capacity full" — // all three previously collapsed into the same undefined counts. -export function useSecurityAnalysisCapacity(scope: string): { - runningCount: number | undefined; - concurrencyLimit: number | undefined; - isLoading: boolean; - isError: boolean; - refetch: () => unknown; -} { +export function useSecurityAnalysisCapacity(scope: string) { const trpc = useTRPC(); const capacityInput = { status: 'open' as const, limit: 1, offset: 0 }; const personal = useQuery({ diff --git a/apps/mobile/src/lib/hooks/use-security-dismiss-draft.ts b/apps/mobile/src/lib/hooks/use-security-dismiss-draft.ts index 023a2de2fb..35db6b4880 100644 --- a/apps/mobile/src/lib/hooks/use-security-dismiss-draft.ts +++ b/apps/mobile/src/lib/hooks/use-security-dismiss-draft.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react'; +import { z } from 'zod'; import { useCurrentUserId } from '@/lib/hooks/use-current-user-id'; import { @@ -19,25 +20,18 @@ import { useDraftFlushOnBackground } from '@/lib/persist/use-draft-flush'; * mutation failed before the server accepted a command id, so no command * observer will ever reconcile it — the retry card must. */ -export type SecurityDismissDraft = { - reason: string; - comment: string; - lastError: string | null; - retryable: boolean | null; -}; +const SecurityDismissDraftSchema = z.object({ + reason: z.string(), + comment: z.string(), + lastError: z.string().nullable(), + retryable: z.boolean().nullable(), +}); + +export type SecurityDismissDraft = z.infer; /** Runtime shape guard for a persisted dismiss draft, passed to `loadDraft`. */ export function isSecurityDismissDraft(value: unknown): value is SecurityDismissDraft { - if (value === null || typeof value !== 'object') { - return false; - } - const record = value as Record; - return ( - typeof record.reason === 'string' && - typeof record.comment === 'string' && - (record.lastError === null || typeof record.lastError === 'string') && - (record.retryable === null || typeof record.retryable === 'boolean') - ); + return SecurityDismissDraftSchema.safeParse(value).success; } type SecurityDismissDraftController = { @@ -160,11 +154,7 @@ export async function listSecurityDismissFailures( * empty list until the read settles, so the dashboard never flashes a card * before the store answers. */ -export function useSecurityDismissFailures(scope: string): { - failures: SecurityDismissFailure[]; - clear: (findingId: string) => void; - refresh: () => void; -} { +export function useSecurityDismissFailures(scope: string) { const { userId } = useCurrentUserId(); const [failures, setFailures] = useState([]); const generationRef = useRef(0); diff --git a/apps/mobile/src/lib/kilo-pass/store-products-state.ts b/apps/mobile/src/lib/kilo-pass/store-products-state.ts index a01e3aeef1..61efed2280 100644 --- a/apps/mobile/src/lib/kilo-pass/store-products-state.ts +++ b/apps/mobile/src/lib/kilo-pass/store-products-state.ts @@ -5,11 +5,7 @@ export function getStoreKiloPassProductsState(params: { isError: boolean; storeErrorMessage: string | null; queryErrorMessage: string | null; -}): { - products: readonly AppStoreKiloPassProduct[]; - isError: boolean; - errorMessage: string | null; -} { +}) { const isError = params.storeErrorMessage !== null || params.isError; return { diff --git a/apps/mobile/src/lib/kilo-pass/use-store-kilo-pass-purchase.ts b/apps/mobile/src/lib/kilo-pass/use-store-kilo-pass-purchase.ts index be146cdd0f..b3c9875268 100644 --- a/apps/mobile/src/lib/kilo-pass/use-store-kilo-pass-purchase.ts +++ b/apps/mobile/src/lib/kilo-pass/use-store-kilo-pass-purchase.ts @@ -31,9 +31,15 @@ const PURCHASE_ERROR_TOAST_DEDUPE_MS = 1500; const RESTORE_PURCHASES_ERROR_MESSAGE = 'Failed to restore purchases. Try again.'; export type AppStoreKiloPassPurchaseActionsDeps = { + // The real implementations (expo-iap's mutateAsync, the tRPC mutation) each + // resolve to their own concrete result; this module never reads it, only + // awaits it, so `Promise` can't stand in here — `Promise` requires + // its real `X` to be assignable to `void`, which none of the callers' true + // return types are. requestPurchase: (params: { request: { apple: { appAccountToken: string; sku: string } }; type: 'subs'; + // oxlint-disable-next-line anti-slop/no-unknown-returns -- see comment above: the resolved value is intentionally unused and varies per real implementation }) => Promise; getAvailablePurchases: () => Promise; restorePurchases: () => Promise; @@ -42,6 +48,7 @@ export type AppStoreKiloPassPurchaseActionsDeps = { platform: 'ios'; storefront: 'app_store'; product: 'kilo_pass'; + // oxlint-disable-next-line anti-slop/no-unknown-returns -- see comment above requestPurchase: the resolved value is intentionally unused and varies per real implementation }) => Promise; finishTransaction: (params: { purchase: Purchase; isConsumable: false }) => Promise; enabledAppleProductIds: readonly string[]; diff --git a/apps/mobile/src/lib/model-id.ts b/apps/mobile/src/lib/model-id.ts index 6483d3f8ba..d3916f40ca 100644 --- a/apps/mobile/src/lib/model-id.ts +++ b/apps/mobile/src/lib/model-id.ts @@ -11,11 +11,16 @@ export function addModelPrefix(modelId: string): string { return `${MODEL_PREFIX}${modelId}`; } -const AUTO_MODEL_LABELS: Record = { +const AUTO_MODEL_LABELS = { 'kilo-auto/frontier': 'Frontier', 'kilo-auto/balanced': 'Balanced', -}; +} satisfies Record; + +/** Looks up a possibly-unknown key in a literal dictionary without widening its type. */ +function lookup(dictionary: Readonly>, key: string): V | undefined { + return (dictionary as Readonly>)[key]; +} export function formatModelName(strippedId: string): string { - return AUTO_MODEL_LABELS[strippedId] ?? strippedId; + return lookup(AUTO_MODEL_LABELS, strippedId) ?? strippedId; } diff --git a/apps/mobile/src/lib/notification-path.ts b/apps/mobile/src/lib/notification-path.ts index d025a1fff8..34351b8955 100644 --- a/apps/mobile/src/lib/notification-path.ts +++ b/apps/mobile/src/lib/notification-path.ts @@ -9,7 +9,7 @@ type NotificationHref = Extract; /** Widen string paths built from helpers into the typed-route string union. */ function toNotificationHref(path: string): NotificationHref { - return path as unknown as NotificationHref; + return path; } export function notificationPathForData(data: PushData): NotificationHref { diff --git a/apps/mobile/src/lib/onboarding/shapes.ts b/apps/mobile/src/lib/onboarding/shapes.ts index 70b4e20710..bbb24c0eeb 100644 --- a/apps/mobile/src/lib/onboarding/shapes.ts +++ b/apps/mobile/src/lib/onboarding/shapes.ts @@ -21,7 +21,7 @@ export type BotIdentity = { export type OnboardingStep = 'identity' | 'channels' | 'provisioning' | 'done'; -export function execPresetToConfig(preset: ExecPreset): { security: string; ask: string } { +export function execPresetToConfig(preset: ExecPreset) { if (preset === 'never-ask') { return { security: 'full', ask: 'off' }; } diff --git a/apps/mobile/src/lib/operation-key.ts b/apps/mobile/src/lib/operation-key.ts index cf10cf169a..741975aab7 100644 --- a/apps/mobile/src/lib/operation-key.ts +++ b/apps/mobile/src/lib/operation-key.ts @@ -25,10 +25,7 @@ export function isOperationInProgress(error: unknown): boolean { * changes, so an edited intent never replays the previous one's ledger result. * `rotateKey()` ends the intent after a success or a terminal failure. */ -export function useHoistedOperationKey(): { - getKey: (fingerprint: string) => string; - rotateKey: () => void; -} { +export function useHoistedOperationKey() { const keyRef = useRef<{ fingerprint: string; key: string } | null>(null); const getKey = (fingerprint: string) => { if (keyRef.current !== null && keyRef.current.fingerprint !== fingerprint) { diff --git a/apps/mobile/src/lib/org-deep-link.ts b/apps/mobile/src/lib/org-deep-link.ts index fc2948edff..a9758ed3f9 100644 --- a/apps/mobile/src/lib/org-deep-link.ts +++ b/apps/mobile/src/lib/org-deep-link.ts @@ -1,3 +1,14 @@ +export type OrgDeepLinkResolution = { + effectiveOrganizationId: string | null; + validatedOrg: T | undefined; + /** Key for data queries; `null` disables them. */ + queryOrganizationId: string | null; + /** Param present and resolves to a membership — caller should persist. */ + shouldPersistOverride: boolean; + /** Param present and the org list has not settled yet. */ + isResolving: boolean; +}; + /** * Pure reconcile for organization deep-links (e.g. low-balance push → * credit-activity with `?org=`). When an explicit org param is present it is @@ -9,16 +20,7 @@ export function reconcileOrgDeepLink(args: contextOrganizationId: string | null; /** `undefined` while the organizations.list query is still unsettled. */ orgs: readonly T[] | undefined; -}): { - effectiveOrganizationId: string | null; - validatedOrg: T | undefined; - /** Key for data queries; `null` disables them. */ - queryOrganizationId: string | null; - /** Param present and resolves to a membership — caller should persist. */ - shouldPersistOverride: boolean; - /** Param present and the org list has not settled yet. */ - isResolving: boolean; -} { +}): OrgDeepLinkResolution { const { orgParam, contextOrganizationId, orgs } = args; if (orgParam == null || orgParam === '') { diff --git a/apps/mobile/src/lib/persist/drafts.ts b/apps/mobile/src/lib/persist/drafts.ts index f912b90d59..0700e333e3 100644 --- a/apps/mobile/src/lib/persist/drafts.ts +++ b/apps/mobile/src/lib/persist/drafts.ts @@ -1,4 +1,5 @@ import * as Sentry from '@sentry/react-native'; +import * as z from 'zod'; import { currentAuthEpoch, isCurrentAuthEpoch } from '@/lib/auth/auth-epoch'; import { chainSave } from '@/lib/hooks/save-chain'; import { utf8ByteLength } from '@/lib/utf8-utils'; @@ -46,9 +47,11 @@ export function draftScope(userId: string): string { return `${DRAFT_SCOPE_PREFIX}${userId}`; } +const stringDraftSchema = z.string(); + /** Runtime shape guard for a composer text draft (a JSON string). */ export function isStringDraft(value: unknown): value is string { - return typeof value === 'string'; + return stringDraftSchema.safeParse(value).success; } /** Shape validator for one loaded draft value, supplied by the caller. */ diff --git a/apps/mobile/src/lib/persist/encrypted-kv.ts b/apps/mobile/src/lib/persist/encrypted-kv.ts index 247483688b..74e0182606 100644 --- a/apps/mobile/src/lib/persist/encrypted-kv.ts +++ b/apps/mobile/src/lib/persist/encrypted-kv.ts @@ -70,6 +70,7 @@ export function validateItemKey(scope: string, k: string): void { } function validateValue(v: string): void { + // oxlint-disable-next-line anti-slop/no-runtime-typeof -- runtime guard against a non-string call from an untyped JS caller if (typeof v !== 'string') { throw new TypeError('encrypted-kv: value must be a string'); } diff --git a/apps/mobile/src/lib/persist/mutation-outbox.ts b/apps/mobile/src/lib/persist/mutation-outbox.ts index 4feab12df6..87b76c0131 100644 --- a/apps/mobile/src/lib/persist/mutation-outbox.ts +++ b/apps/mobile/src/lib/persist/mutation-outbox.ts @@ -1,4 +1,5 @@ import * as Sentry from '@sentry/react-native'; +import * as z from 'zod'; import { currentAuthEpoch, isCurrentAuthEpoch } from '@/lib/auth/auth-epoch'; import { chainSave } from '@/lib/hooks/save-chain'; import * as encryptedKv from '@/lib/persist/encrypted-kv'; @@ -50,18 +51,17 @@ export function outboxScope(userId: string): string { return `${OUTBOX_SCOPE_PREFIX}${userId}`; } +const outboxRowSchema = z.object({ + taxonomy: z.enum(['safe-retry', 'reconcile-first']), + operationKey: z.string(), + fingerprint: z.string(), + scope: z.string().optional(), + input: z.unknown(), +}); + /** Runtime shape guard for a stored outbox row. */ export function isOutboxRow(value: unknown): value is OutboxRow { - if (value === null || typeof value !== 'object') { - return false; - } - const record = value as Record; - return ( - (record.taxonomy === 'safe-retry' || record.taxonomy === 'reconcile-first') && - typeof record.operationKey === 'string' && - typeof record.fingerprint === 'string' && - (record.scope === undefined || typeof record.scope === 'string') - ); + return outboxRowSchema.safeParse(value).success; } function fullKey(userId: string, fingerprint: string): string { diff --git a/apps/mobile/src/lib/persist/read-cache.ts b/apps/mobile/src/lib/persist/read-cache.ts index 3252a79b5c..7ab25923ed 100644 --- a/apps/mobile/src/lib/persist/read-cache.ts +++ b/apps/mobile/src/lib/persist/read-cache.ts @@ -6,6 +6,7 @@ import { type Persister, persistQueryClientRestore, } from '@tanstack/react-query-persist-client'; +import { z } from 'zod'; import { buildAgentSessionListInput } from '@/lib/agent-session-input'; import { currentAuthEpoch, isCurrentAuthEpoch } from '@/lib/auth/auth-epoch'; @@ -59,18 +60,26 @@ const GET_ME_QUERY_KEY: readonly unknown[] = [['user', 'getMe'], { type: 'query' type QueryCacheReader = { getQueryData?: QueryClient['getQueryData'] }; +const cachedUserSchema = z.object({ id: z.string().min(1) }); + /** Authoritative user id from the cached `user.getMe` result, or null. */ export function readCachedUserId(queryClient: QueryCacheReader): string | null { - const data = queryClient.getQueryData?.<{ id?: unknown } | undefined>(GET_ME_QUERY_KEY); - return typeof data?.id === 'string' && data.id.length > 0 ? data.id : null; + const data = queryClient.getQueryData?.(GET_ME_QUERY_KEY); + const parsed = cachedUserSchema.safeParse(data); + return parsed.success ? parsed.data.id : null; } -/** The `input` field of a tRPC query key's meta segment, or undefined. */ -function metaInput(meta: unknown): unknown { - if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) { +const queryKeyMetaSchema = z.object({ input: z.unknown().optional() }); +const plainObjectSchema = z.record(z.string(), z.unknown()); + +/** The `input` field of a tRPC query key's meta segment, when it is a plain object. */ +function metaInput(meta: unknown): Record | undefined { + const parsedMeta = queryKeyMetaSchema.safeParse(meta); + if (!parsedMeta.success) { return undefined; } - return (meta as { input?: unknown }).input; + const parsedInput = plainObjectSchema.safeParse(parsedMeta.data.input); + return parsedInput.success ? parsedInput.data : undefined; } type AllowedProcedure = { @@ -104,10 +113,10 @@ const ALLOWED_PROCEDURES: readonly AllowedProcedure[] = [ // snapshot of this procedure is ever written. isAllowedInput: meta => { const input = metaInput(meta); - if (input == null || typeof input !== 'object') { + if (input === undefined) { return true; } - return (input as { organizationId?: unknown }).organizationId == null; + return input.organizationId == null; }, }, { @@ -122,20 +131,19 @@ const ALLOWED_PROCEDURES: readonly AllowedProcedure[] = [ // enforced separately in {@link shouldPersistReadCacheQuery}. isAllowedInput: meta => { const input = metaInput(meta); - if (typeof input !== 'object' || input === null || Array.isArray(input)) { + if (input === undefined) { return false; } - const candidate = input as Record; - if (Object.keys(candidate).length !== DEFAULT_SESSION_LIST_KEY_COUNT) { + if (Object.keys(input).length !== DEFAULT_SESSION_LIST_KEY_COUNT) { return false; } return ( - candidate.limit === DEFAULT_SESSION_LIST_INPUT.limit && - candidate.orderBy === DEFAULT_SESSION_LIST_INPUT.orderBy && - candidate.includeChildren === DEFAULT_SESSION_LIST_INPUT.includeChildren && - candidate.createdOnPlatform === DEFAULT_SESSION_LIST_INPUT.createdOnPlatform && - candidate.gitUrl === DEFAULT_SESSION_LIST_INPUT.gitUrl && - (candidate.organizationId === null || candidate.organizationId === undefined) + input.limit === DEFAULT_SESSION_LIST_INPUT.limit && + input.orderBy === DEFAULT_SESSION_LIST_INPUT.orderBy && + input.includeChildren === DEFAULT_SESSION_LIST_INPUT.includeChildren && + input.createdOnPlatform === DEFAULT_SESSION_LIST_INPUT.createdOnPlatform && + input.gitUrl === DEFAULT_SESSION_LIST_INPUT.gitUrl && + (input.organizationId === null || input.organizationId === undefined) ); }, }, diff --git a/apps/mobile/src/lib/persist/test-fixtures.ts b/apps/mobile/src/lib/persist/test-fixtures.ts index 1e59a65037..8442594a12 100644 --- a/apps/mobile/src/lib/persist/test-fixtures.ts +++ b/apps/mobile/src/lib/persist/test-fixtures.ts @@ -1,3 +1,4 @@ +import { hashKey } from '@tanstack/react-query'; import { type PersistedClient } from '@tanstack/react-query-persist-client'; /** @@ -21,10 +22,24 @@ export function makePersistedClient(data: unknown, buster = ''): PersistedClient mutations: [], queries: [ { + queryHash: hashKey(GET_ME_QUERY_KEY), queryKey: GET_ME_QUERY_KEY, - state: { status: 'success', data, dataUpdatedAt: Date.now() }, + state: { + data, + dataUpdateCount: 0, + dataUpdatedAt: Date.now(), + error: null, + errorUpdateCount: 0, + errorUpdatedAt: 0, + fetchFailureCount: 0, + fetchFailureReason: null, + fetchMeta: null, + isInvalidated: false, + status: 'success', + fetchStatus: 'idle', + }, }, ], }, - } as unknown as PersistedClient; + }; } diff --git a/apps/mobile/src/lib/persist/use-draft-load.ts b/apps/mobile/src/lib/persist/use-draft-load.ts index 5052b20c2c..e5db522982 100644 --- a/apps/mobile/src/lib/persist/use-draft-load.ts +++ b/apps/mobile/src/lib/persist/use-draft-load.ts @@ -96,9 +96,7 @@ type UseRemoteSpawnDraftCleanupInput = { * user who abandons the screen after a failed attempt loses the prompt, the * same as if the attempt had succeeded. */ -export function useRemoteSpawnDraftCleanup({ userId }: UseRemoteSpawnDraftCleanupInput): { - markRemoteSpawnAttempted: () => void; -} { +export function useRemoteSpawnDraftCleanup({ userId }: UseRemoteSpawnDraftCleanupInput) { const spawnAttemptedRef = useRef(false); const markRemoteSpawnAttempted = useCallback(() => { spawnAttemptedRef.current = true; diff --git a/apps/mobile/src/lib/persist/use-mutation-outbox.ts b/apps/mobile/src/lib/persist/use-mutation-outbox.ts index 398ee29453..99fa35a919 100644 --- a/apps/mobile/src/lib/persist/use-mutation-outbox.ts +++ b/apps/mobile/src/lib/persist/use-mutation-outbox.ts @@ -46,16 +46,7 @@ export type OutboxRowInput = { * - `needsReconcile` surfaces the `reconcile-first` rows that must show a card * instead of auto-POSTing. `remove` and `refresh` keep that list current. */ -export function useMutationOutbox(): { - getStoredOperationKey: (fingerprint: string) => string | null; - writeSafeRetry: (row: OutboxRowInput) => Promise; - writeReconcileFirst: (row: OutboxRowInput & { scope: string }) => Promise; - remove: (fingerprint: string) => Promise; - needsReconcile: OutboxRow[]; - loaded: boolean; - whenLoaded: () => Promise; - refresh: () => void; -} { +export function useMutationOutbox() { const { userId, isLoading } = useCurrentUserId(); const [rows, setRows] = useState([]); // Latest rows, readable without a stale closure: a submit that awaits the diff --git a/apps/mobile/src/lib/pr-review/diff/collapse-on-mark-viewed.ts b/apps/mobile/src/lib/pr-review/diff/collapse-on-mark-viewed.ts index fdf377c7a7..bf43c7f3a9 100644 --- a/apps/mobile/src/lib/pr-review/diff/collapse-on-mark-viewed.ts +++ b/apps/mobile/src/lib/pr-review/diff/collapse-on-mark-viewed.ts @@ -10,7 +10,7 @@ export function collapseOnMarkViewed( expanded: Record, path: string, currentlyViewed: boolean -): Record { +) { if (currentlyViewed) { return expanded; } diff --git a/apps/mobile/src/lib/pr-review/diff/highlight.ts b/apps/mobile/src/lib/pr-review/diff/highlight.ts index 21beb47fd4..b3434a4021 100644 --- a/apps/mobile/src/lib/pr-review/diff/highlight.ts +++ b/apps/mobile/src/lib/pr-review/diff/highlight.ts @@ -43,52 +43,52 @@ function getLowlight(): LowlightInstance { // a single text node, so callers get a valid result back without // throwing). Filenames are lower-cased and the last extension is // matched so `foo.test.ts` resolves to `typescript`. -const EXTENSION_LANGUAGE_MAP: Record = { - ts: 'typescript', - tsx: 'typescript', - mts: 'typescript', - cts: 'typescript', - js: 'javascript', - jsx: 'javascript', - mjs: 'javascript', - cjs: 'javascript', - py: 'python', - go: 'go', - rs: 'rust', - java: 'java', - rb: 'ruby', - json: 'json', - jsonc: 'json', - md: 'markdown', - mdx: 'markdown', - sh: 'bash', - bash: 'bash', - zsh: 'bash', - css: 'css', - scss: 'css', - html: 'xml', - htm: 'xml', - xml: 'xml', - vue: 'xml', - svelte: 'xml', - yml: 'yaml', - yaml: 'yaml', - toml: 'ini', - ini: 'ini', - c: 'c', - h: 'c', - cpp: 'cpp', - cxx: 'cpp', - cc: 'cpp', - hpp: 'cpp', - cs: 'csharp', - php: 'php', - swift: 'swift', - kt: 'kotlin', - scala: 'scala', - sql: 'sql', - graphql: 'graphql', -}; +const EXTENSION_LANGUAGE_MAP: ReadonlyMap = new Map([ + ['ts', 'typescript'], + ['tsx', 'typescript'], + ['mts', 'typescript'], + ['cts', 'typescript'], + ['js', 'javascript'], + ['jsx', 'javascript'], + ['mjs', 'javascript'], + ['cjs', 'javascript'], + ['py', 'python'], + ['go', 'go'], + ['rs', 'rust'], + ['java', 'java'], + ['rb', 'ruby'], + ['json', 'json'], + ['jsonc', 'json'], + ['md', 'markdown'], + ['mdx', 'markdown'], + ['sh', 'bash'], + ['bash', 'bash'], + ['zsh', 'bash'], + ['css', 'css'], + ['scss', 'css'], + ['html', 'xml'], + ['htm', 'xml'], + ['xml', 'xml'], + ['vue', 'xml'], + ['svelte', 'xml'], + ['yml', 'yaml'], + ['yaml', 'yaml'], + ['toml', 'ini'], + ['ini', 'ini'], + ['c', 'c'], + ['h', 'c'], + ['cpp', 'cpp'], + ['cxx', 'cpp'], + ['cc', 'cpp'], + ['hpp', 'cpp'], + ['cs', 'csharp'], + ['php', 'php'], + ['swift', 'swift'], + ['kt', 'kotlin'], + ['scala', 'scala'], + ['sql', 'sql'], + ['graphql', 'graphql'], +]); export function languageForPath(path: string | null | undefined): string | null { if (!path) { @@ -101,7 +101,7 @@ export function languageForPath(path: string | null | undefined): string | null return null; } const ext = basename.slice(dot + 1).toLowerCase(); - return EXTENSION_LANGUAGE_MAP[ext] ?? null; + return EXTENSION_LANGUAGE_MAP.get(ext) ?? null; } export type HighlightToken = { @@ -116,51 +116,51 @@ export type HighlightToken = { // Map a highlight.js class name to our smaller palette. We don't ship // every hljs sub-language — only the ones that show up in the // reviewer surface often enough to be worth coloring. -const HLJS_CLASS_PALETTE: Record = { +const HLJS_CLASS_PALETTE: ReadonlyMap = new Map([ // Keywords / control flow - keyword: 'keyword', - built_in: 'builtin', - 'builtin-name': 'builtin', - literal: 'literal', - symbol: 'literal', - boolean: 'literal', - number: 'number', - 'function-variable': 'function', - 'class-name': 'type', - type: 'type', - 'title.function': 'function', - 'title.class': 'type', - function: 'function', - attr: 'attribute', - attribute: 'attribute', - variable: 'variable', - template_variable: 'variable', - params: 'variable', - property: 'property', - tag: 'tag', - selector: 'selector', - selector_tag: 'selector', - selector_class: 'selector', - selector_id: 'selector', - selector_pseudo: 'selector', + ['keyword', 'keyword'], + ['built_in', 'builtin'], + ['builtin-name', 'builtin'], + ['literal', 'literal'], + ['symbol', 'literal'], + ['boolean', 'literal'], + ['number', 'number'], + ['function-variable', 'function'], + ['class-name', 'type'], + ['type', 'type'], + ['title.function', 'function'], + ['title.class', 'type'], + ['function', 'function'], + ['attr', 'attribute'], + ['attribute', 'attribute'], + ['variable', 'variable'], + ['template_variable', 'variable'], + ['params', 'variable'], + ['property', 'property'], + ['tag', 'tag'], + ['selector', 'selector'], + ['selector_tag', 'selector'], + ['selector_class', 'selector'], + ['selector_id', 'selector'], + ['selector_pseudo', 'selector'], // Literals - string: 'string', - regexp: 'string', - meta_string: 'string', - subst: 'string', - char: 'string', + ['string', 'string'], + ['regexp', 'string'], + ['meta_string', 'string'], + ['subst', 'string'], + ['char', 'string'], // Comments / doc - comment: 'comment', - doctag: 'comment', - quote: 'string', + ['comment', 'comment'], + ['doctag', 'comment'], + ['quote', 'string'], // Operators / punctuation - operator: 'operator', - punctuation: 'operator', + ['operator', 'operator'], + ['punctuation', 'operator'], // Misc - meta: 'meta', - addition: 'add', - deletion: 'del', -}; + ['meta', 'meta'], + ['addition', 'add'], + ['deletion', 'del'], +]); // Cap the per-line cache at 5,000 entries — large diffs can have many // repeated short lines (empty context rows, import lines) but a hard @@ -170,7 +170,7 @@ const highlightCache = new Map(); function tokenFromHljsClassNames(classNames: readonly string[]): string | null { for (const name of classNames) { - const palette = HLJS_CLASS_PALETTE[name]; + const palette = HLJS_CLASS_PALETTE.get(name); if (palette) { return palette; } @@ -178,7 +178,7 @@ function tokenFromHljsClassNames(classNames: readonly string[]): string | null { // the prefix and try again. if (name.startsWith('hljs-')) { const stripped = name.slice('hljs-'.length); - const palette2 = HLJS_CLASS_PALETTE[stripped]; + const palette2 = HLJS_CLASS_PALETTE.get(stripped); if (palette2) { return palette2; } @@ -282,7 +282,12 @@ function runHighlight(text: string, language: string): HighlightToken[] { const tokens: HighlightToken[] = []; // The root node has a single span child whose children carry the // real classes. We flatten through `flattenHast` to get one - // token per contiguous text/class run. + // token per contiguous text/class run. `hast`'s `Properties` index + // signature is wider than our local `HastNode.className` (it allows + // numbers/booleans too), so a direct assertion doesn't type-check — + // the runtime shape from `lowlight` is deterministic and always + // matches our narrower local walker type. + // oxlint-disable-next-line anti-slop/no-chained-type-assertions -- lowlight's Root always matches this shape; only its Properties index signature is wider than our local HastNode flattenHast(tree as unknown as HastNode, tokens); if (tokens.length === 0 && text.length > 0) { return [{ text, className: null }]; diff --git a/apps/mobile/src/lib/pr-review/diff/pr-diff-list-items.ts b/apps/mobile/src/lib/pr-review/diff/pr-diff-list-items.ts index e2cda2b042..45e5cd883e 100644 --- a/apps/mobile/src/lib/pr-review/diff/pr-diff-list-items.ts +++ b/apps/mobile/src/lib/pr-review/diff/pr-diff-list-items.ts @@ -191,7 +191,7 @@ export function addContextLoadState(args: { filePath: string; gapIndex: number; status: 'loading' | 'error' | 'unavailable'; -}): Record> { +}) { const previous = args.state[args.filePath]; const previousState = previous?.[args.gapIndex]; if (args.status === 'unavailable') { @@ -201,7 +201,7 @@ export function addContextLoadState(args: { ...previous, [args.gapIndex]: { status: 'unavailable' }, }, - }; + } satisfies Record>; } const existingLines = previousState?.status === 'loading' || @@ -219,7 +219,7 @@ export function addContextLoadState(args: { ...previous, [args.gapIndex]: nextStatus, }, - }; + } satisfies Record>; } export function getCumulativeLines(state: ExpandSeparatorState | undefined): string[] { @@ -242,7 +242,7 @@ export function setContextLines(args: { gapIndex: number; lines: string[]; totalLines?: number; -}): Record> { +}) { const previous = args.state[args.filePath]; const previousState = previous?.[args.gapIndex]; const existingLines = @@ -263,36 +263,7 @@ export function setContextLines(args: { ...previous, [args.gapIndex]: nextStatus, }, - }; -} - -export function readTrpcErrorCode(error: unknown): string | undefined { - if (!error || typeof error !== 'object') { - return undefined; - } - const record = error as Record; - const data = record.data; - if (data && typeof data === 'object') { - const code = (data as Record).code; - if (typeof code === 'string') { - return code; - } - } - const shape = record.shape; - if (shape && typeof shape === 'object') { - const shapeData = (shape as Record).data; - if (shapeData && typeof shapeData === 'object') { - const code = (shapeData as Record).code; - if (typeof code === 'string') { - return code; - } - } - } - const top = record.code; - if (typeof top === 'string') { - return top; - } - return undefined; + } satisfies Record>; } export type BuildItemsArgs = { diff --git a/apps/mobile/src/lib/pr-review/diff/syntax-colors.ts b/apps/mobile/src/lib/pr-review/diff/syntax-colors.ts index 85ed7547f7..5077d96e40 100644 --- a/apps/mobile/src/lib/pr-review/diff/syntax-colors.ts +++ b/apps/mobile/src/lib/pr-review/diff/syntax-colors.ts @@ -10,7 +10,7 @@ // (plain background, good-tile, danger-tile). `syntax-colors.test.ts` // asserts every light value on those composites, so these stay in lockstep // with `src/global.css`'s `--good` / `--destructive` hue families. -export const TOKEN_DARK_LIGHT: Record = { +export const TOKEN_DARK_LIGHT = { keyword: { light: '#7B2CBF', dark: '#D8B4FE' }, builtin: { light: '#1462DD', dark: '#79B8FF' }, literal: { light: '#7B2CBF', dark: '#D8B4FE' }, @@ -28,16 +28,21 @@ export const TOKEN_DARK_LIGHT: Record = meta: { light: '#6D6860', dark: '#8A8680' }, add: { light: '#24784A', dark: '#5FCB8E' }, del: { light: '#B0483A', dark: '#F28B7A' }, -}; +} satisfies Record; export const DEFAULT_TOKEN_COLOR = { light: '#14130F', dark: '#F2F0EB' }; export const MUTED_COLOR = { light: '#6D6860', dark: '#8A8680' }; +/** Looks up a possibly-unknown key in a literal dictionary without widening its type. */ +function lookup(dictionary: Readonly>, key: string): V | undefined { + return (dictionary as Readonly>)[key]; +} + export function tokenColorFor(className: string | null, isDark: boolean): string { if (!className) { return isDark ? DEFAULT_TOKEN_COLOR.dark : DEFAULT_TOKEN_COLOR.light; } - const palette = TOKEN_DARK_LIGHT[className]; + const palette = lookup(TOKEN_DARK_LIGHT, className); if (!palette) { return isDark ? DEFAULT_TOKEN_COLOR.dark : DEFAULT_TOKEN_COLOR.light; } diff --git a/apps/mobile/src/lib/pr-review/diff/use-pr-diff-context-loader.ts b/apps/mobile/src/lib/pr-review/diff/use-pr-diff-context-loader.ts index c7eb58a857..beb86cce83 100644 --- a/apps/mobile/src/lib/pr-review/diff/use-pr-diff-context-loader.ts +++ b/apps/mobile/src/lib/pr-review/diff/use-pr-diff-context-loader.ts @@ -8,10 +8,10 @@ import { addContextLoadState, type ExpandSeparatorState, type ListItem, - readTrpcErrorCode, setContextLines, } from '@/lib/pr-review/diff/pr-diff-list-items'; import { trpcClient } from '@/lib/trpc'; +import { readTrpcErrorField } from '@/lib/trpc-error'; type UsePrDiffContextLoaderResult = { expandedContext: Record>; @@ -87,7 +87,7 @@ export function usePrDiffContextLoader(args: { }) ); } catch (error: unknown) { - const code = readTrpcErrorCode(error); + const code = readTrpcErrorField(error, 'code'); const status = code === 'NOT_FOUND' ? 'unavailable' : 'error'; setExpandedContext(prev => addContextLoadState({ diff --git a/apps/mobile/src/lib/pr-review/discussion/reaction-pills.ts b/apps/mobile/src/lib/pr-review/discussion/reaction-pills.ts index 3a95a0c85d..4dae9b1234 100644 --- a/apps/mobile/src/lib/pr-review/discussion/reaction-pills.ts +++ b/apps/mobile/src/lib/pr-review/discussion/reaction-pills.ts @@ -9,7 +9,7 @@ import { type ReviewReactionContent, } from '@/lib/pr-review/discussion/review-discussion-types'; -export const REACTION_EMOJI: Record = { +export const REACTION_EMOJI = { THUMBS_UP: '👍', THUMBS_DOWN: '👎', LAUGH: '😄', @@ -18,9 +18,9 @@ export const REACTION_EMOJI: Record = { HEART: '❤️', ROCKET: '🚀', EYES: '👀', -}; +} satisfies Record; -export const REACTION_LABEL: Record = { +export const REACTION_LABEL = { THUMBS_UP: 'Thumbs up', THUMBS_DOWN: 'Thumbs down', LAUGH: 'Laugh', @@ -29,7 +29,7 @@ export const REACTION_LABEL: Record = { HEART: 'Heart', ROCKET: 'Rocket', EYES: 'Eyes', -}; +} satisfies Record; const KNOWN_CONTENTS = new Set(REVIEW_REACTION_CONTENTS); diff --git a/apps/mobile/src/lib/pr-review/discussion/review-discussion-types.ts b/apps/mobile/src/lib/pr-review/discussion/review-discussion-types.ts index 09b7168cef..30c505f458 100644 --- a/apps/mobile/src/lib/pr-review/discussion/review-discussion-types.ts +++ b/apps/mobile/src/lib/pr-review/discussion/review-discussion-types.ts @@ -122,11 +122,7 @@ export function selectThreadAnchorLabel(thread: ReviewThread): string { * Returns the same `isResolved` value with the `isOutdated` label * surfaced for the badges in the thread header. Purely presentational. */ -export function selectThreadBadges(thread: ReviewThread): { - readonly resolved: boolean; - readonly outdated: boolean; - readonly fileLevel: boolean; -} { +export function selectThreadBadges(thread: ReviewThread) { return { resolved: thread.isResolved, outdated: thread.isOutdated, diff --git a/apps/mobile/src/lib/pr-review/discussion/thread-expansion.ts b/apps/mobile/src/lib/pr-review/discussion/thread-expansion.ts index 8492819db0..2e536b9759 100644 --- a/apps/mobile/src/lib/pr-review/discussion/thread-expansion.ts +++ b/apps/mobile/src/lib/pr-review/discussion/thread-expansion.ts @@ -40,16 +40,13 @@ export function toggleThreadExpanded( state: Record, threadId: string, isResolved: boolean -): Record { +) { const current = expandedForThread(state, threadId, isResolved); return { ...state, [threadId]: !current }; } /** Force expand (deferred settle path). Same reference if already true. */ -export function expandThread( - state: Record, - threadId: string -): Record { +export function expandThread(state: Record, threadId: string) { if (state[threadId] === true) { return state; } diff --git a/apps/mobile/src/lib/pr-review/merge/merge-blocked-reasons.ts b/apps/mobile/src/lib/pr-review/merge/merge-blocked-reasons.ts index 7a3066255f..7c8542aa37 100644 --- a/apps/mobile/src/lib/pr-review/merge/merge-blocked-reasons.ts +++ b/apps/mobile/src/lib/pr-review/merge/merge-blocked-reasons.ts @@ -256,17 +256,17 @@ export function getAllowedMergeMethods(repo: PrOverviewRepoSettings): AllowedMer return methods; } -export const PR_MERGE_LABELS: Record = { +export const PR_MERGE_LABELS = { merge: 'Create a merge commit', squash: 'Squash and merge', rebase: 'Rebase and merge', -}; +} satisfies Record; -export const PR_MERGE_DESCRIPTIONS: Record = { +export const PR_MERGE_DESCRIPTIONS = { merge: 'Combine all commits from this branch into the base branch with a merge commit.', squash: 'Combine all commits from this branch into a single commit on the base branch.', rebase: 'Replay all commits from this branch onto the base branch without a merge commit.', -}; +} satisfies Record; /** The default method the picker selects on first open. */ export function defaultMergeMethodFor(repo: PrOverviewRepoSettings): AllowedMergeMethod { diff --git a/apps/mobile/src/lib/pr-review/merge/merge-result-gate.ts b/apps/mobile/src/lib/pr-review/merge/merge-result-gate.ts index 91d42e97c0..1557931bad 100644 --- a/apps/mobile/src/lib/pr-review/merge/merge-result-gate.ts +++ b/apps/mobile/src/lib/pr-review/merge/merge-result-gate.ts @@ -66,7 +66,7 @@ export function gateMergeResult(result: MergePullRequestResult): MergeResultGate // cross-repo / not-requested paths leave it absent. The sheet // collapses both to "clean" (no banner) because the user did not // ask for a delete. - if ('branchDeleteError' in result && typeof result.branchDeleteError === 'string') { + if ('branchDeleteError' in result) { return { kind: 'partial', reason: result.branchDeleteError }; } return { kind: 'clean' }; diff --git a/apps/mobile/src/lib/pr-review/merge/pr-operation-ledger.test.ts b/apps/mobile/src/lib/pr-review/merge/pr-operation-ledger.test.ts index 444c4a58fc..2a65bf5dd7 100644 --- a/apps/mobile/src/lib/pr-review/merge/pr-operation-ledger.test.ts +++ b/apps/mobile/src/lib/pr-review/merge/pr-operation-ledger.test.ts @@ -53,7 +53,7 @@ describe('mapPrOperationError', () => { (surface, expected) => { const mapped = mapPrOperationError(IN_PROGRESS, surface); expect(mapped).toBeInstanceOf(Error); - expect((mapped as Error).message).toBe(expected); + expect(mapped.message).toBe(expected); } ); @@ -62,7 +62,7 @@ describe('mapPrOperationError', () => { surface => { const mapped = mapPrOperationError(AMBIGUOUS, surface); expect(mapped).toBeInstanceOf(Error); - expect((mapped as Error).message).toBe(PR_OPERATION_AMBIGUOUS_MESSAGE); + expect(mapped.message).toBe(PR_OPERATION_AMBIGUOUS_MESSAGE); } ); @@ -71,7 +71,7 @@ describe('mapPrOperationError', () => { surface => { const mapped = mapPrOperationError(PERSISTENCE_FAILED, surface); expect(mapped).toBeInstanceOf(Error); - expect((mapped as Error).message).toBe(PR_OPERATION_PERSISTENCE_FAILED_MESSAGE); + expect(mapped.message).toBe(PR_OPERATION_PERSISTENCE_FAILED_MESSAGE); } ); diff --git a/apps/mobile/src/lib/pr-review/merge/pr-operation-ledger.ts b/apps/mobile/src/lib/pr-review/merge/pr-operation-ledger.ts index 1c77e4282f..0f9d81a034 100644 --- a/apps/mobile/src/lib/pr-review/merge/pr-operation-ledger.ts +++ b/apps/mobile/src/lib/pr-review/merge/pr-operation-ledger.ts @@ -18,12 +18,12 @@ export type PrMutationSurface = 'create-comment' | 'submit-review' | 'reply' | ' // Existing retryable fallback copy per surface (mirrors the sheet/composer // defaults so an in-progress duplicate reads like a normal retryable failure). -const PR_SURFACE_RETRYABLE_COPY: Record = { +const PR_SURFACE_RETRYABLE_COPY = { 'create-comment': 'Could not post comment.', 'submit-review': 'Could not submit review. Check your connection and try again.', reply: 'Could not reply.', merge: 'Could not merge pull request.', -}; +} satisfies Record; export function isPrOperationAmbiguous(error: unknown): boolean { return error instanceof Error && error.message === PR_OPERATION_AMBIGUOUS_MESSAGE; @@ -52,7 +52,7 @@ export function isPrMutationRetryable(error: unknown): boolean { * sheets show the marker copy instead of a code-derived classification. Every * other error passes through unchanged. */ -export function mapPrOperationError(error: unknown, surface: PrMutationSurface): unknown { +export function mapPrOperationError(error: T, surface: PrMutationSurface): T | Error { if (isOperationInProgress(error)) { return new Error(PR_SURFACE_RETRYABLE_COPY[surface]); } diff --git a/apps/mobile/src/lib/pr-review/pending-review-provider.tsx b/apps/mobile/src/lib/pr-review/pending-review-provider.tsx index 4a7f545d8f..74b2a4804d 100644 --- a/apps/mobile/src/lib/pr-review/pending-review-provider.tsx +++ b/apps/mobile/src/lib/pr-review/pending-review-provider.tsx @@ -9,6 +9,7 @@ import { useRef, useState, } from 'react'; +import { z } from 'zod'; import { clearDraft, loadDraft, prReviewDraftKey, saveDraft } from '@/lib/persist/drafts'; import { useDraftFlushOnBackground } from '@/lib/persist/use-draft-flush'; @@ -21,30 +22,20 @@ import { useDraftFlushOnBackground } from '@/lib/persist/use-draft-flush'; // moves between queue and submit. Submission itself always uses the // LATEST head SHA (per the S3 contract) — a per-item 422 surfaces // inline so the user can decide whether to retry or drop the comment. -export type PendingReviewItem = { - id: string; - path: string; - side: 'LEFT' | 'RIGHT'; - line: number; - startLine?: number; - body: string; - commitSha: string; -}; +const PendingReviewItemSchema = z.object({ + id: z.string(), + path: z.string(), + side: z.union([z.literal('LEFT'), z.literal('RIGHT')]), + line: z.number(), + startLine: z.number().optional(), + body: z.string(), + commitSha: z.string(), +}); + +export type PendingReviewItem = z.infer; function isPendingReviewItem(value: unknown): value is PendingReviewItem { - if (value === null || typeof value !== 'object') { - return false; - } - const item = value as Record; - return ( - typeof item.id === 'string' && - typeof item.path === 'string' && - (item.side === 'LEFT' || item.side === 'RIGHT') && - typeof item.line === 'number' && - (item.startLine === undefined || typeof item.startLine === 'number') && - typeof item.body === 'string' && - typeof item.commitSha === 'string' - ); + return PendingReviewItemSchema.safeParse(value).success; } /** diff --git a/apps/mobile/src/lib/pr-review/recent-prs.ts b/apps/mobile/src/lib/pr-review/recent-prs.ts index 1f2cf520e9..e79bfdd36a 100644 --- a/apps/mobile/src/lib/pr-review/recent-prs.ts +++ b/apps/mobile/src/lib/pr-review/recent-prs.ts @@ -1,4 +1,5 @@ import * as SecureStore from 'expo-secure-store'; +import { z } from 'zod'; import { deleteAccountMetadata, writeAccountMetadata } from '@/lib/auth/account-metadata-write'; import { PR_REVIEW_RECENTS_KEY } from '@/lib/storage-keys'; @@ -11,6 +12,14 @@ export type RecentPr = { lastOpenedAt: number; }; +const recentPrSchema = z.object({ + owner: z.string(), + repo: z.string(), + number: z.number(), + title: z.string(), + lastOpenedAt: z.number(), +}); + const RECENT_PR_LIMIT = 10; function recentPrKey(item: RecentPr): string { @@ -27,18 +36,8 @@ function parseRecents(raw: string | null): RecentPr[] { return []; } return parsed.flatMap((entry): RecentPr[] => { - if ( - entry && - typeof entry === 'object' && - typeof (entry as Record).owner === 'string' && - typeof (entry as Record).repo === 'string' && - typeof (entry as Record).number === 'number' && - typeof (entry as Record).title === 'string' && - typeof (entry as Record).lastOpenedAt === 'number' - ) { - return [entry as RecentPr]; - } - return []; + const result = recentPrSchema.safeParse(entry); + return result.success ? [result.data] : []; }); } catch { return []; diff --git a/apps/mobile/src/lib/pr-review/viewed-files.ts b/apps/mobile/src/lib/pr-review/viewed-files.ts index d8fc900cd5..0008c79ad9 100644 --- a/apps/mobile/src/lib/pr-review/viewed-files.ts +++ b/apps/mobile/src/lib/pr-review/viewed-files.ts @@ -1,4 +1,5 @@ import * as SecureStore from 'expo-secure-store'; +import { z } from 'zod'; import { deleteAccountMetadata, writeAccountMetadata } from '@/lib/auth/account-metadata-write'; import { PR_REVIEW_VIEWED_KEY } from '@/lib/storage-keys'; @@ -10,6 +11,12 @@ type ViewedFileEntry = { type ViewedFileMap = Record; +const viewedFileEntrySchema = z.object({ + headSha: z.string(), + viewedPaths: z.array(z.string()), +}); +const rawViewedFileMapSchema = z.record(z.string(), z.unknown()); + const VIEWED_FILES_PR_LIMIT = 20; type ViewedFilePrRef = { @@ -22,37 +29,25 @@ function viewedFilesKey(ref: ViewedFilePrRef): string { return `${ref.owner.toLowerCase()}/${ref.repo.toLowerCase()}#${ref.number}`; } -function isValidEntry(value: unknown): value is ViewedFileEntry { - if (!value || typeof value !== 'object') { - return false; - } - const entry = value as Record; - return ( - typeof entry.headSha === 'string' && - Array.isArray(entry.viewedPaths) && - entry.viewedPaths.every(path => typeof path === 'string') - ); -} - function parseMap(raw: string | null): ViewedFileMap { if (raw == null || raw.length === 0) { return {}; } try { const parsed: unknown = JSON.parse(raw); - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + const shape = rawViewedFileMapSchema.safeParse(parsed); + if (!shape.success) { return {}; } - // Drop any structurally invalid entry rather than trusting the cast, so + // Drop any structurally invalid entry rather than trusting a cast, so // one corrupt record can't make getViewedFiles return a non-array or // make toggleViewedFile throw on `.includes`. - const result: ViewedFileMap = {}; - for (const [key, value] of Object.entries(parsed as Record)) { - if (isValidEntry(value)) { - result[key] = { headSha: value.headSha, viewedPaths: value.viewedPaths }; - } - } - return result; + return Object.fromEntries( + Object.entries(shape.data).flatMap<[string, ViewedFileEntry]>(([key, value]) => { + const entry = viewedFileEntrySchema.safeParse(value); + return entry.success ? [[key, entry.data]] : []; + }) + ); } catch { return {}; } diff --git a/apps/mobile/src/lib/query-client.ts b/apps/mobile/src/lib/query-client.ts index 140cfda8c8..ddc30ffdd6 100644 --- a/apps/mobile/src/lib/query-client.ts +++ b/apps/mobile/src/lib/query-client.ts @@ -1,4 +1,5 @@ import { MutationCache, type Query, QueryCache, QueryClient } from '@tanstack/react-query'; +import { z } from 'zod'; import { handleTrpcQueryError } from '@/lib/auth/trpc-unauthorized'; @@ -17,20 +18,27 @@ type TrpcErrorData = { authRequired?: boolean; }; +const trpcErrorDataSchema = z.object({ + code: z.string().optional(), + authRequired: z.boolean().optional(), +}); +const trpcErrorShapeSchema = z.object({ + data: z.unknown().optional(), + shape: z.object({ data: z.unknown().optional() }).optional(), +}); + /** The serialized tRPC error data, from either the direct or shaped variant. */ function trpcErrorData(error: unknown): TrpcErrorData | undefined { - if (typeof error !== 'object' || error === null) { + const parsedError = trpcErrorShapeSchema.safeParse(error); + if (!parsedError.success) { return undefined; } - const direct = (error as { data?: unknown }).data; - if (typeof direct === 'object' && direct !== null) { - return direct as TrpcErrorData; - } - const shaped = (error as { shape?: { data?: unknown } }).shape?.data; - if (typeof shaped === 'object' && shaped !== null) { - return shaped as TrpcErrorData; + const direct = trpcErrorDataSchema.safeParse(parsedError.data.data); + if (direct.success) { + return direct.data; } - return undefined; + const shaped = trpcErrorDataSchema.safeParse(parsedError.data.shape?.data); + return shaped.success ? shaped.data : undefined; } /** @@ -47,16 +55,17 @@ function isPermissionDeniedError(error: unknown): boolean { return data?.code === 'UNAUTHORIZED' && data.authRequired !== true; } +type QueryKeyInput = { organizationId?: unknown }; + +const queryKeyMetaSchema = z.object({ input: z.unknown().optional() }); + /** The `input` field of a tRPC query key's meta segment, or undefined. */ -function queryKeyInput(queryKey: unknown): unknown { +function queryKeyInput(queryKey: unknown): QueryKeyInput | undefined { if (!Array.isArray(queryKey) || queryKey.length < 2) { return undefined; } - const meta = queryKey[1]; - if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) { - return undefined; - } - return (meta as { input?: unknown }).input; + const parsedMeta = queryKeyMetaSchema.safeParse(queryKey[1]); + return parsedMeta.success ? (parsedMeta.data.input as QueryKeyInput | undefined) : undefined; } /** @@ -74,7 +83,7 @@ function removePermissionDeniedQueries( return; } const input = queryKeyInput(query.queryKey); - const organizationId = (input as { organizationId?: unknown } | undefined)?.organizationId; + const organizationId = input?.organizationId; if (organizationId !== undefined && organizationId !== null) { queryClient.removeQueries({ queryKey: [query.queryKey[0], { input: { organizationId } }], diff --git a/apps/mobile/src/lib/route-params.ts b/apps/mobile/src/lib/route-params.ts index bca88b4843..aef381a588 100644 --- a/apps/mobile/src/lib/route-params.ts +++ b/apps/mobile/src/lib/route-params.ts @@ -13,7 +13,7 @@ export function parseParam( value: string | string[] | undefined, allowed?: readonly T[] ): T | null { - if (typeof value !== 'string' || value.length === 0) { + if (value === undefined || Array.isArray(value) || value.length === 0) { return null; } if (allowed && !allowed.includes(value as T)) { diff --git a/apps/mobile/src/lib/session-pr-navigation.ts b/apps/mobile/src/lib/session-pr-navigation.ts index 95ce2692d7..9bbe0ff15d 100644 --- a/apps/mobile/src/lib/session-pr-navigation.ts +++ b/apps/mobile/src/lib/session-pr-navigation.ts @@ -25,7 +25,7 @@ export function resolveSessionPrTapTarget( input: SessionPrNavigationInput ): SessionPrNavigationResult { const url = input.url; - const parsed = typeof url === 'string' && url.length > 0 ? parseGitHubPrUrl(url) : null; + const parsed = url ? parseGitHubPrUrl(url) : null; if (parsed) { return { kind: 'in-app', href: getPrReviewPath(parsed.owner, parsed.repo, input.number) }; diff --git a/apps/mobile/src/lib/share-navigation.ts b/apps/mobile/src/lib/share-navigation.ts index f8ba030409..99f0efac89 100644 --- a/apps/mobile/src/lib/share-navigation.ts +++ b/apps/mobile/src/lib/share-navigation.ts @@ -49,7 +49,7 @@ export function appendShareParams( } /** Parse the destination params a focused delivery must set from a pending href. */ -export function parseShareHrefParams(href: string): { organizationId: string | undefined } { +export function parseShareHrefParams(href: string) { const queryStart = href.indexOf('?'); if (queryStart === -1) { return { organizationId: undefined }; @@ -98,6 +98,9 @@ function normalizePath(path: string): string[] { * Delivery waits until the formSheet is fully gone — never a fixed timer. */ export function navigationContainsShareGate(state: unknown): boolean { + // Walks the untyped React Navigation state tree, which has no shared + // discriminant to narrow on. + // oxlint-disable-next-line anti-slop/no-runtime-typeof -- see above if (!state || typeof state !== 'object') { return false; } diff --git a/apps/mobile/src/lib/telemetry/sentry-scrub.test.ts b/apps/mobile/src/lib/telemetry/sentry-scrub.test.ts index 2c8c6bcefd..a9225901a1 100644 --- a/apps/mobile/src/lib/telemetry/sentry-scrub.test.ts +++ b/apps/mobile/src/lib/telemetry/sentry-scrub.test.ts @@ -8,7 +8,7 @@ describe('scrubEvent', () => { request: { url: 'https://api.example.com/trpc/getUser?input=%7B%22id%22%3A%221%22%7D' }, }; - const result = scrubEvent(event) as typeof event; + const result = scrubEvent(event); expect(result.request.url).toBe('https://api.example.com/trpc/getUser'); }); @@ -18,7 +18,7 @@ describe('scrubEvent', () => { request: { url: 'https://api.example.com/data?repo=kilocode&org=myorg' }, }; - const result = scrubEvent(event) as typeof event; + const result = scrubEvent(event); expect(result.request.url).toBe('https://api.example.com/data'); }); @@ -28,7 +28,7 @@ describe('scrubEvent', () => { contexts: { response: { url: 'https://cdn.example.com/file?token=abc' } }, }; - const result = scrubEvent(event) as typeof event; + const result = scrubEvent(event); expect(result.contexts.response.url).toBe('https://cdn.example.com/file'); }); @@ -38,7 +38,7 @@ describe('scrubEvent', () => { user: { email: 'user@example.com', id: 'abc' }, }; - const result = scrubEvent(event) as typeof event; + const result = scrubEvent(event); expect(result.user.email).toBeUndefined(); expect(result.user.id).toBe('abc'); @@ -49,7 +49,7 @@ describe('scrubEvent', () => { user: { username: 'jdoe', id: 'abc' }, }; - const result = scrubEvent(event) as typeof event; + const result = scrubEvent(event); expect(result.user.username).toBeUndefined(); expect(result.user.id).toBe('abc'); @@ -60,7 +60,7 @@ describe('scrubEvent', () => { user: { ip_address: '1.2.3.4', id: 'abc' }, }; - const result = scrubEvent(event) as typeof event; + const result = scrubEvent(event); expect(result.user.ip_address).toBeUndefined(); expect(result.user.id).toBe('abc'); @@ -71,7 +71,7 @@ describe('scrubEvent', () => { extra: { auth: 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0' }, }; - const result = scrubEvent(event) as typeof event; + const result = scrubEvent(event); expect(result.extra.auth).toBe('[redacted]'); }); @@ -81,7 +81,7 @@ describe('scrubEvent', () => { extra: { token: 'abcdefghijklmnopqrst' }, }; - const result = scrubEvent(event) as typeof event; + const result = scrubEvent(event); expect(result.extra.token).toBe('[redacted]'); }); @@ -91,7 +91,7 @@ describe('scrubEvent', () => { extra: { count: 42, name: 'short' }, }; - const result = scrubEvent(event) as typeof event; + const result = scrubEvent(event); expect(result.extra.count).toBe(42); expect(result.extra.name).toBe('short'); @@ -102,7 +102,7 @@ describe('scrubEvent', () => { tags: { session: 'Bearer tok1234567890abcdef' }, }; - const result = scrubEvent(event) as typeof event; + const result = scrubEvent(event); expect(result.tags.session).toBe('[redacted]'); }); @@ -114,8 +114,10 @@ describe('scrubEvent', () => { }); it('returns undefined unchanged without throwing', () => { - expect(() => scrubEvent(undefined)).not.toThrow(); - expect(scrubEvent(undefined)).toBeUndefined(); + expect(() => { + scrubEvent | undefined>(undefined); + }).not.toThrow(); + expect(scrubEvent | undefined>(undefined)).toBeUndefined(); }); it('returns string input unchanged without throwing', () => { @@ -146,7 +148,7 @@ describe('scrubEvent', () => { it('leaves URL without query string unchanged', () => { const event = { request: { url: 'https://api.example.com/trpc/getUser' } }; - const result = scrubEvent(event) as typeof event; + const result = scrubEvent(event); expect(result.request.url).toBe('https://api.example.com/trpc/getUser'); }); @@ -154,7 +156,7 @@ describe('scrubEvent', () => { it('does not mutate extra when no token values present', () => { const event = { extra: { env: 'production', build: 123 } }; - const result = scrubEvent(event) as typeof event; + const result = scrubEvent(event); expect(result.extra).toEqual({ env: 'production', build: 123 }); }); @@ -198,10 +200,10 @@ describe('scrubBreadcrumb', () => { data: { url: 'https://example.com/page?secret=123', method: 'GET' }, }; - const result = scrubBreadcrumb(breadcrumb) as typeof breadcrumb; + const result = scrubBreadcrumb(breadcrumb); - expect(result.data.url).toBe('https://example.com/page'); - expect(result.data.method).toBe('GET'); + expect(result?.data.url).toBe('https://example.com/page'); + expect(result?.data.method).toBe('GET'); }); it('returns null input unchanged without throwing', () => { diff --git a/apps/mobile/src/lib/telemetry/sentry-scrub.ts b/apps/mobile/src/lib/telemetry/sentry-scrub.ts index d3a0227a35..e527ee7748 100644 --- a/apps/mobile/src/lib/telemetry/sentry-scrub.ts +++ b/apps/mobile/src/lib/telemetry/sentry-scrub.ts @@ -6,6 +6,11 @@ * `beforeBreadcrumb` drops the event silently, which would hide crashes. */ +/* oxlint-disable anti-slop/no-runtime-typeof -- Sentry event/breadcrumb payloads are + * external, arbitrarily shaped, and must never throw on a malformed shape; a + * zod schema for the full Sentry Event/Breadcrumb type would risk silently + * dropping fields this walker isn't meant to know about. */ + /** Strip the query string from a URL. Returns empty string if parsing fails. */ function stripQuery(url: unknown): string { if (typeof url !== 'string') { @@ -49,7 +54,7 @@ function redactTokens( * - Redacts token-shaped values (20+ base64url chars or `Bearer ` prefix) * in `event.extra` and `event.tags`. */ -export function scrubEvent(event: unknown): unknown { +export function scrubEvent(event: T): T { try { if (event == null || typeof event !== 'object') { return event; @@ -105,7 +110,7 @@ export function scrubEvent(event: unknown): unknown { * - Strips the query string from `breadcrumb.data.url`. * - Leaves navigation and fetch breadcrumbs otherwise intact. */ -export function scrubBreadcrumb(breadcrumb: unknown): unknown { +export function scrubBreadcrumb(breadcrumb: T): T | null { try { if (breadcrumb == null || typeof breadcrumb !== 'object') { return breadcrumb; diff --git a/apps/mobile/src/lib/trpc-error.ts b/apps/mobile/src/lib/trpc-error.ts index 92e046bae2..46653d6351 100644 --- a/apps/mobile/src/lib/trpc-error.ts +++ b/apps/mobile/src/lib/trpc-error.ts @@ -1,8 +1,20 @@ +import { z } from 'zod'; + // Shared tRPC error helpers. tRPC v11 client errors expose `data.code` / // `data.message`; server-shaped errors expose `shape.data.code` / // `shape.data.message`. Anything else is treated as an unknown transient // error. +const DirectErrorSchema = z.looseObject({ + data: z.looseObject({ code: z.string().optional(), message: z.string().optional() }), +}); +const ShapedErrorSchema = z.looseObject({ + shape: z.looseObject({ + data: z.looseObject({ code: z.string().optional(), message: z.string().optional() }), + }), +}); +const TopLevelCodeSchema = z.looseObject({ code: z.string() }); + /** * Extracts a field from an unknown tRPC error. Reads `data[field]` first, * then `shape.data[field]`, then (for `code`) the top-level `code`, then @@ -10,31 +22,18 @@ * exists. */ export function readTrpcErrorField(error: unknown, field: 'code' | 'message'): string | undefined { - if (!error || typeof error !== 'object') { - return undefined; + const direct = DirectErrorSchema.safeParse(error); + if (direct.success && direct.data.data[field] !== undefined) { + return direct.data.data[field]; } - const record = error as Record; - const data = record.data; - if (data && typeof data === 'object') { - const value = (data as Record)[field]; - if (typeof value === 'string') { - return value; - } - } - const shape = record.shape; - if (shape && typeof shape === 'object') { - const shapeData = (shape as Record).data; - if (shapeData && typeof shapeData === 'object') { - const value = (shapeData as Record)[field]; - if (typeof value === 'string') { - return value; - } - } + const shaped = ShapedErrorSchema.safeParse(error); + if (shaped.success && shaped.data.shape.data[field] !== undefined) { + return shaped.data.shape.data[field]; } if (field === 'code') { - const top = record.code; - if (typeof top === 'string') { - return top; + const top = TopLevelCodeSchema.safeParse(error); + if (top.success) { + return top.data.code; } } if (field === 'message' && error instanceof Error) { diff --git a/apps/mobile/src/lib/trpc.ts b/apps/mobile/src/lib/trpc.ts index cd82ae6c66..055adc51f6 100644 --- a/apps/mobile/src/lib/trpc.ts +++ b/apps/mobile/src/lib/trpc.ts @@ -33,13 +33,13 @@ const E2E_LATENCY_RULES: readonly (readonly [procedure: string, delayMs: number] ]; function requestUrlString(url: RequestInfo | URL): string { - if (typeof url === 'string') { - return url; - } if (url instanceof URL) { return url.href; } - return url.url; + if (url instanceof Request) { + return url.url; + } + return url; } function e2eLatencyForUrl(url: string): number { diff --git a/apps/mobile/src/lib/voice-input/native-voice-input.ts b/apps/mobile/src/lib/voice-input/native-voice-input.ts index 009d1ff737..a4c45e0daa 100644 --- a/apps/mobile/src/lib/voice-input/native-voice-input.ts +++ b/apps/mobile/src/lib/voice-input/native-voice-input.ts @@ -18,6 +18,7 @@ function isAndroidApiLevelAtLeast(level: number): boolean { return false; } const version = Platform.Version; + // oxlint-disable-next-line anti-slop/no-runtime-typeof -- Platform.Version is an environment probe typed `string | number`; typeof is the only way to tell which if (typeof version === 'number') { return version >= level; } diff --git a/apps/mobile/src/lib/voice-input/voice-input-controller-helpers.ts b/apps/mobile/src/lib/voice-input/voice-input-controller-helpers.ts index 9e149d2af7..a52c44f278 100644 --- a/apps/mobile/src/lib/voice-input/voice-input-controller-helpers.ts +++ b/apps/mobile/src/lib/voice-input/voice-input-controller-helpers.ts @@ -8,10 +8,7 @@ type PermissionResult = const noopBooleanResolver = (_ok: boolean): void => undefined; -function createBooleanResolver(): { - promise: Promise; - resolve: (value: boolean) => void; -} { +function createBooleanResolver() { let resolveBoolean = noopBooleanResolver; const promise = new Promise(resolve => { resolveBoolean = resolve; @@ -26,23 +23,20 @@ export type PendingVoiceInputStart = { owner: string; }; -export function createVoiceInputStartQueue(): { - cancel: (owner?: string) => void; - run: ( - request: PendingVoiceInputStart, - task: (request: PendingVoiceInputStart) => Promise - ) => Promise; -} { +export function createVoiceInputStartQueue() { let pending: PendingVoiceInputStart | null = null; let barrier: Promise | null = null; return { - cancel: owner => { + cancel: (owner?: string) => { if (pending && (owner === undefined || pending.owner === owner)) { pending.cancelled = true; } }, - run: async (request, task) => { + run: async ( + request: PendingVoiceInputStart, + task: (request: PendingVoiceInputStart) => Promise + ) => { if (pending) { pending.cancelled = true; } diff --git a/apps/mobile/src/lib/voice-input/voice-input-language.ts b/apps/mobile/src/lib/voice-input/voice-input-language.ts index 8f7cf3cc07..ac71cd1904 100644 --- a/apps/mobile/src/lib/voice-input/voice-input-language.ts +++ b/apps/mobile/src/lib/voice-input/voice-input-language.ts @@ -116,6 +116,9 @@ export async function resolveVoiceInputStartLanguageTag(): Promise { const locales = getLocales(); const deviceTags = locales .map(l => l.languageTag) + // `getLocales()` types `languageTag` as a non-optional string, but the native + // module can still hand back a missing/empty value at runtime. + // oxlint-disable-next-line anti-slop/no-runtime-typeof -- environment probe: guards against a real-device value diverging from expo-localization's static type. .filter((t): t is string => typeof t === 'string' && t.length > 0); const rawTag = resolveVoiceInputLanguageTag(locales); diff --git a/apps/mobile/src/test/render-with-providers.tsx b/apps/mobile/src/test/render-with-providers.tsx index 604a9782a8..68c32bfca2 100644 --- a/apps/mobile/src/test/render-with-providers.tsx +++ b/apps/mobile/src/test/render-with-providers.tsx @@ -35,6 +35,9 @@ type RenderWithProvidersResult = { unmount: () => void; }; +/** Named so the widening below reads as an owned contract, not an anonymous object type. */ +type PendingRendererRef = { current: TestRenderer.ReactTestRenderer | undefined }; + /** * Build a `QueryClient` configured for deterministic tests: retries disabled so a * rejected query surfaces immediately, and no background refetching. @@ -63,7 +66,7 @@ export async function renderWithProviders( const inner = Wrapper ? createElement(Wrapper, null, ui) : ui; const tree = createElement(QueryClientProvider, { client: queryClient }, inner); - const ref: { current: TestRenderer.ReactTestRenderer | undefined } = { current: undefined }; + const ref: PendingRendererRef = { current: undefined }; await act(async () => { ref.current = TestRenderer.create(tree); await Promise.resolve(); diff --git a/package.json b/package.json index af68fbc727..91cd9b2be1 100644 --- a/package.json +++ b/package.json @@ -46,9 +46,11 @@ "packageManager": "pnpm@11.1.2", "devDependencies": { "@expo/fingerprint": "0.16.7", + "@oxlint/plugins": "1.78.0", "@types/node": "catalog:", "@types/proper-lockfile": "4.1.4", "@typescript/native-preview": "catalog:", + "eslint-plugin-zod-utils": "1.0.11", "husky": "9.1.7", "ink": "6.8.0", "oxfmt": "0.40.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c3b970a5c6..92339cfe21 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,93 +6,18 @@ settings: catalogs: default: - '@cloudflare/vitest-pool-workers': - specifier: 0.16.13 - version: 0.16.13 - '@cloudflare/workers-types': - specifier: 4.20260605.1 - version: 4.20260605.1 - '@octokit/auth-app': - specifier: 8.2.0 - version: 8.2.0 - '@sentry/cli': - specifier: 3.6.2 - version: 3.6.2 - '@tanstack/query-async-storage-persister': - specifier: 5.100.10 - version: 5.100.10 - '@tanstack/react-query': - specifier: 5.100.10 - version: 5.100.10 - '@tanstack/react-query-persist-client': - specifier: 5.100.10 - version: 5.100.10 - '@trpc/client': - specifier: 11.17.0 - version: 11.17.0 - '@trpc/server': - specifier: 11.17.0 - version: 11.17.0 - '@trpc/tanstack-react-query': - specifier: 11.17.0 - version: 11.17.0 - '@types/jsonwebtoken': - specifier: 9.0.10 - version: 9.0.10 '@types/node': specifier: 24.12.4 version: 24.12.4 '@typescript/native-preview': specifier: 7.0.0-dev.20260514.1 version: 7.0.0-dev.20260514.1 - '@vitest/coverage-v8': - specifier: 4.1.6 - version: 4.1.6 - '@vitest/ui': - specifier: 4.1.6 - version: 4.1.6 - '@workos-inc/node': - specifier: 8.13.0 - version: 8.13.0 - aws4fetch: - specifier: 1.0.20 - version: 1.0.20 - drizzle-kit: - specifier: 0.31.10 - version: 0.31.10 - jose: - specifier: 6.2.3 - version: 6.2.3 - jsonwebtoken: - specifier: 9.0.3 - version: 9.0.3 - p-limit: - specifier: 7.3.0 - version: 7.3.0 - stripe: - specifier: 19.3.1 - version: 19.3.1 tsx: specifier: 4.21.0 version: 4.21.0 typescript: specifier: 5.9.3 version: 5.9.3 - ulid: - specifier: 3.0.2 - version: 3.0.2 - vitest: - specifier: 4.1.6 - version: 4.1.6 - workers-tagged-logger: - specifier: 1.0.0 - version: 1.0.0 - wrangler: - specifier: 4.112.0 - version: 4.112.0 - zod: - specifier: 4.4.3 - version: 4.4.3 overrides: '@babel/plugin-transform-modules-systemjs': 7.29.7 @@ -159,6 +84,9 @@ importers: '@expo/fingerprint': specifier: 0.16.7 version: 0.16.7 + '@oxlint/plugins': + specifier: 1.78.0 + version: 1.78.0 '@types/node': specifier: 'catalog:' version: 24.12.4 @@ -168,12 +96,15 @@ importers: '@typescript/native-preview': specifier: 'catalog:' version: 7.0.0-dev.20260514.1 + eslint-plugin-zod-utils: + specifier: 1.0.11 + version: 1.0.11(typescript@5.9.3) husky: specifier: 9.1.7 version: 9.1.7 ink: specifier: 6.8.0 - version: 6.8.0(@types/react@19.2.14)(bufferutil@4.1.0)(react-devtools-core@6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react@19.2.6)(utf-8-validate@6.0.6) + version: 6.8.0(react@19.2.6) oxfmt: specifier: 0.40.0 version: 0.40.0 @@ -1324,7 +1255,7 @@ importers: version: 7.0.0-dev.20260514.1 jest: specifier: 30.3.0 - version: 30.3.0(@types/node@25.5.2)(node-notifier@10.0.1) + version: 30.3.0(@types/node@24.12.4)(node-notifier@10.0.1) typescript: specifier: 'catalog:' version: 5.9.3 @@ -1974,7 +1905,7 @@ importers: version: 0.3.7 '@cloudflare/vitest-pool-workers': specifier: 'catalog:' - version: 0.16.13(@types/node@24.12.4)(@vitest/runner@4.1.6)(@vitest/snapshot@4.1.6)(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vitest@4.1.6) + version: 0.16.13(@cloudflare/workers-types@4.20260605.1)(@types/node@24.12.4)(@vitest/runner@4.1.6)(@vitest/snapshot@4.1.6)(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vitest@4.1.6) '@kilocode/sdk': specifier: 7.4.20 version: 7.4.20 @@ -2122,7 +2053,7 @@ importers: devDependencies: '@cloudflare/vitest-pool-workers': specifier: 'catalog:' - version: 0.16.13(@types/node@24.12.4)(@vitest/runner@4.1.6)(@vitest/snapshot@4.1.6)(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vitest@4.1.6) + version: 0.16.13(@cloudflare/workers-types@4.20260605.1)(@types/node@24.12.4)(@vitest/runner@4.1.6)(@vitest/snapshot@4.1.6)(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vitest@4.1.6) '@types/jest': specifier: 30.0.0 version: 30.0.0 @@ -2284,7 +2215,7 @@ importers: devDependencies: '@cloudflare/vitest-pool-workers': specifier: 'catalog:' - version: 0.16.13(@types/node@24.12.4)(@vitest/runner@4.1.6)(@vitest/snapshot@4.1.6)(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vitest@4.1.6) + version: 0.16.13(@cloudflare/workers-types@4.20260605.1)(@types/node@24.12.4)(@vitest/runner@4.1.6)(@vitest/snapshot@4.1.6)(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vitest@4.1.6) '@types/node': specifier: 'catalog:' version: 24.12.4 @@ -2459,7 +2390,7 @@ importers: devDependencies: '@cloudflare/vitest-pool-workers': specifier: 'catalog:' - version: 0.16.13(@types/node@25.5.2)(@vitest/runner@4.1.6)(@vitest/snapshot@4.1.6)(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vitest@4.1.6) + version: 0.16.13(@cloudflare/workers-types@4.20260605.1)(@types/node@25.5.2)(@vitest/runner@4.1.6)(@vitest/snapshot@4.1.6)(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vitest@4.1.6) '@typescript/native-preview': specifier: 'catalog:' version: 7.0.0-dev.20260514.1 @@ -2566,7 +2497,7 @@ importers: devDependencies: '@cloudflare/vitest-pool-workers': specifier: 'catalog:' - version: 0.16.13(@types/node@24.12.4)(@vitest/runner@4.1.6)(@vitest/snapshot@4.1.6)(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vitest@4.1.6) + version: 0.16.13(@cloudflare/workers-types@4.20260605.1)(@types/node@24.12.4)(@vitest/runner@4.1.6)(@vitest/snapshot@4.1.6)(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vitest@4.1.6) '@types/content-disposition': specifier: 0.5.9 version: 0.5.9 @@ -2923,7 +2854,7 @@ importers: devDependencies: '@cloudflare/vitest-pool-workers': specifier: 'catalog:' - version: 0.16.13(@types/node@24.12.4)(@vitest/runner@4.1.6)(@vitest/snapshot@4.1.6)(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vitest@4.1.6) + version: 0.16.13(@cloudflare/workers-types@4.20260605.1)(@types/node@24.12.4)(@vitest/runner@4.1.6)(@vitest/snapshot@4.1.6)(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vitest@4.1.6) '@types/node': specifier: 'catalog:' version: 24.12.4 @@ -2960,7 +2891,7 @@ importers: devDependencies: '@cloudflare/vitest-pool-workers': specifier: 'catalog:' - version: 0.16.13(@types/node@24.12.4)(@vitest/runner@4.1.6)(@vitest/snapshot@4.1.6)(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vitest@4.1.6) + version: 0.16.13(@cloudflare/workers-types@4.20260605.1)(@types/node@24.12.4)(@vitest/runner@4.1.6)(@vitest/snapshot@4.1.6)(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vitest@4.1.6) '@types/node': specifier: 'catalog:' version: 24.12.4 @@ -3227,7 +3158,7 @@ importers: devDependencies: '@cloudflare/vitest-pool-workers': specifier: 'catalog:' - version: 0.16.13(@types/node@24.12.4)(@vitest/runner@4.1.6)(@vitest/snapshot@4.1.6)(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vitest@4.1.6) + version: 0.16.13(@cloudflare/workers-types@4.20260605.1)(@types/node@24.12.4)(@vitest/runner@4.1.6)(@vitest/snapshot@4.1.6)(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vitest@4.1.6) '@types/node': specifier: 'catalog:' version: 24.12.4 @@ -7012,6 +6943,10 @@ packages: resolution: {integrity: sha512-aUOYj3qpaR/Mbo0HQo8CuwrxbkwRTpsxMlvaQ00INGXS1KdvllRHU1eEn7WYgwhgK3cXtAbLP13VjSknwdxvzA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@oxlint/plugins@1.78.0': + resolution: {integrity: sha512-Ypt8KeRYw+4jUtlPirfcHWMrn5ms12VrrFPD+Mds477/7tJxG1Kcz2Yrg2nVcTQEUx/GdlhS+BUg1kmxNm04Ug==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@panva/hkdf@1.2.1': resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==} @@ -9848,30 +9783,41 @@ packages: '@types/yargs@17.0.35': resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} - '@typescript-eslint/project-service@8.57.0': - resolution: {integrity: sha512-pR+dK0BlxCLxtWfaKQWtYr7MhKmzqZxuii+ZjuFlZlIGRZm22HnXFqa2eY+90MUz8/i80YJmzFGDUsi8dMOV5w==} + '@typescript-eslint/project-service@8.67.0': + resolution: {integrity: sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.67.0': + resolution: {integrity: sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.57.0': - resolution: {integrity: sha512-LtXRihc5ytjJIQEH+xqjB0+YgsV4/tW35XKX3GTZHpWtcC8SPkT/d4tqdf1cKtesryHm2bgp6l555NYcT2NLvA==} + '@typescript-eslint/tsconfig-utils@8.67.0': + resolution: {integrity: sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/types@8.57.0': - resolution: {integrity: sha512-dTLI8PEXhjUC7B9Kre+u0XznO696BhXcTlOn0/6kf1fHaQW8+VjJAVHJ3eTI14ZapTxdkOmc80HblPQLaEeJdg==} + '@typescript-eslint/types@8.67.0': + resolution: {integrity: sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.57.0': - resolution: {integrity: sha512-m7faHcyVg0BT3VdYTlX8GdJEM7COexXxS6KqGopxdtkQRvBanK377QDHr4W/vIPAR+ah9+B/RclSW5ldVniO1Q==} + '@typescript-eslint/typescript-estree@8.67.0': + resolution: {integrity: sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.67.0': + resolution: {integrity: sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <6.0.0' + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/visitor-keys@8.57.0': - resolution: {integrity: sha512-zm6xx8UT/Xy2oSr2ZXD0pZo7Jx2XsCoID2IUh9YSTFRu7z+WdwYTRk6LhUftm1crwqbuoF6I8zAFeCMw0YjwDg==} + '@typescript-eslint/visitor-keys@8.67.0': + resolution: {integrity: sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@typescript/native-preview-darwin-arm64@7.0.0-dev.20251019.1': @@ -10713,6 +10659,9 @@ packages: bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + balanced-match@4.0.4: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} @@ -10833,6 +10782,9 @@ packages: resolution: {integrity: sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ==} engines: {node: '>= 5.10.0'} + brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + brace-expansion@5.0.9: resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} engines: {node: 20 || >=22} @@ -11350,6 +11302,9 @@ packages: compute-scroll-into-view@2.0.4: resolution: {integrity: sha512-y/ZA3BGnxoM/QHHQ2Uy49CLtnWPbt4tTPpEEZiEmmiWBFKjej7nEyH8Ryz54jH0MLXflUYA3Er2zUxPSJu5R+g==} + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + concat-stream@1.6.2: resolution: {integrity: sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==} engines: {'0': node >= 0.8} @@ -12273,6 +12228,11 @@ packages: peerDependencies: eslint: ^8.0.0 || ^9.0.0 || ^10.0.0 + eslint-plugin-zod-utils@1.0.11: + resolution: {integrity: sha512-mzV49rVHsHgEytdkCSBkVDRWygQQ9j6aPjCgucfie5QhbQdmwxZ7sOkuXNmFugkT1ahBXmBlcV3w75wAZ9/Byg==} + peerDependencies: + eslint: '>=8.57.0 || >=9.0.0 || >=10.0.0' + eslint-scope@5.1.1: resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} engines: {node: '>=8.0.0'} @@ -18690,7 +18650,7 @@ snapshots: '@apm-js-collab/code-transformer@0.18.1': dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 astring: 1.9.0 esquery: 1.7.0 meriyah: 6.1.4 @@ -20369,38 +20329,6 @@ snapshots: - bufferutil - utf-8-validate - '@cloudflare/vitest-pool-workers@0.16.13(@types/node@24.12.4)(@vitest/runner@4.1.6)(@vitest/snapshot@4.1.6)(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vitest@4.1.6)': - dependencies: - '@vitest/runner': 4.1.6 - '@vitest/snapshot': 4.1.6 - cjs-module-lexer: 1.2.3 - esbuild: 0.28.1 - miniflare: 4.20260603.0(@types/node@24.12.4)(bufferutil@4.1.0)(utf-8-validate@6.0.6) - vitest: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@24.12.4)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(esbuild@0.28.1)(jiti@2.7.0)(jsdom@29.1.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4) - wrangler: 4.98.0(@types/node@24.12.4)(bufferutil@4.1.0)(utf-8-validate@6.0.6) - zod: 3.25.76 - transitivePeerDependencies: - - '@cloudflare/workers-types' - - '@types/node' - - bufferutil - - utf-8-validate - - '@cloudflare/vitest-pool-workers@0.16.13(@types/node@25.5.2)(@vitest/runner@4.1.6)(@vitest/snapshot@4.1.6)(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vitest@4.1.6)': - dependencies: - '@vitest/runner': 4.1.6 - '@vitest/snapshot': 4.1.6 - cjs-module-lexer: 1.2.3 - esbuild: 0.28.1 - miniflare: 4.20260603.0(@types/node@25.5.2)(bufferutil@4.1.0)(utf-8-validate@6.0.6) - vitest: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@25.5.2)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(esbuild@0.28.1)(jiti@2.7.0)(jsdom@29.1.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4) - wrangler: 4.98.0(@types/node@25.5.2)(bufferutil@4.1.0)(utf-8-validate@6.0.6) - zod: 3.25.76 - transitivePeerDependencies: - - '@cloudflare/workers-types' - - '@types/node' - - bufferutil - - utf-8-validate - '@cloudflare/workerd-darwin-64@1.20260603.1': optional: true @@ -20989,6 +20917,10 @@ snapshots: '@esbuild/win32-x64@0.28.1': optional: true + '@eslint-community/eslint-utils@4.9.1': + dependencies: + eslint-visitor-keys: 3.4.3 + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.7.0))': dependencies: eslint: 9.39.4(jiti@2.7.0) @@ -22680,7 +22612,7 @@ snapshots: '@mdx-js/mdx@3.1.1': dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 '@types/estree-jsx': 1.0.5 '@types/hast': 3.0.4 '@types/mdx': 2.0.13 @@ -23660,6 +23592,8 @@ snapshots: '@oxlint/plugins@1.64.0': {} + '@oxlint/plugins@1.78.0': {} + '@panva/hkdf@1.2.1': {} '@paper-design/shaders-react@0.0.76(@types/react@19.2.14)(react@19.2.6)': @@ -24774,7 +24708,7 @@ snapshots: '@rollup/pluginutils@5.3.0(rollup@4.62.3)': dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 estree-walker: 2.0.2 picomatch: 4.0.4 optionalDependencies: @@ -26759,16 +26693,16 @@ snapshots: '@types/eslint-scope@3.7.7': dependencies: '@types/eslint': 9.6.1 - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 '@types/eslint@9.6.1': dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 '@types/json-schema': 7.0.15 '@types/estree-jsx@1.0.5': dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 '@types/estree@1.0.8': {} @@ -26937,27 +26871,32 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 - '@typescript-eslint/project-service@8.57.0(typescript@5.9.3)': + '@typescript-eslint/project-service@8.67.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.57.0(typescript@5.9.3) - '@typescript-eslint/types': 8.57.0 + '@typescript-eslint/tsconfig-utils': 8.67.0(typescript@5.9.3) + '@typescript-eslint/types': 8.67.0 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/tsconfig-utils@8.57.0(typescript@5.9.3)': + '@typescript-eslint/scope-manager@8.67.0': + dependencies: + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/visitor-keys': 8.67.0 + + '@typescript-eslint/tsconfig-utils@8.67.0(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/types@8.57.0': {} + '@typescript-eslint/types@8.67.0': {} - '@typescript-eslint/typescript-estree@8.57.0(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.67.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.57.0(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.57.0(typescript@5.9.3) - '@typescript-eslint/types': 8.57.0 - '@typescript-eslint/visitor-keys': 8.57.0 + '@typescript-eslint/project-service': 8.67.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.67.0(typescript@5.9.3) + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/visitor-keys': 8.67.0 debug: 4.4.3 minimatch: 10.2.5 semver: 7.8.5 @@ -26967,9 +26906,19 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.57.0': + '@typescript-eslint/utils@8.67.0(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1 + '@typescript-eslint/scope-manager': 8.67.0 + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/typescript-estree': 8.67.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.67.0': dependencies: - '@typescript-eslint/types': 8.57.0 + '@typescript-eslint/types': 8.67.0 eslint-visitor-keys: 5.0.1 '@typescript/native-preview-darwin-arm64@7.0.0-dev.20251019.1': @@ -27140,7 +27089,7 @@ snapshots: obug: 2.1.1 std-env: 4.0.0 tinyrainbow: 3.1.0 - vitest: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@25.5.2)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(esbuild@0.28.1)(jiti@2.7.0)(jsdom@29.1.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4) + vitest: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@24.12.4)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(esbuild@0.28.1)(jiti@2.7.0)(jsdom@29.1.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4) '@vitest/expect@3.2.4': dependencies: @@ -27218,7 +27167,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vitest: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@25.5.2)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(esbuild@0.28.1)(jiti@2.7.0)(jsdom@29.1.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4) + vitest: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@24.12.4)(@vitest/coverage-v8@4.1.6)(@vitest/ui@4.1.6)(esbuild@0.28.1)(jiti@2.7.0)(jsdom@29.1.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4) '@vitest/utils@3.2.4': dependencies: @@ -27944,6 +27893,8 @@ snapshots: bail@2.0.2: {} + balanced-match@1.0.2: {} + balanced-match@4.0.4: {} bare-events@2.8.2: {} @@ -28058,6 +28009,11 @@ snapshots: dependencies: big-integer: 1.6.52 + brace-expansion@1.1.12: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + brace-expansion@5.0.9: dependencies: balanced-match: 4.0.4 @@ -28591,6 +28547,8 @@ snapshots: compute-scroll-into-view@2.0.4: {} + concat-map@0.0.1: {} + concat-stream@1.6.2: dependencies: buffer-from: 1.1.2 @@ -29069,7 +29027,7 @@ snapshots: detective-typescript@14.0.0(typescript@5.9.3): dependencies: - '@typescript-eslint/typescript-estree': 8.57.0(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.67.0(typescript@5.9.3) ast-module-types: 6.0.1 node-source-walk: 7.0.1 typescript: 5.9.3 @@ -29516,6 +29474,13 @@ snapshots: typescript: 6.0.3 yaml: 2.8.4 + eslint-plugin-zod-utils@1.0.11(typescript@5.9.3): + dependencies: + '@typescript-eslint/utils': 8.67.0(typescript@5.9.3) + transitivePeerDependencies: + - supports-color + - typescript + eslint-scope@5.1.1: dependencies: esrecurse: 4.3.0 @@ -29545,7 +29510,7 @@ snapshots: '@humanfs/node': 0.16.8 '@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/retry': 0.4.3 - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 ajv: 6.14.0 chalk: 4.1.2 cross-spawn: 7.0.6 @@ -29608,7 +29573,7 @@ snapshots: estree-util-attach-comments@3.0.0: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 estree-util-build-jsx@3.0.1: dependencies: @@ -29621,7 +29586,7 @@ snapshots: estree-util-scope@1.0.0: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 devlop: 1.1.0 estree-util-to-js@2.0.0: @@ -29639,7 +29604,7 @@ snapshots: estree-walker@3.0.3: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 esutils@2.0.3: {} @@ -30954,7 +30919,7 @@ snapshots: hast-util-to-estree@3.1.3: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 '@types/estree-jsx': 1.0.5 '@types/hast': 3.0.4 comma-separated-tokens: 2.0.3 @@ -30975,7 +30940,7 @@ snapshots: hast-util-to-jsx-runtime@2.3.6: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 '@types/hast': 3.0.4 '@types/unist': 3.0.3 comma-separated-tokens: 2.0.3 @@ -31258,6 +31223,38 @@ snapshots: - bufferutil - utf-8-validate + ink@6.8.0(react@19.2.6): + dependencies: + '@alcalzone/ansi-tokenize': 0.2.5 + ansi-escapes: 7.3.0 + ansi-styles: 6.2.3 + auto-bind: 5.0.1 + chalk: 5.6.2 + cli-boxes: 3.0.0 + cli-cursor: 4.0.0 + cli-truncate: 5.2.0 + code-excerpt: 4.0.0 + es-toolkit: 1.45.1 + indent-string: 5.0.0 + is-in-ci: 2.0.0 + patch-console: 2.0.0 + react: 19.2.6 + react-reconciler: 0.33.0(react@19.2.6) + scheduler: 0.27.0 + signal-exit: 3.0.7 + slice-ansi: 8.0.0 + stack-utils: 2.0.6 + string-width: 8.2.0 + terminal-size: 4.0.1 + type-fest: 5.5.0 + widest-line: 6.0.0 + wrap-ansi: 9.0.2 + ws: 8.21.0 + yoga-layout: 3.2.1 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + inline-style-parser@0.2.7: {} internmap@2.0.3: {} @@ -31384,7 +31381,7 @@ snapshots: is-reference@1.2.1: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 is-regex@1.2.1: dependencies: @@ -33267,7 +33264,7 @@ snapshots: micromark-extension-mdx-expression@3.0.1: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 devlop: 1.1.0 micromark-factory-mdx-expression: 2.0.3 micromark-factory-space: 2.0.1 @@ -33278,7 +33275,7 @@ snapshots: micromark-extension-mdx-jsx@3.0.2: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 devlop: 1.1.0 estree-util-is-identifier-name: 3.0.0 micromark-factory-mdx-expression: 2.0.3 @@ -33295,7 +33292,7 @@ snapshots: micromark-extension-mdxjs-esm@3.0.0: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 devlop: 1.1.0 micromark-core-commonmark: 2.0.3 micromark-util-character: 2.1.1 @@ -33331,7 +33328,7 @@ snapshots: micromark-factory-mdx-expression@2.0.3: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 devlop: 1.1.0 micromark-factory-space: 2.0.1 micromark-util-character: 2.1.1 @@ -33395,7 +33392,7 @@ snapshots: micromark-util-events-to-acorn@2.0.3: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 '@types/unist': 3.0.3 devlop: 1.1.0 estree-util-visit: 2.0.0 @@ -33574,7 +33571,7 @@ snapshots: minimatch@3.1.5: dependencies: - brace-expansion: 5.0.9 + brace-expansion: 1.1.12 minimatch@5.1.9: dependencies: @@ -35560,7 +35557,7 @@ snapshots: recma-build-jsx@1.0.0: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 estree-util-build-jsx: 3.0.1 vfile: 6.0.3 @@ -35575,14 +35572,14 @@ snapshots: recma-parse@1.0.0: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 esast-util-from-js: 2.0.1 unified: 11.0.5 vfile: 6.0.3 recma-stringify@1.0.0: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 estree-util-to-js: 2.0.0 unified: 11.0.5 vfile: 6.0.3 @@ -35654,7 +35651,7 @@ snapshots: rehype-recma@1.0.0: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 '@types/hast': 3.0.4 hast-util-to-estree: 3.1.3 transitivePeerDependencies: @@ -38073,40 +38070,6 @@ snapshots: - bufferutil - utf-8-validate - wrangler@4.98.0(@types/node@24.12.4)(bufferutil@4.1.0)(utf-8-validate@6.0.6): - dependencies: - '@cloudflare/kv-asset-handler': 0.5.0 - '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260603.1) - blake3-wasm: 2.1.5 - esbuild: 0.28.1 - miniflare: 4.20260603.0(@types/node@24.12.4)(bufferutil@4.1.0)(utf-8-validate@6.0.6) - path-to-regexp: 8.4.2 - unenv: 2.0.0-rc.24 - workerd: 1.20260603.1 - optionalDependencies: - fsevents: 2.3.3 - transitivePeerDependencies: - - '@types/node' - - bufferutil - - utf-8-validate - - wrangler@4.98.0(@types/node@25.5.2)(bufferutil@4.1.0)(utf-8-validate@6.0.6): - dependencies: - '@cloudflare/kv-asset-handler': 0.5.0 - '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260603.1) - blake3-wasm: 2.1.5 - esbuild: 0.28.1 - miniflare: 4.20260603.0(@types/node@25.5.2)(bufferutil@4.1.0)(utf-8-validate@6.0.6) - path-to-regexp: 8.4.2 - unenv: 2.0.0-rc.24 - workerd: 1.20260603.1 - optionalDependencies: - fsevents: 2.3.3 - transitivePeerDependencies: - - '@types/node' - - bufferutil - - utf-8-validate - wrap-ansi@10.0.0: dependencies: ansi-styles: 6.2.3 @@ -38155,6 +38118,8 @@ snapshots: bufferutil: 4.1.0 utf-8-validate: 6.0.6 + ws@8.21.0: {} + ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6): optionalDependencies: bufferutil: 4.1.0 diff --git a/tools/oxlint/anti-slop/LICENSE b/tools/oxlint/anti-slop/LICENSE new file mode 100644 index 0000000000..69239ead1e --- /dev/null +++ b/tools/oxlint/anti-slop/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Dillon Mulroy + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/tools/oxlint/anti-slop/index.ts b/tools/oxlint/anti-slop/index.ts new file mode 100644 index 0000000000..c255923277 --- /dev/null +++ b/tools/oxlint/anti-slop/index.ts @@ -0,0 +1,41 @@ +import { eslintCompatPlugin } from '@oxlint/plugins'; + +import { noChainedTypeAssertionsRule } from './rules/no-chained-type-assertions.ts'; +import { noConditionalEmptyObjectSpreadRule } from './rules/no-conditional-empty-object-spread.ts'; +import { noKnownValueWideningRule } from './rules/no-known-value-widening.ts'; +import { noModuleMockingRule } from './rules/no-module-mocking.ts'; +import { noObjectParametersRule } from './rules/no-object-parameters.ts'; +import { noReflectApplyRule } from './rules/no-reflect-apply.ts'; +import { noReflectGetRule } from './rules/no-reflect-get.ts'; +import { noRuntimeTypeofRule } from './rules/no-runtime-typeof.ts'; +import { noForbiddenTermInSymbolNamesRule } from './rules/no-shape-in-symbol-names.ts'; +import { noUnknownParametersRule } from './rules/no-unknown-parameters.ts'; +import { noUnknownReturnsRule } from './rules/no-unknown-returns.ts'; +import { noUnknownTypeAliasesRule } from './rules/no-unknown-type-aliases.ts'; +import { noUnsafeDictionaryTypeRule } from './rules/no-unsafe-dictionary-type.ts'; +import { noWidenThenAssertRule } from './rules/no-widen-then-assert.ts'; +import { requireSafetyCommentForTypeAssertionRule } from './rules/require-safety-comment-for-type-assertion.ts'; + +/** Generic Oxlint rules that reject low-evidence and low-signal implementation patterns. */ +const antiSlopPlugin = eslintCompatPlugin({ + meta: { name: 'anti-slop' }, + rules: { + 'no-chained-type-assertions': noChainedTypeAssertionsRule, + 'no-conditional-empty-object-spread': noConditionalEmptyObjectSpreadRule, + 'no-known-value-widening': noKnownValueWideningRule, + 'no-module-mocking': noModuleMockingRule, + 'no-object-parameters': noObjectParametersRule, + 'no-reflect-apply': noReflectApplyRule, + 'no-reflect-get': noReflectGetRule, + 'no-runtime-typeof': noRuntimeTypeofRule, + 'no-unsafe-dictionary-type': noUnsafeDictionaryTypeRule, + 'no-shape-in-symbol-names': noForbiddenTermInSymbolNamesRule, + 'no-unknown-parameters': noUnknownParametersRule, + 'no-unknown-returns': noUnknownReturnsRule, + 'no-unknown-type-aliases': noUnknownTypeAliasesRule, + 'no-widen-then-assert': noWidenThenAssertRule, + 'require-safety-comment-for-type-assertion': requireSafetyCommentForTypeAssertionRule, + }, +}); + +export default antiSlopPlugin; diff --git a/tools/oxlint/anti-slop/rules/no-chained-type-assertions.ts b/tools/oxlint/anti-slop/rules/no-chained-type-assertions.ts new file mode 100644 index 0000000000..d819f80a7f --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-chained-type-assertions.ts @@ -0,0 +1,77 @@ +import { defineRule } from '@oxlint/plugins'; +import type { ESTree } from '@oxlint/plugins'; + +type TypeAssertionExpression = ESTree.TSAsExpression | ESTree.TSTypeAssertion; + +function isTypeAssertionExpression(node: ESTree.Node): node is TypeAssertionExpression { + return node.type === 'TSAsExpression' || node.type === 'TSTypeAssertion'; +} + +function unwrapParenthesizedExpression(expression: ESTree.Expression): ESTree.Expression { + let current = expression; + while (current.type === 'ParenthesizedExpression') { + current = current.expression; + } + return current; +} + +function isConstAssertion(node: TypeAssertionExpression): boolean { + const { typeAnnotation } = node; + return ( + typeAnnotation.type === 'TSTypeReference' && + typeAnnotation.typeName.type === 'Identifier' && + typeAnnotation.typeName.name === 'const' + ); +} + +function isOutermostAssertionInChain(node: TypeAssertionExpression): boolean { + let current: ESTree.Expression = node; + let parent = node.parent; + + while (parent.type === 'ParenthesizedExpression' && parent.expression === current) { + current = parent; + parent = parent.parent; + } + + return !isTypeAssertionExpression(parent) || parent.expression !== current; +} + +function isForbiddenAssertionChain(node: TypeAssertionExpression): boolean { + let assertionCount = 0; + let hasNonConstAssertion = false; + let current: ESTree.Expression = node; + + while (isTypeAssertionExpression(current)) { + assertionCount += 1; + hasNonConstAssertion ||= !isConstAssertion(current); + current = unwrapParenthesizedExpression(current.expression); + } + + return assertionCount > 1 && hasNonConstAssertion; +} + +/** Disallow nested TypeScript type assertions, while permitting chains made only of const assertions. */ +export const noChainedTypeAssertionsRule = defineRule({ + meta: { + type: 'problem', + docs: { + description: + 'Disallow chained TypeScript as and angle-bracket assertions, including parenthesized chains.', + }, + messages: { + chained: + 'This assertion chain discards type evidence. Keep the original precise type, or parse untrusted input at its boundary before narrowing it.', + }, + }, + createOnce(context) { + const checkTypeAssertion = (node: TypeAssertionExpression) => { + if (!isOutermostAssertionInChain(node) || !isForbiddenAssertionChain(node)) return; + context.report({ node, messageId: 'chained' }); + }; + + return { + TSAsExpression: checkTypeAssertion, + TSTypeAssertion: checkTypeAssertion, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-conditional-empty-object-spread.ts b/tools/oxlint/anti-slop/rules/no-conditional-empty-object-spread.ts new file mode 100644 index 0000000000..9ee2edfa8b --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-conditional-empty-object-spread.ts @@ -0,0 +1,49 @@ +import { defineRule } from '@oxlint/plugins'; +import type { ESTree } from '@oxlint/plugins'; + +function unwrapParentheses(node: ESTree.Expression): ESTree.Expression { + let current = node; + while (current.type === 'ParenthesizedExpression') { + current = current.expression; + } + return current; +} + +function isEmptyObjectExpression(node: ESTree.Expression): boolean { + return node.type === 'ObjectExpression' && node.properties.length === 0; +} + +function isConditionalEmptyObjectSpread(node: ESTree.Expression): boolean { + const conditional = unwrapParentheses(node); + return ( + conditional.type === 'ConditionalExpression' && + (isEmptyObjectExpression(conditional.consequent) || + isEmptyObjectExpression(conditional.alternate)) + ); +} + +/** Ban conditional empty-object spreads without changing their omission semantics. */ +export const noConditionalEmptyObjectSpreadRule = defineRule({ + meta: { + type: 'suggestion', + docs: { + description: + 'Disallow object spreads that conditionally spread an empty object to omit fields.', + }, + messages: { + avoid: + 'This conditional spread hides property omission behind an empty object. Build the object in separate statements and add the property only when present.', + }, + }, + createOnce(context) { + return { + SpreadElement(node) { + if (node.parent.type !== 'ObjectExpression') return; + + if (isConditionalEmptyObjectSpread(node.argument)) { + context.report({ node, messageId: 'avoid' }); + } + }, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-known-value-widening.ts b/tools/oxlint/anti-slop/rules/no-known-value-widening.ts new file mode 100644 index 0000000000..57c0b9a4cd --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-known-value-widening.ts @@ -0,0 +1,244 @@ +import { defineRule } from '@oxlint/plugins'; + +import { + classifyWideningTarget, + createTypeEnvironment, + isKnownEvidenceExpression, + type TypeEnvironment, + type WideningTarget, +} from '../shared/dictionary-types.ts'; + +import type { ESTree, Scope, SourceCode, Variable } from '@oxlint/plugins'; + +type FunctionExpression = ESTree.ArrowFunctionExpression | ESTree.Function; + +function unwrapExpression(expression: ESTree.Expression): ESTree.Expression { + let current = expression; + while ( + current.type === 'ParenthesizedExpression' || + current.type === 'TSAsExpression' || + current.type === 'TSSatisfiesExpression' || + current.type === 'TSTypeAssertion' || + current.type === 'TSNonNullExpression' + ) { + current = current.expression; + } + return current; +} + +function resolveVariable( + sourceCode: SourceCode, + identifier: ESTree.IdentifierReference +): Variable | null { + let scope: Scope | null = sourceCode.getScope(identifier); + while (scope !== null) { + const variable = scope.set.get(identifier.name); + if (variable !== undefined) return variable; + scope = scope.upper; + } + return null; +} + +function variableDeclarator(variable: Variable): ESTree.VariableDeclarator | null { + if (variable.defs.length !== 1) return null; + const [definition] = variable.defs; + return definition?.type === 'Variable' && definition.node.type === 'VariableDeclarator' + ? definition.node + : null; +} + +function isStableConstVariable(variable: Variable, declarator: ESTree.VariableDeclarator): boolean { + return ( + declarator.parent.type === 'VariableDeclaration' && + declarator.parent.kind === 'const' && + variable.references.every(reference => reference.init || !reference.isWrite()) + ); +} + +function hasKnownEvidence( + sourceCode: SourceCode, + expression: ESTree.Expression, + visitedVariables = new Set() +): boolean { + if (isKnownEvidenceExpression(expression)) return true; + const unwrapped = unwrapExpression(expression); + if (unwrapped.type !== 'Identifier') return false; + const variable = resolveVariable(sourceCode, unwrapped); + if (variable === null || visitedVariables.has(variable)) return false; + const declarator = variableDeclarator(variable); + if ( + declarator === null || + declarator.init === null || + !isStableConstVariable(variable, declarator) + ) { + return false; + } + visitedVariables.add(variable); + return hasKnownEvidence(sourceCode, declarator.init, visitedVariables); +} + +function annotationTarget( + annotation: ESTree.TSTypeAnnotation | null | undefined, + environment: TypeEnvironment +): WideningTarget | null { + return annotation === null || annotation === undefined + ? null + : classifyWideningTarget(annotation.typeAnnotation, environment); +} + +function enclosingFunction(node: ESTree.Node): FunctionExpression | null { + let current: ESTree.Node | null = node.parent; + while (current !== null && current.type !== 'Program') { + if ( + current.type === 'ArrowFunctionExpression' || + current.type === 'FunctionDeclaration' || + current.type === 'FunctionExpression' + ) { + return current; + } + current = current.parent; + } + return null; +} + +function sourceKeyName(sourceCode: SourceCode, key: ESTree.PropertyKey): string { + if (key.type === 'Identifier' || key.type === 'PrivateIdentifier') return key.name; + if (key.type === 'Literal') return String(key.value); + return sourceCode.getText(key); +} + +function functionName(sourceCode: SourceCode, owner: FunctionExpression | null): string { + if (owner === null) return 'anonymous function'; + if (owner.id !== null) return owner.id.name; + const parent = owner.parent; + if (parent.type === 'VariableDeclarator' && parent.id.type === 'Identifier') + return parent.id.name; + if (parent.type === 'MethodDefinition') return sourceKeyName(sourceCode, parent.key); + return 'anonymous function'; +} + +function isEmptyObjectExpression(expression: ESTree.Expression): boolean { + const unwrapped = unwrapExpression(expression); + return unwrapped.type === 'ObjectExpression' && unwrapped.properties.length === 0; +} + +function isDictionaryAccumulatorTarget(destination: WideningTarget): boolean { + return destination.kind === 'open dictionary' || destination.kind === 'generic container'; +} + +function hasParentAssertion(node: ESTree.Node): boolean { + return node.parent?.type === 'TSAsExpression' || node.parent?.type === 'TSTypeAssertion'; +} + +/** Detect sound syntactic cases where a known value is explicitly widened and loses evidence. */ +export const noKnownValueWideningRule = defineRule({ + meta: { + type: 'problem', + docs: { + description: + 'Disallow syntactically established values from flowing into explicitly broad or anonymous target types that discard useful evidence.', + }, + messages: { + widening: + 'The explicit {{target}} type on {{subject}} discards known type evidence. Keep inference, validate with `satisfies`, or use a named owner contract.', + }, + }, + createOnce(context) { + let environment: TypeEnvironment | null = null; + + const reportFlow = ( + expression: ESTree.Expression, + destination: WideningTarget | null, + subject: string + ) => { + if (destination === null) return; + if (isDictionaryAccumulatorTarget(destination) && isEmptyObjectExpression(expression)) { + return; + } + if (!hasKnownEvidence(context.sourceCode, expression)) return; + context.report({ + node: expression, + messageId: 'widening', + data: { subject, target: destination.kind }, + }); + }; + + const targetFromAnnotation = (annotation: ESTree.TSTypeAnnotation | null | undefined) => + environment === null ? null : annotationTarget(annotation, environment); + + return { + Program(node) { + environment = createTypeEnvironment(node); + }, + VariableDeclarator(node) { + if (node.init === null || node.id.type !== 'Identifier') return; + reportFlow( + node.init, + targetFromAnnotation(node.id.typeAnnotation), + `binding \`${node.id.name}\`` + ); + }, + PropertyDefinition(node) { + if (node.value === null) return; + reportFlow( + node.value, + targetFromAnnotation(node.typeAnnotation), + `property \`${sourceKeyName(context.sourceCode, node.key)}\`` + ); + }, + AccessorProperty(node) { + if (node.value === null) return; + reportFlow( + node.value, + targetFromAnnotation(node.typeAnnotation), + `property \`${sourceKeyName(context.sourceCode, node.key)}\`` + ); + }, + AssignmentExpression(node) { + if (node.operator !== '=' || node.left.type !== 'Identifier') return; + const variable = resolveVariable(context.sourceCode, node.left); + if (variable === null) return; + const declarator = variableDeclarator(variable); + if (declarator === null || declarator.id.type !== 'Identifier') return; + reportFlow( + node.right, + targetFromAnnotation(declarator.id.typeAnnotation), + `binding \`${declarator.id.name}\`` + ); + }, + ReturnStatement(node) { + if (node.argument === null) return; + const owner = enclosingFunction(node); + reportFlow( + node.argument, + targetFromAnnotation(owner?.returnType), + `return value of \`${functionName(context.sourceCode, owner)}\`` + ); + }, + ArrowFunctionExpression(node) { + if (node.body.type === 'BlockStatement') return; + reportFlow( + node.body, + targetFromAnnotation(node.returnType), + `return value of \`${functionName(context.sourceCode, node)}\`` + ); + }, + TSAsExpression(node) { + if (environment === null || hasParentAssertion(node)) return; + reportFlow( + node.expression, + classifyWideningTarget(node.typeAnnotation, environment), + 'assertion' + ); + }, + TSTypeAssertion(node) { + if (environment === null || hasParentAssertion(node)) return; + reportFlow( + node.expression, + classifyWideningTarget(node.typeAnnotation, environment), + 'assertion' + ); + }, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-module-mocking.ts b/tools/oxlint/anti-slop/rules/no-module-mocking.ts new file mode 100644 index 0000000000..91d63138d2 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-module-mocking.ts @@ -0,0 +1,93 @@ +import { defineRule } from '@oxlint/plugins'; + +import type { ESTree, Scope, SourceCode, Variable } from '@oxlint/plugins'; + +const moduleMockMethods = new Set(['doMock', 'mock', 'unstable_mockModule']); + +function resolveVariable( + sourceCode: SourceCode, + identifier: ESTree.IdentifierReference +): Variable | null { + let scope: Scope | null = sourceCode.getScope(identifier); + while (scope !== null) { + const variable = scope.set.get(identifier.name); + if (variable !== undefined) return variable; + scope = scope.upper; + } + return null; +} + +function importedName(node: ESTree.Node): string | null { + if (node.type !== 'ImportSpecifier') return null; + return node.imported.type === 'Identifier' ? node.imported.name : node.imported.value; +} + +function isTestFrameworkObject( + sourceCode: SourceCode, + expression: ESTree.Expression +): expression is ESTree.IdentifierReference { + if (expression.type !== 'Identifier') return false; + if ( + (expression.name === 'vi' || expression.name === 'jest') && + sourceCode.isGlobalReference(expression) + ) { + return true; + } + + const variable = resolveVariable(sourceCode, expression); + if (variable === null || variable.defs.length === 0) { + return expression.name === 'vi' || expression.name === 'jest'; + } + return variable.defs.some(definition => { + if (definition.type !== 'ImportBinding' || definition.parent?.type !== 'ImportDeclaration') { + return false; + } + const source = definition.parent.source.value; + const name = importedName(definition.node); + return ( + (source === 'vitest' && name === 'vi') || (source === '@jest/globals' && name === 'jest') + ); + }); +} + +function moduleMockCall(sourceCode: SourceCode, callee: ESTree.Expression): boolean { + if (!('property' in callee) || !('object' in callee) || !('computed' in callee)) return false; + if (!isTestFrameworkObject(sourceCode, callee.object)) return false; + const property = callee.property; + const method = callee.computed + ? property.type === 'Literal' && + (property.value === 'doMock' || + property.value === 'mock' || + property.value === 'unstable_mockModule') + ? property.value + : null + : property.type === 'Identifier' + ? property.name + : null; + return method !== null && moduleMockMethods.has(method); +} + +/** Ban test framework module mocking in favor of real dependency seams. */ +export const noModuleMockingRule = defineRule({ + meta: { + type: 'problem', + docs: { + description: + 'Disallow Vitest and Jest module mocking; tests must replace dependencies through real interfaces.', + }, + messages: { + moduleMock: + 'Replace module mocking with dependency injection through a real interface, service layer, or faithful test implementation.', + }, + }, + createOnce(context) { + return { + CallExpression(node) { + if (node.callee.type === 'Super' || node.callee.type === 'V8IntrinsicExpression') return; + if (moduleMockCall(context.sourceCode, node.callee)) { + context.report({ node, messageId: 'moduleMock' }); + } + }, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-object-parameters.ts b/tools/oxlint/anti-slop/rules/no-object-parameters.ts new file mode 100644 index 0000000000..b453d85ddc --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-object-parameters.ts @@ -0,0 +1,121 @@ +import { defineRule } from '@oxlint/plugins'; + +import type { ESTree, SourceCode } from '@oxlint/plugins'; + +import { lexicalTypeParameterNames } from '../shared/lexical-type-parameters.ts'; + +type Parameter = ESTree.ParamPattern; +type ParameterOwner = + | ESTree.ArrowFunctionExpression + | ESTree.Function + | ESTree.TSCallSignatureDeclaration + | ESTree.TSConstructSignatureDeclaration + | ESTree.TSConstructorType + | ESTree.TSFunctionType + | ESTree.TSMethodSignature; + +function parameterAnnotation(parameter: Parameter): ESTree.TSTypeAnnotation | null | undefined { + if (parameter.type === 'TSParameterProperty') { + return parameterAnnotation(parameter.parameter); + } + if (parameter.type === 'RestElement') { + return parameter.typeAnnotation ?? parameterAnnotation(parameter.argument); + } + if (parameter.type === 'AssignmentPattern') { + return parameter.typeAnnotation ?? parameter.left.typeAnnotation; + } + return parameter.typeAnnotation; +} + +function parameterName(parameter: Parameter, sourceCode: SourceCode): string { + return parameter.type === 'Identifier' + ? parameter.name + : sourceCode.getText(parameter).replace(/\s*:\s*object\s*$/u, ''); +} + +/** Ban the broad object type on function inputs, including local aliases to object. */ +export const noObjectParametersRule = defineRule({ + meta: { + type: 'problem', + docs: { + description: + 'Disallow object function parameters; inputs must use an owner-provided type and be parsed at their boundary.', + }, + messages: { + objectParameter: + 'Parameter `{{parameter}}` uses the broad `object` type. Accept a named owner type; parse external input at its boundary before calling this function.', + }, + }, + createOnce(context) { + const aliases = new Map(); + + const resolvesToObject = ( + type: ESTree.TSType, + shadowedAliases: ReadonlySet, + visited = new Set() + ): boolean => { + if (type.type === 'TSObjectKeyword') return true; + if (type.type === 'TSParenthesizedType') + return resolvesToObject(type.typeAnnotation, shadowedAliases, visited); + if (type.type === 'TSUnionType') { + return type.types.some(member => resolvesToObject(member, shadowedAliases, visited)); + } + if ( + type.type !== 'TSTypeReference' || + type.typeName.type !== 'Identifier' || + (type.typeArguments !== null && + type.typeArguments !== undefined && + type.typeArguments.params.length > 0) || + visited.has(type.typeName.name) || + shadowedAliases.has(type.typeName.name) + ) { + return false; + } + const alias = aliases.get(type.typeName.name); + if (alias === undefined) return false; + const nextVisited = new Set(visited); + nextVisited.add(type.typeName.name); + return resolvesToObject(alias, shadowedAliases, nextVisited); + }; + + const checkParameters = (node: ParameterOwner) => { + const shadowedAliases = lexicalTypeParameterNames(node, context.sourceCode.visitorKeys); + for (const parameter of node.params) { + const annotation = parameterAnnotation(parameter); + if (annotation === null || annotation === undefined) continue; + if (!resolvesToObject(annotation.typeAnnotation, shadowedAliases)) continue; + context.report({ + node: annotation.typeAnnotation, + messageId: 'objectParameter', + data: { parameter: parameterName(parameter, context.sourceCode) }, + }); + } + }; + + return { + Program(node) { + aliases.clear(); + for (const statement of node.body) { + const declaration = + statement.type === 'ExportNamedDeclaration' ? statement.declaration : statement; + if ( + declaration?.type === 'TSTypeAliasDeclaration' && + (declaration.typeParameters === null || declaration.typeParameters === undefined) + ) { + aliases.set(declaration.id.name, declaration.typeAnnotation); + } + } + }, + ArrowFunctionExpression: checkParameters, + FunctionDeclaration: checkParameters, + FunctionExpression: checkParameters, + TSCallSignatureDeclaration: checkParameters, + TSConstructSignatureDeclaration: checkParameters, + TSConstructorType: checkParameters, + TSDeclareFunction: checkParameters, + TSEmptyBodyFunctionExpression: checkParameters, + TSFunctionType: checkParameters, + TSMethodSignature: checkParameters, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-reflect-apply.ts b/tools/oxlint/anti-slop/rules/no-reflect-apply.ts new file mode 100644 index 0000000000..8b7232c525 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-reflect-apply.ts @@ -0,0 +1,28 @@ +import { defineRule } from '@oxlint/plugins'; + +import { isGlobalReflectMethodCall } from '../shared/reflect-method.ts'; + +/** Ban Reflect.apply, which bypasses ordinary typed function calls. */ +export const noReflectApplyRule = defineRule({ + meta: { + type: 'problem', + docs: { + description: + 'Disallow Reflect.apply; call typed functions directly or model dynamic dispatch behind an interface.', + }, + messages: { + reflectApply: + 'Replace `Reflect.apply` with a typed function call. Model dynamic dispatch behind a named interface.', + }, + }, + createOnce(context) { + return { + CallExpression(node) { + if (node.callee.type === 'Super' || node.callee.type === 'V8IntrinsicExpression') return; + if (isGlobalReflectMethodCall(context.sourceCode, node.callee, 'apply')) { + context.report({ node, messageId: 'reflectApply' }); + } + }, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-reflect-get.ts b/tools/oxlint/anti-slop/rules/no-reflect-get.ts new file mode 100644 index 0000000000..a24e78d231 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-reflect-get.ts @@ -0,0 +1,28 @@ +import { defineRule } from '@oxlint/plugins'; + +import { isGlobalReflectMethodCall } from '../shared/reflect-method.ts'; + +/** Ban Reflect.get, which bypasses ordinary property access and useful type evidence. */ +export const noReflectGetRule = defineRule({ + meta: { + type: 'problem', + docs: { + description: + 'Disallow Reflect.get; use typed property access or parse dynamic input into a domain type.', + }, + messages: { + reflectGet: + 'Replace `Reflect.get` with typed property access. Parse dynamic input into a named domain type before reading it.', + }, + }, + createOnce(context) { + return { + CallExpression(node) { + if (node.callee.type === 'Super' || node.callee.type === 'V8IntrinsicExpression') return; + if (isGlobalReflectMethodCall(context.sourceCode, node.callee, 'get')) { + context.report({ node, messageId: 'reflectGet' }); + } + }, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-runtime-typeof.ts b/tools/oxlint/anti-slop/rules/no-runtime-typeof.ts new file mode 100644 index 0000000000..7b8f29ae02 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-runtime-typeof.ts @@ -0,0 +1,64 @@ +import { defineRule } from '@oxlint/plugins'; + +import type { ESTree } from '@oxlint/plugins'; + +type RuntimeFunction = ESTree.ArrowFunctionExpression | ESTree.Function; + +function isRuntimeFunction(node: ESTree.Node): node is RuntimeFunction { + return ( + node.type === 'ArrowFunctionExpression' || + node.type === 'FunctionDeclaration' || + node.type === 'FunctionExpression' + ); +} + +function isInsideTypeGuard(node: ESTree.Node): boolean { + let current: ESTree.Node | null = node.parent; + while (current !== null && current.type !== 'Program') { + if (isRuntimeFunction(current)) { + return current.returnType?.typeAnnotation.type === 'TSTypePredicate'; + } + current = current.parent; + } + return false; +} + +/** Disallow runtime typeof checks that narrow unparsed values instead of decoding them. */ +export const noRuntimeTypeofRule = defineRule({ + meta: { + type: 'problem', + docs: { + description: + 'Disallow runtime typeof checks; external values must be decoded into meaningful types at their I/O boundary.', + }, + messages: { + runtimeTypeof: + 'A `typeof` check narrows a representation without establishing its contract. Parse input at its I/O boundary, then branch on the domain value.', + }, + schema: [ + { + type: 'object', + properties: { + allowInTypeGuards: { type: 'boolean' }, + }, + additionalProperties: false, + }, + ], + defaultOptions: [{ allowInTypeGuards: false }], + }, + createOnce(context) { + return { + UnaryExpression(node) { + const option = context.options?.[0]; + const allowInTypeGuards = + typeof option === 'object' && + option !== null && + !Array.isArray(option) && + option.allowInTypeGuards === true; + if (node.operator === 'typeof' && (!allowInTypeGuards || !isInsideTypeGuard(node))) { + context.report({ node, messageId: 'runtimeTypeof' }); + } + }, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-shape-in-symbol-names.ts b/tools/oxlint/anti-slop/rules/no-shape-in-symbol-names.ts new file mode 100644 index 0000000000..458ed7d11f --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-shape-in-symbol-names.ts @@ -0,0 +1,39 @@ +import { defineRule } from '@oxlint/plugins'; +import type { ESTree } from '@oxlint/plugins'; + +const FORBIDDEN_SYMBOL_NAME = 'shape'; + +function containsForbiddenSymbolName(name: string): boolean { + return name.toLowerCase().includes(FORBIDDEN_SYMBOL_NAME); +} + +/** Ban the case-insensitive substring "shape" in every JavaScript and TypeScript symbol name. */ +export const noForbiddenTermInSymbolNamesRule = defineRule({ + meta: { + type: 'problem', + docs: { + description: + 'Disallow the case-insensitive substring "shape" in JavaScript, TypeScript, private, and JSX symbol names.', + }, + messages: { + forbiddenSymbolName: + 'Rename symbol "{{name}}" for its domain role; "shape" describes structure rather than ownership.', + }, + }, + createOnce(context) { + const reportForbiddenSymbolName = (node: ESTree.Node & { name: string }) => { + if (!containsForbiddenSymbolName(node.name)) return; + context.report({ + node, + messageId: 'forbiddenSymbolName', + data: { name: node.name }, + }); + }; + + return { + Identifier: reportForbiddenSymbolName, + PrivateIdentifier: reportForbiddenSymbolName, + JSXIdentifier: reportForbiddenSymbolName, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-unknown-parameters.ts b/tools/oxlint/anti-slop/rules/no-unknown-parameters.ts new file mode 100644 index 0000000000..a6442faa62 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-unknown-parameters.ts @@ -0,0 +1,83 @@ +import { defineRule } from '@oxlint/plugins'; +import type { ESTree } from '@oxlint/plugins'; + +type Parameter = ESTree.ParamPattern; +type ParameterOwner = + | ESTree.ArrowFunctionExpression + | ESTree.Function + | ESTree.TSCallSignatureDeclaration + | ESTree.TSConstructSignatureDeclaration + | ESTree.TSConstructorType + | ESTree.TSFunctionType + | ESTree.TSMethodSignature; + +function parameterAnnotation(parameter: Parameter): ESTree.TSTypeAnnotation | null | undefined { + if (parameter.type === 'TSParameterProperty') { + return parameterAnnotation(parameter.parameter); + } + if (parameter.type === 'RestElement') { + return parameter.typeAnnotation ?? parameterAnnotation(parameter.argument); + } + if (parameter.type === 'AssignmentPattern') { + return parameter.typeAnnotation ?? parameter.left.typeAnnotation; + } + return parameter.typeAnnotation; +} + +function parameterName(parameter: Parameter, sourceText: string): string { + if (parameter.type === 'TSParameterProperty') { + return parameterName(parameter.parameter, sourceText); + } + if (parameter.type === 'AssignmentPattern') { + return parameterName(parameter.left, sourceText); + } + if (parameter.type === 'RestElement') { + return parameterName(parameter.argument, sourceText); + } + return parameter.type === 'Identifier' + ? parameter.name + : sourceText.replace(/\s*:\s*unknown\s*$/u, ''); +} + +/** Disallow unknown inputs except explicitly named error-cause enrichment. */ +export const noUnknownParametersRule = defineRule({ + meta: { + type: 'problem', + docs: { + description: + 'Disallow explicitly unknown function parameters except `cause`; decode unknown input at its I/O boundary instead.', + }, + messages: { + unknownParameter: + 'Parameter `{{parameter}}` leaves input unparsed. Accept a named domain type; run the expected schema or parser at the I/O boundary before calling this function.', + }, + }, + createOnce(context) { + const checkParameters = (node: ParameterOwner) => { + for (const parameter of node.params) { + const annotation = parameterAnnotation(parameter); + if (annotation?.typeAnnotation.type !== 'TSUnknownKeyword') continue; + const name = parameterName(parameter, context.sourceCode.getText(parameter)); + if (name === 'cause') continue; + context.report({ + node: annotation.typeAnnotation, + messageId: 'unknownParameter', + data: { parameter: name }, + }); + } + }; + + return { + ArrowFunctionExpression: checkParameters, + FunctionDeclaration: checkParameters, + FunctionExpression: checkParameters, + TSCallSignatureDeclaration: checkParameters, + TSConstructSignatureDeclaration: checkParameters, + TSConstructorType: checkParameters, + TSDeclareFunction: checkParameters, + TSEmptyBodyFunctionExpression: checkParameters, + TSFunctionType: checkParameters, + TSMethodSignature: checkParameters, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-unknown-returns.ts b/tools/oxlint/anti-slop/rules/no-unknown-returns.ts new file mode 100644 index 0000000000..8c7acc06ae --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-unknown-returns.ts @@ -0,0 +1,113 @@ +import { defineRule } from '@oxlint/plugins'; + +import type { ESTree } from '@oxlint/plugins'; + +import { lexicalTypeParameterNames } from '../shared/lexical-type-parameters.ts'; + +type FunctionWithReturnType = + | ESTree.ArrowFunctionExpression + | ESTree.Function + | ESTree.TSCallSignatureDeclaration + | ESTree.TSConstructSignatureDeclaration + | ESTree.TSConstructorType + | ESTree.TSFunctionType + | ESTree.TSMethodSignature; + +function referencedAliasName(type: ESTree.TSType): string | null { + if (type.type === 'TSParenthesizedType') return referencedAliasName(type.typeAnnotation); + if (type.type !== 'TSTypeReference' || type.typeName.type !== 'Identifier') return null; + return type.typeArguments === null || + type.typeArguments === undefined || + type.typeArguments.params.length === 0 + ? type.typeName.name + : null; +} + +/** Ban function contracts that return unknown instead of a parsed domain type. */ +export const noUnknownReturnsRule = defineRule({ + meta: { + type: 'problem', + docs: { + description: + 'Disallow functions whose explicit return contract is unknown or Promise.', + }, + messages: { + unknownReturn: + 'This function exposes `unknown` to its caller. Parse the value at its boundary and return a named domain type.', + }, + }, + createOnce(context) { + const aliases = new Map(); + + const resolvesToUnknown = ( + type: ESTree.TSType, + shadowedAliases: ReadonlySet, + visited = new Set() + ): boolean => { + if (type.type === 'TSUnknownKeyword') return true; + if (type.type === 'TSParenthesizedType') { + return resolvesToUnknown(type.typeAnnotation, shadowedAliases, visited); + } + if (type.type === 'TSUnionType') { + return type.types.some(member => resolvesToUnknown(member, shadowedAliases, visited)); + } + if ( + type.type === 'TSTypeReference' && + type.typeName.type === 'Identifier' && + (type.typeName.name === 'Promise' || type.typeName.name === 'PromiseLike') + ) { + const value = type.typeArguments?.params[0]; + return value !== undefined && resolvesToUnknown(value, shadowedAliases, visited); + } + const name = referencedAliasName(type); + if (name === null || visited.has(name) || shadowedAliases.has(name)) return false; + const alias = aliases.get(name); + if ( + alias === undefined || + (alias.typeParameters !== null && alias.typeParameters !== undefined) + ) { + return false; + } + const nextVisited = new Set(visited); + nextVisited.add(name); + return resolvesToUnknown(alias.typeAnnotation, shadowedAliases, nextVisited); + }; + + const checkReturnType = (node: FunctionWithReturnType) => { + const annotation = node.returnType; + if (annotation === null || annotation === undefined) return; + if ( + !resolvesToUnknown( + annotation.typeAnnotation, + lexicalTypeParameterNames(node, context.sourceCode.visitorKeys) + ) + ) { + return; + } + context.report({ node: annotation.typeAnnotation, messageId: 'unknownReturn' }); + }; + + return { + Program(node) { + aliases.clear(); + for (const statement of node.body) { + const declaration = + statement.type === 'ExportNamedDeclaration' ? statement.declaration : statement; + if (declaration?.type === 'TSTypeAliasDeclaration') { + aliases.set(declaration.id.name, declaration); + } + } + }, + ArrowFunctionExpression: checkReturnType, + FunctionDeclaration: checkReturnType, + FunctionExpression: checkReturnType, + TSCallSignatureDeclaration: checkReturnType, + TSConstructSignatureDeclaration: checkReturnType, + TSConstructorType: checkReturnType, + TSDeclareFunction: checkReturnType, + TSEmptyBodyFunctionExpression: checkReturnType, + TSFunctionType: checkReturnType, + TSMethodSignature: checkReturnType, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-unknown-type-aliases.ts b/tools/oxlint/anti-slop/rules/no-unknown-type-aliases.ts new file mode 100644 index 0000000000..33e4d5aca4 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-unknown-type-aliases.ts @@ -0,0 +1,70 @@ +import { defineRule } from '@oxlint/plugins'; + +import type { ESTree } from '@oxlint/plugins'; + +function referencedAliasName(type: ESTree.TSType): string | null { + if (type.type === 'TSParenthesizedType') return referencedAliasName(type.typeAnnotation); + if (type.type !== 'TSTypeReference' || type.typeName.type !== 'Identifier') return null; + return type.typeArguments === null || + type.typeArguments === undefined || + type.typeArguments.params.length === 0 + ? type.typeName.name + : null; +} + +/** Ban named aliases that merely conceal TypeScript's unknown top type. */ +export const noUnknownTypeAliasesRule = defineRule({ + meta: { + type: 'problem', + docs: { + description: + 'Disallow type aliases whose resolved type is unknown; unknown must remain visible at an allowed boundary.', + }, + messages: { + unknownAlias: + 'Type alias `{{alias}}` hides `unknown`. Keep `unknown` explicit at the parsing boundary or on an allowed `cause` field; otherwise use the parsed owner type.', + }, + }, + createOnce(context) { + const aliases = new Map(); + + const resolvesToUnknown = (type: ESTree.TSType, visited = new Set()): boolean => { + if (type.type === 'TSUnknownKeyword') return true; + if (type.type === 'TSParenthesizedType') + return resolvesToUnknown(type.typeAnnotation, visited); + const name = referencedAliasName(type); + if (name === null || visited.has(name)) return false; + const alias = aliases.get(name); + if ( + alias === undefined || + (alias.typeParameters !== null && alias.typeParameters !== undefined) + ) { + return false; + } + const nextVisited = new Set(visited); + nextVisited.add(name); + return resolvesToUnknown(alias.typeAnnotation, nextVisited); + }; + + return { + Program(node) { + aliases.clear(); + for (const statement of node.body) { + const declaration = + statement.type === 'ExportNamedDeclaration' ? statement.declaration : statement; + if (declaration?.type === 'TSTypeAliasDeclaration') { + aliases.set(declaration.id.name, declaration); + } + } + for (const alias of aliases.values()) { + if (!resolvesToUnknown(alias.typeAnnotation, new Set([alias.id.name]))) continue; + context.report({ + node: alias.id, + messageId: 'unknownAlias', + data: { alias: alias.id.name }, + }); + } + }, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-unsafe-dictionary-type.ts b/tools/oxlint/anti-slop/rules/no-unsafe-dictionary-type.ts new file mode 100644 index 0000000000..8bf897cafb --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-unsafe-dictionary-type.ts @@ -0,0 +1,134 @@ +import { defineRule } from '@oxlint/plugins'; + +import { + classifyUnsafeDictionary, + classifyUnsafeDictionaryValue, + createTypeEnvironment, + type TypeEnvironment, +} from '../shared/dictionary-types.ts'; + +import type { ESTree } from '@oxlint/plugins'; + +const typeNodeKinds: ReadonlySet = new Set([ + 'JSDocNonNullableType', + 'JSDocNullableType', + 'JSDocUnknownType', + 'TSAnyKeyword', + 'TSArrayType', + 'TSBigIntKeyword', + 'TSBooleanKeyword', + 'TSConditionalType', + 'TSConstructorType', + 'TSFunctionType', + 'TSImportType', + 'TSIndexedAccessType', + 'TSInferType', + 'TSIntersectionType', + 'TSIntrinsicKeyword', + 'TSLiteralType', + 'TSMappedType', + 'TSNamedTupleMember', + 'TSNeverKeyword', + 'TSNullKeyword', + 'TSNumberKeyword', + 'TSObjectKeyword', + 'TSParenthesizedType', + 'TSStringKeyword', + 'TSSymbolKeyword', + 'TSTemplateLiteralType', + 'TSThisType', + 'TSTupleType', + 'TSTypeLiteral', + 'TSTypeOperator', + 'TSTypePredicate', + 'TSTypeQuery', + 'TSTypeReference', + 'TSUndefinedKeyword', + 'TSUnionType', + 'TSUnknownKeyword', + 'TSVoidKeyword', +]); + +function isTypeNode(node: ESTree.Node): node is ESTree.TSType { + return typeNodeKinds.has(node.type); +} + +function typeReferenceName(type: ESTree.TSTypeReference): string | null { + return type.typeName.type === 'Identifier' ? type.typeName.name : null; +} + +function isInsideTypeAliasDeclaration(node: ESTree.Node): boolean { + let current: ESTree.Node | null = node.parent; + while (current !== null && current.type !== 'Program') { + if (current.type === 'TSTypeAliasDeclaration') return true; + current = current.parent; + } + return false; +} + +function isPlainAliasConsumerUse(node: ESTree.TSType, environment: TypeEnvironment): boolean { + if (node.type !== 'TSTypeReference' || node.typeArguments?.params.length) return false; + const name = typeReferenceName(node); + return name !== null && environment.aliases.has(name) && !isInsideTypeAliasDeclaration(node); +} + +function shouldReportType(node: ESTree.TSType, environment: TypeEnvironment): boolean { + if (isPlainAliasConsumerUse(node, environment)) return false; + if (classifyUnsafeDictionary(node, environment) === null) return false; + let current: ESTree.Node | null = node.parent; + while (current !== null && current.type !== 'Program') { + if (isTypeNode(current) && classifyUnsafeDictionary(current, environment) !== null) + return false; + current = current.parent; + } + return true; +} + +/** Disallow object-dictionary contracts whose direct value type is an unsafe escape hatch. */ +export const noUnsafeDictionaryTypeRule = defineRule({ + meta: { + type: 'problem', + docs: { + description: + 'Disallow object-dictionary contracts whose direct value type is unknown, any, object, {}, or a union/alias containing one of those escape hatches.', + }, + messages: { + unsafeDictionary: + "This dictionary's {{value}} value type gives callers no concrete value contract. Use an owner/schema-derived value type; parse external payloads before insertion.", + }, + }, + createOnce(context) { + let environment: TypeEnvironment | null = null; + const report = (node: ESTree.Node, value: string) => { + context.report({ node, messageId: 'unsafeDictionary', data: { value } }); + }; + const reportIfUnsafe = (node: ESTree.TSType) => { + if (environment === null || !shouldReportType(node, environment)) return; + const unsafe = classifyUnsafeDictionary(node, environment); + if (unsafe === null) return; + report(node, unsafe.unsafeValue); + }; + + return { + Program(node) { + environment = createTypeEnvironment(node); + }, + TSTypeReference: reportIfUnsafe, + TSTypeLiteral: reportIfUnsafe, + TSMappedType: reportIfUnsafe, + TSIndexSignature(node) { + if ( + environment === null || + node.typeAnnotation === null || + node.parent.type === 'TSTypeLiteral' + ) + return; + const unsafe = classifyUnsafeDictionaryValue( + node.typeAnnotation.typeAnnotation, + environment + ); + if (unsafe !== null) report(node, unsafe.unsafeValue); + }, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/no-widen-then-assert.ts b/tools/oxlint/anti-slop/rules/no-widen-then-assert.ts new file mode 100644 index 0000000000..f559480924 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-widen-then-assert.ts @@ -0,0 +1,366 @@ +import { defineRule } from '@oxlint/plugins'; +import type { ESTree, Variable } from '@oxlint/plugins'; + +type BroadTypeKind = 'top' | 'object' | 'record'; + +type KnownValueEvidence = { + readonly type: ESTree.TSType | null; +}; + +const functionBoundaryTypes = new Set([ + 'ArrowFunctionExpression', + 'FunctionDeclaration', + 'FunctionExpression', + 'TSDeclareFunction', + 'TSEmptyBodyFunctionExpression', +]); + +function unwrapExpressionParentheses(expression: ESTree.Expression): ESTree.Expression { + let current = expression; + while (current.type === 'ParenthesizedExpression') current = current.expression; + return current; +} + +function unwrapTypeParentheses(type: ESTree.TSType): ESTree.TSType { + let current = type; + while (current.type === 'TSParenthesizedType') current = current.typeAnnotation; + return current; +} + +function typeReferenceName(type: ESTree.TSTypeReference): string | null { + return type.typeName.type === 'Identifier' ? type.typeName.name : null; +} + +function isUnknownOrAnyType(type: ESTree.TSType): boolean { + const unwrapped = unwrapTypeParentheses(type); + return unwrapped.type === 'TSUnknownKeyword' || unwrapped.type === 'TSAnyKeyword'; +} + +function isBroadRecordKeyType(type: ESTree.TSType): boolean { + const unwrapped = unwrapTypeParentheses(type); + if ( + unwrapped.type === 'TSStringKeyword' || + unwrapped.type === 'TSNumberKeyword' || + unwrapped.type === 'TSSymbolKeyword' + ) { + return true; + } + if (unwrapped.type === 'TSUnionType') return unwrapped.types.every(isBroadRecordKeyType); + return unwrapped.type === 'TSTypeReference' && typeReferenceName(unwrapped) === 'PropertyKey'; +} + +function isBroadRecordType(type: ESTree.TSType): boolean { + const unwrapped = unwrapTypeParentheses(type); + + if (unwrapped.type === 'TSTypeReference') { + if (typeReferenceName(unwrapped) === 'Readonly') { + const [inner] = unwrapped.typeArguments?.params ?? []; + return inner !== undefined && isBroadRecordType(inner); + } + + if (typeReferenceName(unwrapped) !== 'Record') return false; + const parameters = unwrapped.typeArguments?.params ?? []; + return ( + parameters.length === 2 && + parameters[0] !== undefined && + parameters[1] !== undefined && + isBroadRecordKeyType(parameters[0]) && + isUnknownOrAnyType(parameters[1]) + ); + } + + if (unwrapped.type !== 'TSTypeLiteral' || unwrapped.members.length !== 1) return false; + const [member] = unwrapped.members; + const [parameter] = member?.type === 'TSIndexSignature' ? member.parameters : []; + return ( + member?.type === 'TSIndexSignature' && + member.parameters.length === 1 && + parameter !== undefined && + isBroadRecordKeyType(parameter.typeAnnotation.typeAnnotation) && + isUnknownOrAnyType(member.typeAnnotation.typeAnnotation) + ); +} + +function broadTypeKind(type: ESTree.TSType): BroadTypeKind | null { + const unwrapped = unwrapTypeParentheses(type); + if (unwrapped.type === 'TSUnknownKeyword' || unwrapped.type === 'TSAnyKeyword') return 'top'; + if (unwrapped.type === 'TSObjectKeyword') return 'object'; + return isBroadRecordType(unwrapped) ? 'record' : null; +} + +function assertedExpression( + node: ESTree.TSAsExpression | ESTree.TSTypeAssertion +): ESTree.Expression { + return unwrapExpressionParentheses(node.expression); +} + +function assertionFromExpression( + expression: ESTree.Expression +): ESTree.TSAsExpression | ESTree.TSTypeAssertion | null { + const unwrapped = unwrapExpressionParentheses(expression); + return unwrapped.type === 'TSAsExpression' || unwrapped.type === 'TSTypeAssertion' + ? unwrapped + : null; +} + +function normalizedTypeText(sourceText: string, type: ESTree.TSType): string { + return sourceText.slice(type.start, type.end).replaceAll(/\s+/gu, ''); +} + +function typesHaveSameSyntax( + sourceText: string, + left: ESTree.TSType | null, + right: ESTree.TSType +): boolean { + return ( + left !== null && + normalizedTypeText(sourceText, unwrapTypeParentheses(left)) === + normalizedTypeText(sourceText, unwrapTypeParentheses(right)) + ); +} + +function isDefinitelyObjectType(type: ESTree.TSType): boolean { + const unwrapped = unwrapTypeParentheses(type); + switch (unwrapped.type) { + case 'TSArrayType': + case 'TSConstructorType': + case 'TSFunctionType': + case 'TSMappedType': + case 'TSObjectKeyword': + case 'TSTupleType': + return true; + case 'TSTypeLiteral': + return unwrapped.members.length > 0; + case 'TSIntersectionType': + return unwrapped.types.every(isDefinitelyObjectType); + case 'TSTypeOperator': + return unwrapped.operator === 'readonly' && isDefinitelyObjectType(unwrapped.typeAnnotation); + default: + return false; + } +} + +function isDefinitelyNarrowerRecordType(type: ESTree.TSType): boolean { + const unwrapped = unwrapTypeParentheses(type); + if (unwrapped.type === 'TSTypeLiteral') { + return unwrapped.members.some(member => member.type !== 'TSIndexSignature'); + } + + if (unwrapped.type !== 'TSTypeReference') return false; + if (typeReferenceName(unwrapped) === 'Readonly') { + const [inner] = unwrapped.typeArguments?.params ?? []; + return inner !== undefined && isDefinitelyNarrowerRecordType(inner); + } + if (typeReferenceName(unwrapped) !== 'Record') return false; + + const parameters = unwrapped.typeArguments?.params ?? []; + return ( + parameters.length === 2 && parameters[1] !== undefined && !isUnknownOrAnyType(parameters[1]) + ); +} + +function functionBoundary(node: ESTree.Node): ESTree.Node | null { + let current = node.parent; + while (current !== null && current.type !== 'Program') { + if (functionBoundaryTypes.has(current.type)) return current; + current = current.parent; + } + return null; +} + +function resolvedVariableForIdentifier( + scopes: readonly { + readonly references: readonly { + readonly identifier: ESTree.Node; + readonly resolved: Variable | null; + }[]; + }[], + identifier: ESTree.IdentifierReference +): Variable | null { + for (const scope of scopes) { + const reference = scope.references.find( + candidate => + candidate.identifier.start === identifier.start && + candidate.identifier.end === identifier.end + ); + if (reference !== undefined) return reference.resolved; + } + return null; +} + +function variableDeclarator(variable: Variable): ESTree.VariableDeclarator | null { + for (const definition of variable.defs) { + if (definition.type === 'Variable' && definition.node.type === 'VariableDeclarator') { + return definition.node; + } + } + return null; +} + +function knownValueEvidence( + expression: ESTree.Expression, + scopes: Parameters[0], + boundary: ESTree.Node | null, + visitedVariables: ReadonlySet +): KnownValueEvidence | null { + const unwrapped = unwrapExpressionParentheses(expression); + + if (unwrapped.type === 'TSAsExpression' || unwrapped.type === 'TSTypeAssertion') { + if (broadTypeKind(unwrapped.typeAnnotation) !== null) return null; + return { type: unwrapped.typeAnnotation }; + } + + if (unwrapped.type === 'Literal' || unwrapped.type === 'TemplateLiteral') { + return { type: null }; + } + + if ( + unwrapped.type === 'ArrayExpression' || + unwrapped.type === 'ArrowFunctionExpression' || + unwrapped.type === 'ClassExpression' || + unwrapped.type === 'FunctionExpression' || + unwrapped.type === 'NewExpression' || + unwrapped.type === 'ObjectExpression' + ) { + return { type: null }; + } + + if (unwrapped.type !== 'Identifier') return null; + const variable = resolvedVariableForIdentifier(scopes, unwrapped); + if (variable === null || visitedVariables.has(variable)) return null; + + const annotatedIdentifier = variable.identifiers.find( + identifier => identifier.typeAnnotation !== null && identifier.typeAnnotation !== undefined + ); + const annotation = annotatedIdentifier?.typeAnnotation?.typeAnnotation; + if (annotation !== undefined && annotatedIdentifier !== undefined) { + if (functionBoundary(annotatedIdentifier) !== boundary || broadTypeKind(annotation) !== null) { + return null; + } + return { type: annotation }; + } + + const declarator = variableDeclarator(variable); + if ( + declarator === null || + declarator.parent.type !== 'VariableDeclaration' || + declarator.parent.kind !== 'const' || + declarator.init === null || + variable.references.some(reference => reference.isWrite() && !reference.init) || + functionBoundary(declarator) !== boundary + ) { + return null; + } + + return knownValueEvidence( + declarator.init, + scopes, + boundary, + new Set([...visitedVariables, variable]) + ); +} + +function widenedBinding( + variable: Variable, + scopes: Parameters[0] +): { + readonly broadKind: BroadTypeKind; + readonly evidence: KnownValueEvidence; + readonly declaredAt: number; + readonly boundary: ESTree.Node | null; +} | null { + const declarator = variableDeclarator(variable); + if ( + declarator === null || + declarator.parent.type !== 'VariableDeclaration' || + declarator.parent.kind !== 'const' || + declarator.id.type !== 'Identifier' || + declarator.init === null || + variable.references.some(reference => reference.isWrite() && !reference.init) + ) { + return null; + } + + const boundary = functionBoundary(declarator); + const declaredType = declarator.id.typeAnnotation?.typeAnnotation; + const initializerAssertion = assertionFromExpression(declarator.init); + const initializerBroadKind = + initializerAssertion === null ? null : broadTypeKind(initializerAssertion.typeAnnotation); + const declaredBroadKind = declaredType === undefined ? null : broadTypeKind(declaredType); + const broadKind = declaredBroadKind ?? initializerBroadKind; + if (broadKind === null) return null; + + const originalExpression = + initializerAssertion !== null && initializerBroadKind !== null + ? assertedExpression(initializerAssertion) + : declarator.init; + const evidence = knownValueEvidence(originalExpression, scopes, boundary, new Set([variable])); + return evidence === null ? null : { broadKind, evidence, declaredAt: declarator.end, boundary }; +} + +function assertionIsNarrower( + sourceText: string, + broadKind: BroadTypeKind, + evidence: KnownValueEvidence, + assertedType: ESTree.TSType +): boolean { + if (broadTypeKind(assertedType) !== null) return false; + if (broadKind === 'top') return true; + if (typesHaveSameSyntax(sourceText, evidence.type, assertedType)) return true; + if (broadKind === 'object') return isDefinitelyObjectType(assertedType); + return isDefinitelyNarrowerRecordType(assertedType); +} + +/** Detect immutable local bindings that erase a known type and are later asserted back to a narrower type. */ +export const noWidenThenAssertRule = defineRule({ + meta: { + type: 'problem', + docs: { + description: + 'Disallow local const flows that explicitly widen a known value before asserting the widened binding to a narrower type.', + }, + messages: { + widenThenAssert: + 'Binding "{{name}}" discards type evidence and later recreates it with an assertion. Keep the precise type from initialization through use; parse boundary input once.', + }, + }, + createOnce(context) { + let scopes: Parameters[0] = []; + + const checkAssertion = (node: ESTree.TSAsExpression | ESTree.TSTypeAssertion) => { + const expression = assertedExpression(node); + if (expression.type !== 'Identifier') return; + + const variable = resolvedVariableForIdentifier(scopes, expression); + if (variable === null) return; + const widened = widenedBinding(variable, scopes); + if ( + widened === null || + node.start <= widened.declaredAt || + functionBoundary(node) !== widened.boundary || + !assertionIsNarrower( + context.sourceCode.text, + widened.broadKind, + widened.evidence, + node.typeAnnotation + ) + ) { + return; + } + + context.report({ + node, + messageId: 'widenThenAssert', + data: { name: expression.name }, + }); + }; + + return { + Program() { + scopes = context.sourceCode.scopeManager.scopes; + }, + TSAsExpression: checkAssertion, + TSTypeAssertion: checkAssertion, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/rules/require-safety-comment-for-type-assertion.ts b/tools/oxlint/anti-slop/rules/require-safety-comment-for-type-assertion.ts new file mode 100644 index 0000000000..4873a0a8ef --- /dev/null +++ b/tools/oxlint/anti-slop/rules/require-safety-comment-for-type-assertion.ts @@ -0,0 +1,62 @@ +import { defineRule } from '@oxlint/plugins'; + +import type { ESTree, SourceCode } from '@oxlint/plugins'; + +type TypeAssertion = ESTree.TSAsExpression | ESTree.TSTypeAssertion; + +const commentOwnerKinds = new Set([ + 'ExpressionStatement', + 'PropertyDefinition', + 'ReturnStatement', + 'ThrowStatement', + 'VariableDeclaration', +]); + +function isConstAssertion(node: TypeAssertion): boolean { + return ( + node.typeAnnotation.type === 'TSTypeReference' && + node.typeAnnotation.typeName.type === 'Identifier' && + node.typeAnnotation.typeName.name === 'const' + ); +} + +function hasSafetyComment(sourceCode: SourceCode, node: TypeAssertion): boolean { + let current: ESTree.Node = node; + while (true) { + if ( + sourceCode + .getCommentsBefore(current) + .some(comment => comment.end <= node.start && /\bSAFETY\s*:/u.test(comment.value)) + ) { + return true; + } + if (commentOwnerKinds.has(current.type) || current.parent.type === 'Program') return false; + current = current.parent; + } +} + +/** Require every non-const type assertion to state the invariant TypeScript cannot express. */ +export const requireSafetyCommentForTypeAssertionRule = defineRule({ + meta: { + type: 'problem', + docs: { + description: + 'Require a nearby SAFETY comment for every TypeScript type assertion except const assertions.', + }, + messages: { + missingSafetyComment: + 'This type assertion has no `SAFETY:` justification. State the checked invariant immediately before the assertion or its containing statement.', + }, + }, + createOnce(context) { + const checkAssertion = (node: TypeAssertion) => { + if (isConstAssertion(node) || hasSafetyComment(context.sourceCode, node)) return; + context.report({ node, messageId: 'missingSafetyComment' }); + }; + + return { + TSAsExpression: checkAssertion, + TSTypeAssertion: checkAssertion, + }; + }, +}); diff --git a/tools/oxlint/anti-slop/shared/dictionary-types.ts b/tools/oxlint/anti-slop/shared/dictionary-types.ts new file mode 100644 index 0000000000..d54a641410 --- /dev/null +++ b/tools/oxlint/anti-slop/shared/dictionary-types.ts @@ -0,0 +1,494 @@ +import type { ESTree } from '@oxlint/plugins'; + +const BUILT_INS = new Set([ + 'Record', + 'Readonly', + 'Partial', + 'Required', + 'Pick', + 'Omit', + 'PropertyKey', + 'NonNullable', +]); +const TRANSPARENT_WRAPPERS = new Set(['Readonly', 'Partial', 'Required', 'NonNullable']); + +type TypeAliasEnvironment = ReadonlyMap; + +type ResolvedType = { + readonly type: ESTree.TSType; + readonly substitutions: TypeAliasEnvironment; +}; + +export type UnsafeDictionary = { + readonly kind: 'unsafe-dictionary'; + readonly unsafeValue: 'any' | 'empty-object' | 'object' | 'union' | 'unknown'; +}; + +export type WideningTargetKind = + | 'anonymous object' + | 'generic container' + | 'object' + | 'open dictionary' + | 'unknown'; + +export type WideningTarget = { + readonly kind: WideningTargetKind; +}; + +export type TypeEnvironment = { + readonly aliases: ReadonlyMap; + readonly interfaces: ReadonlyMap; + readonly shadowedBuiltIns: ReadonlySet; +}; + +function declaredStatement(statement: ESTree.Statement): ESTree.Node | null { + return statement.type === 'ExportNamedDeclaration' || + statement.type === 'ExportDefaultDeclaration' + ? (statement.declaration ?? null) + : statement; +} + +export function createTypeEnvironment(program: ESTree.Program): TypeEnvironment { + const aliases = new Map(); + const interfaces = new Map(); + const shadowedBuiltIns = new Set(); + + for (const statement of program.body) { + const declaration = declaredStatement(statement); + if (declaration?.type === 'ImportDeclaration') { + for (const specifier of declaration.specifiers) { + if (BUILT_INS.has(specifier.local.name)) shadowedBuiltIns.add(specifier.local.name); + } + continue; + } + + if (declaration?.type === 'TSTypeAliasDeclaration') { + const existing = aliases.get(declaration.id.name); + if (existing === undefined) aliases.set(declaration.id.name, declaration); + else shadowedBuiltIns.add(declaration.id.name); + if (BUILT_INS.has(declaration.id.name)) shadowedBuiltIns.add(declaration.id.name); + continue; + } + + if (declaration?.type === 'TSInterfaceDeclaration') { + const declarations = interfaces.get(declaration.id.name) ?? []; + declarations.push(declaration); + interfaces.set(declaration.id.name, declarations); + if (BUILT_INS.has(declaration.id.name)) shadowedBuiltIns.add(declaration.id.name); + continue; + } + + if (declaration?.type === 'TSEnumDeclaration') { + if (BUILT_INS.has(declaration.id.name)) shadowedBuiltIns.add(declaration.id.name); + continue; + } + + if ( + (declaration?.type === 'ClassDeclaration' || declaration?.type === 'FunctionDeclaration') && + declaration.id !== null + ) { + if (BUILT_INS.has(declaration.id.name)) shadowedBuiltIns.add(declaration.id.name); + } + } + + return { aliases, interfaces, shadowedBuiltIns }; +} + +function typeReferenceName(type: ESTree.TSTypeReference): string | null { + return type.typeName.type === 'Identifier' ? type.typeName.name : null; +} + +function isBuiltIn(name: string, environment: TypeEnvironment): boolean { + return BUILT_INS.has(name) && !environment.shadowedBuiltIns.has(name); +} + +function isUnappliedReferenceTo(type: ESTree.TSType, name: string): boolean { + const unwrapped = unwrapTransparentType(type); + return ( + unwrapped.type === 'TSTypeReference' && + typeReferenceName(unwrapped) === name && + (unwrapped.typeArguments === null || + unwrapped.typeArguments === undefined || + unwrapped.typeArguments.params.length === 0) + ); +} + +function unwrapTransparentType(type: ESTree.TSType): ESTree.TSType { + let current = type; + while ( + current.type === 'TSParenthesizedType' || + (current.type === 'TSTypeOperator' && current.operator === 'readonly') + ) { + current = current.typeAnnotation; + } + return current; +} + +function isNeverType(type: ESTree.TSType): boolean { + return unwrapTransparentType(type).type === 'TSNeverKeyword'; +} + +function isEffectivelyEmptyMember(member: ESTree.TSSignature): boolean { + return ( + member.type === 'TSPropertySignature' && + member.optional === true && + member.typeAnnotation !== null && + member.typeAnnotation !== undefined && + isNeverType(member.typeAnnotation.typeAnnotation) + ); +} + +function isEffectivelyEmptyTypeLiteral(type: ESTree.TSTypeLiteral): boolean { + return type.members.length === 0 || type.members.every(isEffectivelyEmptyMember); +} + +function isEffectivelyEmptyInterface( + declarations: readonly ESTree.TSInterfaceDeclaration[] +): boolean { + if (declarations.length !== 1) return false; + const [type] = declarations; + return ( + type !== undefined && + type.extends.length === 0 && + (type.body.body.length === 0 || type.body.body.every(isEffectivelyEmptyMember)) + ); +} + +function resolvedSubstitutionArgument( + type: ESTree.TSType, + base: TypeAliasEnvironment, + resolving: ReadonlySet = new Set() +): ESTree.TSType { + const unwrapped = unwrapTransparentType(type); + if (unwrapped.type !== 'TSTypeReference') return type; + const name = typeReferenceName(unwrapped); + if (name === null || resolving.has(name)) return type; + const substitution = base.get(name); + if (substitution === undefined) return type; + const nextResolving = new Set(resolving); + nextResolving.add(name); + return resolvedSubstitutionArgument(substitution, base, nextResolving); +} + +function aliasSubstitution( + alias: ESTree.TSTypeAliasDeclaration, + type: ESTree.TSTypeReference, + base: TypeAliasEnvironment +): TypeAliasEnvironment | null { + const parameters = alias.typeParameters?.params ?? []; + const arguments_ = type.typeArguments?.params ?? []; + const next = new Map(base); + for (const [index, parameter] of parameters.entries()) { + const argument = arguments_[index] ?? parameter.default; + if (argument === null || argument === undefined) return null; + next.set(parameter.name.name, resolvedSubstitutionArgument(argument, next)); + } + return next; +} + +function unsafeDirectValue( + type: ESTree.TSType, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, + resolvingAliases: ReadonlySet +): UnsafeDictionary['unsafeValue'] | null { + const unwrapped = unwrapTransparentType(type); + if (unwrapped.type === 'TSUnknownKeyword') return 'unknown'; + if (unwrapped.type === 'TSAnyKeyword') return 'any'; + if (unwrapped.type === 'TSObjectKeyword') return 'object'; + if (unwrapped.type === 'TSTypeLiteral' && isEffectivelyEmptyTypeLiteral(unwrapped)) + return 'empty-object'; + if (unwrapped.type === 'TSUnionType') { + return unwrapped.types.some( + member => unsafeDirectValue(member, environment, substitutions, resolvingAliases) !== null + ) + ? 'union' + : null; + } + if (unwrapped.type === 'TSIntersectionType') { + const unsafeMembers = unwrapped.types.map(member => + unsafeDirectValue(member, environment, substitutions, resolvingAliases) + ); + if (unsafeMembers.includes('any')) return 'any'; + return unsafeMembers.length > 0 && unsafeMembers.every(member => member !== null) + ? unsafeMembers[0] + : null; + } + if (unwrapped.type !== 'TSTypeReference') return null; + const name = typeReferenceName(unwrapped); + if (name === null) return null; + if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, environment)) { + const wrapped = unwrapped.typeArguments?.params[0]; + return wrapped === undefined + ? null + : unsafeDirectValue(wrapped, environment, substitutions, resolvingAliases); + } + const substitution = substitutions.get(name); + if (substitution !== undefined) { + return isUnappliedReferenceTo(substitution, name) + ? null + : unsafeDirectValue(substitution, environment, substitutions, resolvingAliases); + } + const interfaceDeclarations = environment.interfaces.get(name); + if (interfaceDeclarations !== undefined) { + return isEffectivelyEmptyInterface(interfaceDeclarations) ? 'empty-object' : null; + } + const alias = environment.aliases.get(name); + if (alias === undefined || resolvingAliases.has(name)) return null; + const nextSubstitutions = aliasSubstitution(alias, unwrapped, substitutions); + if (nextSubstitutions === null) return null; + const nextResolving = new Set(resolvingAliases); + nextResolving.add(name); + return unsafeDirectValue(alias.typeAnnotation, environment, nextSubstitutions, nextResolving); +} + +function dictionaryValueTypes( + type: ESTree.TSType, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, + resolvingAliases: ReadonlySet +): readonly ResolvedType[] { + const unwrapped = unwrapTransparentType(type); + + if (unwrapped.type === 'TSTypeLiteral') { + return unwrapped.members.flatMap((member): readonly ResolvedType[] => + member.type === 'TSIndexSignature' && member.typeAnnotation !== null + ? [{ type: member.typeAnnotation.typeAnnotation, substitutions }] + : [] + ); + } + + if (unwrapped.type === 'TSMappedType') { + return unwrapped.typeAnnotation === null + ? [] + : [{ type: unwrapped.typeAnnotation, substitutions }]; + } + + if (unwrapped.type !== 'TSTypeReference') return []; + const name = typeReferenceName(unwrapped); + if (name === null) return []; + + const substitution = substitutions.get(name); + if (substitution !== undefined) { + return isUnappliedReferenceTo(substitution, name) + ? [] + : dictionaryValueTypes(substitution, environment, substitutions, resolvingAliases); + } + + if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, environment)) { + const wrapped = unwrapped.typeArguments?.params[0]; + return wrapped === undefined + ? [] + : dictionaryValueTypes(wrapped, environment, substitutions, resolvingAliases); + } + + if (name === 'Record' && isBuiltIn(name, environment)) { + const value = unwrapped.typeArguments?.params[1] ?? null; + return value === null ? [] : [{ type: value, substitutions }]; + } + + if ((name === 'Pick' || name === 'Omit') && isBuiltIn(name, environment)) { + const source = unwrapped.typeArguments?.params[0]; + return source === undefined + ? [] + : dictionaryValueTypes(source, environment, substitutions, resolvingAliases); + } + + const alias = environment.aliases.get(name); + if (alias === undefined || resolvingAliases.has(name)) return []; + const nextSubstitutions = aliasSubstitution(alias, unwrapped, substitutions); + if (nextSubstitutions === null) return []; + const nextResolving = new Set(resolvingAliases); + nextResolving.add(name); + return dictionaryValueTypes(alias.typeAnnotation, environment, nextSubstitutions, nextResolving); +} + +export function classifyUnsafeDictionaryValue( + valueType: ESTree.TSType, + environment: TypeEnvironment +): UnsafeDictionary | null { + const unsafeValue = unsafeDirectValue(valueType, environment, new Map(), new Set()); + return unsafeValue === null ? null : { kind: 'unsafe-dictionary', unsafeValue }; +} + +export function classifyUnsafeDictionary( + type: ESTree.TSType, + environment: TypeEnvironment +): UnsafeDictionary | null { + for (const valueType of dictionaryValueTypes(type, environment, new Map(), new Set())) { + const unsafeValue = unsafeDirectValue( + valueType.type, + environment, + valueType.substitutions, + new Set() + ); + if (unsafeValue !== null) return { kind: 'unsafe-dictionary', unsafeValue }; + } + return null; +} + +function resolvesToDictionary( + type: ESTree.TSType, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, + resolvingAliases: ReadonlySet +): boolean { + return dictionaryValueTypes(type, environment, substitutions, resolvingAliases).length > 0; +} + +export function classifyWideningTarget( + type: ESTree.TSType, + environment: TypeEnvironment +): WideningTarget | null { + const unwrapped = unwrapTransparentType(type); + if (unwrapped.type === 'TSUnknownKeyword') return { kind: 'unknown' }; + if (unwrapped.type === 'TSObjectKeyword') return { kind: 'object' }; + if (unwrapped.type === 'TSTypeLiteral') { + return unwrapped.members.some(member => member.type === 'TSIndexSignature') + ? { kind: 'open dictionary' } + : unwrapped.members.length > 0 + ? { kind: 'anonymous object' } + : null; + } + if (unwrapped.type === 'TSMappedType') return { kind: 'open dictionary' }; + if (unwrapped.type !== 'TSTypeReference') return null; + const name = typeReferenceName(unwrapped); + if (name === null) return null; + if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, environment)) { + const wrapped = unwrapped.typeArguments?.params[0]; + return wrapped === undefined ? null : classifyWideningTarget(wrapped, environment); + } + if (name === 'Record' && isBuiltIn(name, environment)) return { kind: 'open dictionary' }; + const alias = environment.aliases.get(name); + if (alias === undefined) return null; + if ((alias.typeParameters?.params.length ?? 0) > 0) { + const substitutions = aliasSubstitution(alias, unwrapped, new Map()); + return substitutions !== null && + resolvesToDictionary(alias.typeAnnotation, environment, substitutions, new Set([name])) + ? { kind: 'generic container' } + : null; + } + const substitutions = aliasSubstitution(alias, unwrapped, new Map()); + if (substitutions === null) return null; + const resolved = classifyAliasBroadTarget( + alias.typeAnnotation, + environment, + substitutions, + new Set([name]) + ); + return resolved; +} + +function isBroadMappedKey( + type: ESTree.TSType, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment +): boolean { + const unwrapped = unwrapTransparentType(type); + if ( + unwrapped.type === 'TSStringKeyword' || + unwrapped.type === 'TSNumberKeyword' || + unwrapped.type === 'TSSymbolKeyword' + ) { + return true; + } + if (unwrapped.type === 'TSUnionType') { + return unwrapped.types.every(member => isBroadMappedKey(member, environment, substitutions)); + } + if (unwrapped.type !== 'TSTypeReference') return false; + const name = typeReferenceName(unwrapped); + if (name === null) return false; + const substitution = substitutions.get(name); + if (substitution !== undefined && !isUnappliedReferenceTo(substitution, name)) { + return isBroadMappedKey(substitution, environment, substitutions); + } + return name === 'PropertyKey' && isBuiltIn(name, environment); +} + +function classifyAliasBroadTarget( + type: ESTree.TSType, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, + resolvingAliases: ReadonlySet +): WideningTarget | null { + const unwrapped = unwrapTransparentType(type); + if (unwrapped.type === 'TSUnknownKeyword') return { kind: 'unknown' }; + if (unwrapped.type === 'TSObjectKeyword') return { kind: 'object' }; + if (unwrapped.type === 'TSTypeLiteral') { + return unwrapped.members.some(member => member.type === 'TSIndexSignature') + ? { kind: 'open dictionary' } + : null; + } + if (unwrapped.type === 'TSMappedType') { + return isBroadMappedKey(unwrapped.constraint, environment, substitutions) + ? { kind: 'open dictionary' } + : null; + } + if (unwrapped.type !== 'TSTypeReference') return null; + const name = typeReferenceName(unwrapped); + if (name === null) return null; + const substitution = substitutions.get(name); + if (substitution !== undefined) { + return isUnappliedReferenceTo(substitution, name) + ? null + : classifyAliasBroadTarget(substitution, environment, substitutions, resolvingAliases); + } + if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, environment)) { + const wrapped = unwrapped.typeArguments?.params[0]; + return wrapped === undefined + ? null + : classifyAliasBroadTarget(wrapped, environment, substitutions, resolvingAliases); + } + if (name === 'Record' && isBuiltIn(name, environment)) { + return { kind: 'open dictionary' }; + } + const alias = environment.aliases.get(name); + if (alias === undefined || resolvingAliases.has(name)) return null; + const nextSubstitutions = aliasSubstitution(alias, unwrapped, substitutions); + if (nextSubstitutions === null) return null; + const nextResolving = new Set(resolvingAliases); + nextResolving.add(name); + return classifyAliasBroadTarget( + alias.typeAnnotation, + environment, + nextSubstitutions, + nextResolving + ); +} + +export function isPopulatedObjectExpression(expression: ESTree.Expression): boolean { + let current = expression; + while ( + current.type === 'ParenthesizedExpression' || + current.type === 'TSAsExpression' || + current.type === 'TSTypeAssertion' || + current.type === 'TSNonNullExpression' + ) { + current = current.expression; + } + return current.type === 'ObjectExpression' && current.properties.length > 0; +} + +export function isKnownEvidenceExpression(expression: ESTree.Expression): boolean { + let current = expression; + while ( + current.type === 'ParenthesizedExpression' || + current.type === 'TSAsExpression' || + current.type === 'TSTypeAssertion' || + current.type === 'TSNonNullExpression' || + current.type === 'TSSatisfiesExpression' + ) { + current = current.expression; + } + if (current.type === 'ObjectExpression') return true; + return ( + current.type === 'ArrayExpression' || + current.type === 'ArrowFunctionExpression' || + current.type === 'ClassExpression' || + current.type === 'FunctionExpression' || + current.type === 'NewExpression' || + current.type === 'Literal' || + current.type === 'TemplateLiteral' || + current.type === 'UnaryExpression' + ); +} diff --git a/tools/oxlint/anti-slop/shared/lexical-type-parameters.ts b/tools/oxlint/anti-slop/shared/lexical-type-parameters.ts new file mode 100644 index 0000000000..a2ccb9e747 --- /dev/null +++ b/tools/oxlint/anti-slop/shared/lexical-type-parameters.ts @@ -0,0 +1,58 @@ +import type { ESTree } from '@oxlint/plugins'; + +type VisitorKeys = Readonly>; + +function isNode(value: unknown): value is ESTree.Node { + return ( + typeof value === 'object' && value !== null && 'type' in value && typeof value.type === 'string' + ); +} + +function collectInferTypeParameterNames( + node: ESTree.Node, + visitorKeys: VisitorKeys, + names: Set +): void { + if (node.type === 'TSInferType') names.add(node.typeParameter.name.name); + const record = node as unknown as Readonly>; + for (const key of visitorKeys[node.type] ?? []) { + const value = record[key]; + if (isNode(value)) { + collectInferTypeParameterNames(value, visitorKeys, names); + continue; + } + if (!Array.isArray(value)) continue; + for (const child of value) { + if (isNode(child)) collectInferTypeParameterNames(child, visitorKeys, names); + } + } +} + +/** Collect type binders that are in scope at a node and can shadow module aliases. */ +export function lexicalTypeParameterNames( + node: ESTree.Node, + visitorKeys: VisitorKeys +): ReadonlySet { + const names = new Set(); + let descendant: ESTree.Node = node; + let current: ESTree.Node | null = node; + while (current !== null && current.type !== 'Program') { + if ('typeParameters' in current) { + for (const parameter of current.typeParameters?.params ?? []) { + names.add(parameter.name.name); + } + } + if ( + current.type === 'TSMappedType' && + (descendant === current.nameType || descendant === current.typeAnnotation) + ) { + names.add(current.key.name); + } + if (current.type === 'TSConditionalType' && descendant === current.trueType) { + collectInferTypeParameterNames(current.extendsType, visitorKeys, names); + } + descendant = current; + current = current.parent; + } + return names; +} diff --git a/tools/oxlint/anti-slop/shared/reflect-method.ts b/tools/oxlint/anti-slop/shared/reflect-method.ts new file mode 100644 index 0000000000..7be25189fb --- /dev/null +++ b/tools/oxlint/anti-slop/shared/reflect-method.ts @@ -0,0 +1,35 @@ +import type { ESTree, Scope, SourceCode, Variable } from '@oxlint/plugins'; + +function resolveVariable( + sourceCode: SourceCode, + identifier: ESTree.IdentifierReference +): Variable | null { + let scope: Scope | null = sourceCode.getScope(identifier); + while (scope !== null) { + const variable = scope.set.get(identifier.name); + if (variable !== undefined) return variable; + scope = scope.upper; + } + return null; +} + +function isGlobalReflect(sourceCode: SourceCode, expression: ESTree.Expression): boolean { + if (expression.type !== 'Identifier' || expression.name !== 'Reflect') return false; + if (sourceCode.isGlobalReference(expression)) return true; + const variable = resolveVariable(sourceCode, expression); + return variable === null || variable.defs.length === 0; +} + +/** Reports whether a call target names one method on the global Reflect object. */ +export function isGlobalReflectMethodCall( + sourceCode: SourceCode, + callee: ESTree.Expression, + methodName: string +): boolean { + if (!('property' in callee) || !('object' in callee) || !('computed' in callee)) return false; + if (!isGlobalReflect(sourceCode, callee.object)) return false; + const property = callee.property; + return callee.computed + ? property.type === 'Literal' && property.value === methodName + : property.type === 'Identifier' && property.name === methodName; +} diff --git a/tools/oxlint/zod-utils.mjs b/tools/oxlint/zod-utils.mjs new file mode 100644 index 0000000000..5c421e945c --- /dev/null +++ b/tools/oxlint/zod-utils.mjs @@ -0,0 +1,32 @@ +// Runs eslint-plugin-zod-utils under oxlint. The rule's type-aware check is +// optional (it branches on `parserServices.program`), but its +// `getParserServices(context, true)` call throws unless parserServices +// carries the tseslint node maps, which oxlint never provides. Handing it a +// stub with `program: null` lands it on its syntactic path. +import zodUtils from 'eslint-plugin-zod-utils'; + +const stubParserServices = { + program: null, + esTreeNodeToTSNodeMap: new WeakMap(), + tsNodeToESTreeNodeMap: new WeakMap(), +}; + +function withStubParserServices(context) { + if (context.sourceCode.parserServices?.esTreeNodeToTSNodeMap != null) { + return context; + } + const sourceCode = Object.create(context.sourceCode, { + parserServices: { value: stubParserServices }, + }); + return Object.create(context, { sourceCode: { value: sourceCode } }); +} + +export default { + meta: { name: 'zod-utils' }, + rules: Object.fromEntries( + Object.entries(zodUtils.rules).map(([name, rule]) => [ + name, + { ...rule, create: context => rule.create(withStubParserServices(context)) }, + ]) + ), +};