diff --git a/package.json b/package.json index 8a57c07e5..d6ae3aae8 100644 --- a/package.json +++ b/package.json @@ -57,8 +57,9 @@ "security:pr-scan": "bun run scripts/pr-intent-scan.ts", "test:provider-recommendation": "bun test src/utils/providerRecommendation.test.ts src/utils/providerProfile.test.ts", "typecheck": "tsc --noEmit", + "typecheck:type-tests": "bun run scripts/typecheck-type-tests.ts", "smoke": "bun run build && node dist/cli.mjs --version", - "check": "bun run smoke && bun run test:full", + "check": "bun run smoke && bun run typecheck:type-tests && bun run test:full", "verify:privacy": "bun run scripts/verify-no-phone-home.ts", "build:verified": "bun run build && bun run verify:privacy", "test:provider": "bun test --max-concurrency=1 src/services/api/*.test.ts src/utils/context.test.ts", diff --git a/scripts/typecheck-type-tests.ts b/scripts/typecheck-type-tests.ts new file mode 100644 index 000000000..00439b56f --- /dev/null +++ b/scripts/typecheck-type-tests.ts @@ -0,0 +1,89 @@ +import path from 'node:path' +import ts from 'typescript' + +// Root `tsc --noEmit` has a known failing baseline. This focused check +// enforces diagnostics in type assertion files and covered implementation +// files while quarantining dependency diagnostics until the broader baseline +// is fixed. +function fail(message: string): never { + console.error(message) + process.exit(1) +} + +function normalizeFileName(fileName: string): string { + return path.normalize(path.resolve(fileName)) +} + +const configPath = ts.findConfigFile( + process.cwd(), + ts.sys.fileExists, + 'tsconfig.type-tests.json', +) + +if (!configPath) { + fail('Could not find tsconfig.type-tests.json') +} + +const configFile = ts.readConfigFile(configPath, ts.sys.readFile) +const formatHost: ts.FormatDiagnosticsHost = { + getCanonicalFileName: fileName => fileName, + getCurrentDirectory: ts.sys.getCurrentDirectory, + getNewLine: () => ts.sys.newLine, +} + +if (configFile.error) { + console.error(ts.formatDiagnostic(configFile.error, formatHost)) + process.exit(1) +} + +const parsedConfig = ts.parseJsonConfigFileContent( + configFile.config, + ts.sys, + path.dirname(configPath), +) + +if (parsedConfig.errors.length > 0) { + console.error( + ts.formatDiagnosticsWithColorAndContext(parsedConfig.errors, formatHost), + ) + process.exit(1) +} + +const rootFileNames = parsedConfig.fileNames.map(normalizeFileName) +const rootFileNameSet = new Set(rootFileNames) + +if (rootFileNameSet.size === 0) { + fail('tsconfig.type-tests.json does not include any files') +} + +const program = ts.createProgram({ + rootNames: parsedConfig.fileNames, + options: parsedConfig.options, + projectReferences: parsedConfig.projectReferences, +}) + +const diagnostics = ts.getPreEmitDiagnostics(program) +const blockingDiagnostics = diagnostics.filter(diagnostic => { + if (!diagnostic.file) { + return true + } + + return rootFileNameSet.has(normalizeFileName(diagnostic.file.fileName)) +}) + +if (blockingDiagnostics.length > 0) { + console.error( + ts.formatDiagnosticsWithColorAndContext(blockingDiagnostics, formatHost), + ) + process.exit(1) +} + +const ignoredDiagnostics = diagnostics.length - blockingDiagnostics.length +const ignoredSuffix = + ignoredDiagnostics === 0 + ? '' + : ` (${ignoredDiagnostics} dependency diagnostics ignored)` + +console.log( + `Focused typecheck passed: ${rootFileNameSet.size} files checked${ignoredSuffix}.`, +) diff --git a/src/commands/model/model.test.tsx b/src/commands/model/model.test.tsx index 9724d9ade..6bf813487 100644 --- a/src/commands/model/model.test.tsx +++ b/src/commands/model/model.test.tsx @@ -13,6 +13,7 @@ import { setSessionSettingsCache, } from '../../utils/settings/settingsCache.js' import type { ModelOption } from '../../utils/model/modelOptions.js' +import type { ModelSetting } from '../../utils/model/model.js' import type { SettingsJson } from '../../utils/settings/types.js' type SettingsModule = typeof import('../../utils/settings/settings.js') @@ -2582,7 +2583,7 @@ test('interactive model picker rejects models blocked by availableModels before ...getDefaultAppState(), mainLoopModel: 'allowed-model', } - let latestMainLoopModel = initialState.mainLoopModel + let latestMainLoopModel: ModelSetting = initialState.mainLoopModel const instance = await render( 0) { - ttftText = computeTtftText(apiMetricsRef.current); - } - // When leader is idle but teammates are running (and we're viewing the leader), // show a static dim idle display instead of the animated spinner — otherwise // useStalledAnimation detects no new tokens after 3s and turns the spinner red. diff --git a/src/components/agents/AgentsMenu.test.tsx b/src/components/agents/AgentsMenu.test.tsx index cc30b430e..41a7b247e 100644 --- a/src/components/agents/AgentsMenu.test.tsx +++ b/src/components/agents/AgentsMenu.test.tsx @@ -79,11 +79,34 @@ function createAgent( agentType: string, source: AgentDefinition['source'] = 'userSettings', ): AgentDefinition { + const whenToUse = `Use ${agentType}` + const getSystemPrompt = () => `You are ${agentType}` + + if (source === 'built-in') { + return { + agentType, + whenToUse, + source, + baseDir: 'built-in', + getSystemPrompt, + } + } + + if (source === 'plugin') { + return { + agentType, + whenToUse, + source, + plugin: 'test-plugin', + getSystemPrompt, + } + } + return { agentType, - whenToUse: `Use ${agentType}`, + whenToUse, source, - getSystemPrompt: () => `You are ${agentType}`, + getSystemPrompt, } } @@ -157,7 +180,7 @@ test('sets a different active session agent from the agent menu', async () => { patchConsole: false, }) let callbackAgent: AgentDefinition | undefined - let latestAgent = initialState.agent + let latestAgent: string | undefined = initialState.agent root.render( } {feature('WEB_BROWSER_TOOL') ? WebBrowserPanelModule && : null} - {showSpinner && 0} leaderIsIdle={!isLoading} />} + {showSpinner && 0} leaderIsIdle={!isLoading} />} {!showSpinner && !isLoading && !userInputOnProcessing && !hasRunningTeammates && isBriefOnly && !viewedAgentTask && } {isFullscreenEnvEnabled() && } } bottom={ diff --git a/src/state/AppState.tsx b/src/state/AppState.tsx index fd94c9159..9645d7e21 100644 --- a/src/state/AppState.tsx +++ b/src/state/AppState.tsx @@ -26,6 +26,7 @@ import { type AppState, type AppStateStore, getDefaultAppState } from './AppStat // can incrementally move off the .tsx import and stop pulling React. export { type AppState, type AppStateStore, type CompletionBoundary, getDefaultAppState, IDLE_SPECULATION_STATE, type SpeculationResult, type SpeculationState } from './AppStateStore.js'; export const AppStoreContext = React.createContext(null); +type IfAny = 0 extends 1 & T ? Y : N; type Props = { children: React.ReactNode; initialState?: AppState; @@ -35,7 +36,7 @@ type Props = { }) => void; }; const HasAppStateContext = React.createContext(false); -export function AppStateProvider(t0) { +export function AppStateProvider(t0: Props): React.ReactNode { const $ = _c(13); const { children, @@ -46,7 +47,7 @@ export function AppStateProvider(t0) { if (hasAppStateContext) { throw new Error("AppStateProvider can not be nested within another AppStateProvider"); } - let t1; + let t1: () => AppStateStore; if ($[0] !== initialState || $[1] !== onChangeAppState) { t1 = () => createStore(initialState ?? getDefaultAppState(), onChangeAppState); $[0] = initialState; @@ -55,8 +56,8 @@ export function AppStateProvider(t0) { } else { t1 = $[2]; } - const [store] = useState(t1); - let t2; + const [store] = useState(t1); + let t2: () => void; if ($[3] !== store) { t2 = () => { const { @@ -72,7 +73,7 @@ export function AppStateProvider(t0) { } else { t2 = $[4]; } - let t3; + let t3: React.DependencyList; if ($[5] === Symbol.for("react.memo_cache_sentinel")) { t3 = []; $[5] = t3; @@ -80,9 +81,9 @@ export function AppStateProvider(t0) { t3 = $[5]; } useEffect(t2, t3); - let t4; + let t4: (source: SettingSource) => void; if ($[6] !== store.setState) { - t4 = source => applySettingsChange(source, store.setState); + t4 = (source: SettingSource) => applySettingsChange(source, store.setState); $[6] = store.setState; $[7] = t4; } else { @@ -109,7 +110,7 @@ export function AppStateProvider(t0) { } return t6; } -function _temp(prev) { +function _temp(prev: AppState): AppState { return { ...prev, toolPermissionContext: createDisabledBypassPermissionsContext(prev.toolPermissionContext) @@ -140,16 +141,18 @@ function useAppStore(): AppStateStore { * const { text, promptId } = useAppState(s => s.promptSuggestion) // good * ``` */ -export function useAppState(selector) { +export function useAppState(selector: (state: AppState) => T): T; +export function useAppState(selector: IfAny): any; +export function useAppState(selector: (state: AppState) => T): T { const store = useAppStore(); - const selectorRef = React.useRef(selector); - const storeRef = React.useRef(store); + const selectorRef = React.useRef<(state: AppState) => T>(selector); + const storeRef = React.useRef(store); // Update refs during render so get() always calls the latest selector/store // without creating a new function identity that would trigger useSyncExternalStore // to re-sync and cause re-render loops. selectorRef.current = selector; storeRef.current = store; - const get = React.useCallback(() => { + const get = React.useCallback((): T => { return selectorRef.current(storeRef.current.getState()); }, []); return useSyncExternalStore(store.subscribe, get, get); @@ -160,31 +163,39 @@ export function useAppState(selector) { * Returns a stable reference that never changes -- components using only * this hook will never re-render from state changes. */ -export function useSetAppState() { +export function useSetAppState(): AppStateStore['setState'] { return useAppStore().setState; } /** * Get the store directly (for passing getState/setState to non-React code). */ -export function useAppStateStore() { +export function useAppStateStore(): AppStateStore { return useAppStore(); } -const NOOP_SUBSCRIBE = () => () => {}; +const NOOP_SUBSCRIBE: AppStateStore['subscribe'] = () => () => {}; /** * Safe version of useAppState that returns undefined if called outside of AppStateProvider. * Useful for components that may be rendered in contexts where AppStateProvider isn't available. */ -export function useAppStateMaybeOutsideOfProvider(selector) { +export function useAppStateMaybeOutsideOfProvider( + selector: (state: AppState) => T, +): T | undefined; +export function useAppStateMaybeOutsideOfProvider( + selector: IfAny, +): any; +export function useAppStateMaybeOutsideOfProvider( + selector: (state: AppState) => T, +): T | undefined { const store = useContext(AppStoreContext); - const selectorRef = React.useRef(selector); - const storeRef = React.useRef(store); + const selectorRef = React.useRef<(state: AppState) => T>(selector); + const storeRef = React.useRef(store); // Update refs during render so get() always calls the latest selector/store // without creating a new function identity. selectorRef.current = selector; storeRef.current = store; - const get = React.useCallback(() => { + const get = React.useCallback((): T | undefined => { return storeRef.current ? selectorRef.current(storeRef.current.getState()) : undefined; }, []); return useSyncExternalStore(store ? store.subscribe : NOOP_SUBSCRIBE, get); diff --git a/src/state/AppState.types.test.tsx b/src/state/AppState.types.test.tsx new file mode 100644 index 000000000..f9db1e575 --- /dev/null +++ b/src/state/AppState.types.test.tsx @@ -0,0 +1,105 @@ +import type { AppStateStore } from './AppState.js' +import { + useAppState, + useAppStateMaybeOutsideOfProvider, + useAppStateStore, + useSetAppState, +} from './AppState.js' + +type Assert = T +type IsAny = 0 extends 1 & T ? true : false +type IsEqual = (() => T extends A ? 1 : 2) extends < + T, +>() => T extends B ? 1 : 2 + ? true + : false + +function assertAppStateHookTypes(): void { + const anySelector = ((state: unknown) => state) as any + + const verbose = useAppState(state => state.verbose) + type _VerboseIsBoolean = Assert> + type _VerboseIsNotAny = Assert extends false ? true : false> + + const anySelected = useAppState(anySelector) + type _AnySelectorStaysAny = Assert< + IsAny extends true ? true : false + > + + // @ts-expect-error Compatibility overload is reserved for compiler-erased `any` selectors. + useAppState((state: { missing: string }) => state.missing) + + const maybeVerbose = useAppStateMaybeOutsideOfProvider( + state => state.verbose, + ) + type _MaybeVerboseIsOptionalBoolean = Assert< + IsEqual + > + type _MaybeVerboseIsNotAny = Assert< + IsAny extends false ? true : false + > + + const maybeAnySelected = useAppStateMaybeOutsideOfProvider(anySelector) + type _MaybeAnySelectorStaysAny = Assert< + IsAny extends true ? true : false + > + + // @ts-expect-error Compatibility overload is reserved for compiler-erased `any` selectors. + useAppStateMaybeOutsideOfProvider((state: { missing: string }) => state.missing) + + const setAppState = useSetAppState() + type _SetAppStateIsStoreSetter = Assert< + IsEqual + > + type _SetAppStateIsNotAny = Assert< + IsAny extends false ? true : false + > + + const store = useAppStateStore() + type _StoreIsAppStateStore = Assert> + type _StoreIsNotAny = Assert extends false ? true : false> + + const additionalDirectoryKeys = useAppState(state => + Array.from(state.toolPermissionContext.additionalWorkingDirectories.keys()), + ) + type _AdditionalDirectoryKeysAreStrings = Assert< + IsEqual + > + type _AdditionalDirectoryKeysAreNotAny = Assert< + IsAny extends false ? true : false + > + + const hasActiveOverlay = useAppState(state => + state.activeOverlays.has('test-overlay'), + ) + type _ActiveOverlayCheckIsBoolean = Assert< + IsEqual + > + type _ActiveOverlayCheckIsNotAny = Assert< + IsAny extends false ? true : false + > + + const registeredToolNames = useAppState(state => + state.replContext + ? Array.from(state.replContext.registeredTools.keys()) + : [], + ) + type _RegisteredToolNamesAreStrings = Assert< + IsEqual + > + type _RegisteredToolNamesAreNotAny = Assert< + IsAny extends false ? true : false + > + + void verbose + void anySelected + void maybeVerbose + void maybeAnySelected + void setAppState + void store + void additionalDirectoryKeys + void hasActiveOverlay + void registeredToolNames +} + +void assertAppStateHookTypes diff --git a/src/types/utils.ts b/src/types/utils.ts index 28965d473..b1c8ce568 100644 --- a/src/types/utils.ts +++ b/src/types/utils.ts @@ -4,11 +4,19 @@ */ /* eslint-disable @typescript-eslint/no-explicit-any */ -export type DeepImmutable = T extends any[] - ? readonly DeepImmutable[] - : T extends object - ? { readonly [K in keyof T]: DeepImmutable } - : T +export type DeepImmutable = T extends (...args: any[]) => any + ? T + : T extends ReadonlyMap + ? ReadonlyMap, DeepImmutable> + : T extends ReadonlySet + ? ReadonlySet> + : T extends readonly unknown[] + ? number extends T['length'] + ? readonly DeepImmutable[] + : { readonly [K in keyof T]: DeepImmutable } + : T extends object + ? { readonly [K in keyof T]: DeepImmutable } + : T export type Permutations = T extends T ? T | `${T}${Permutations>}` diff --git a/src/types/utils.types.test.ts b/src/types/utils.types.test.ts new file mode 100644 index 000000000..ba520cb00 --- /dev/null +++ b/src/types/utils.types.test.ts @@ -0,0 +1,63 @@ +import type { DeepImmutable } from './utils.js' + +type Assert = T +type IsEqual = (() => T extends A ? 1 : 2) extends < + T, +>() => T extends B ? 1 : 2 + ? true + : false + +type ImmutableReadonlyMap = DeepImmutable< + ReadonlyMap +> +type ImmutableReadonlyMapValue = NonNullable< + ReturnType +> +type _ReadonlyMapValueIsDeepImmutable = Assert< + IsEqual +> + +type ImmutableReadonlySet = DeepImmutable> +type ImmutableReadonlySetValue = + ImmutableReadonlySet extends ReadonlySet ? Value : never +type _ReadonlySetValueIsDeepImmutable = Assert< + IsEqual +> + +type ImmutableReadonlyTuple = DeepImmutable< + readonly [{ a: string[] }, { b: number[] }] +> +type _ReadonlyTuplePreservesPositions = Assert< + IsEqual< + ImmutableReadonlyTuple, + readonly [ + { readonly a: readonly string[] }, + { readonly b: readonly number[] }, + ] + > +> + +function assertReadonlyCollectionTypes( + readonlyMap: ImmutableReadonlyMap, + readonlySet: ImmutableReadonlySet, + readonlyTuple: ImmutableReadonlyTuple, +): void { + const mapValue = readonlyMap.get('test') + if (mapValue) { + // @ts-expect-error DeepImmutable keeps ReadonlyMap APIs but freezes nested values. + mapValue.items.push('mutates') + } + + for (const setValue of readonlySet) { + // @ts-expect-error DeepImmutable keeps ReadonlySet APIs but freezes nested values. + setValue.items.push('mutates') + } + + // @ts-expect-error DeepImmutable preserves tuple positions and freezes nested values. + readonlyTuple[0].a.push('mutates') + + // @ts-expect-error DeepImmutable preserves readonly tuple arity. + readonlyTuple[2] +} + +void assertReadonlyCollectionTypes diff --git a/tsconfig.type-tests.json b/tsconfig.type-tests.json new file mode 100644 index 000000000..e4d1660ca --- /dev/null +++ b/tsconfig.type-tests.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true + }, + "files": [ + "src/state/AppState.types.test.tsx", + "src/state/AppState.tsx", + "src/types/utils.types.test.ts", + "src/types/utils.ts" + ], + "include": [] +}