diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2c9f69ab08f0..d6827b3e6c2f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -238,6 +238,15 @@ jobs: # runs tsc scoped to the dashboard tree against a frozen baseline of # pre-existing errors; only NEW errors fail it. - run: npm run check:dashboard-typecheck + # The rest of the app surface — src/lib, src/shared, bin, packages and the + # Electron types — had no type gate: next build ignores type errors and + # the root tsconfig cannot finish. API routes are covered separately by + # api-route-typecheck.yml, so this is not "everything else was unchecked". + # This runs the production-only program against a frozen per-(file, code) + # count baseline, so a new error anywhere in that surface fails the job + # while the pre-existing debt stays visible and cannot grow. + - run: npm run check:build-typecheck + timeout-minutes: 5 # typecheck:noimplicit:core dropped from this job (2026-07 optimize): # it was advisory (continue-on-error) and largely subsumed by the blocking # check:type-coverage ratchet in quality-gate. Local: npm run typecheck:noimplicit:core. diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index dbee35d658a7..71fa4c34273a 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -295,6 +295,9 @@ jobs: echo "::group::check:dashboard-typecheck" npm run check:dashboard-typecheck || failed+=("check:dashboard-typecheck") echo "::endgroup::" + echo "::group::check:build-typecheck" + npm run check:build-typecheck || failed+=("check:build-typecheck") + echo "::endgroup::" # #10134: TS7 zero-new-diagnostics ratchet — folded into this non-fail-fast # loop (never a separate blocking step) so an earlier red gate cannot abort # the job and mask it (#8542 mechanism). PR-only: the base-relative diff --git a/changelog.d/maintenance/18603-production-type-ratchet.md b/changelog.d/maintenance/18603-production-type-ratchet.md new file mode 100644 index 000000000000..29d946a23439 --- /dev/null +++ b/changelog.d/maintenance/18603-production-type-ratchet.md @@ -0,0 +1 @@ +- **chore(typecheck):** a new blocking gate, `check:build-typecheck`, holds the production TypeScript surface against frozen per-file baselines (453 → 254 diagnostics, 10 `@ts-nocheck` directives removed), and the API-route gate now shares the same guarded ratchet machinery. Along the way: the Cursor login poll route now passes the machine id to `syncToCloud()` (it used to sync to `/sync/undefined`), and the Inner.ai model discovery request now actually carries the provider outbound guard (it was passed as an ignored third argument). (#18603 — thanks @HouMinXi) diff --git a/config/quality/api-typecheck-baseline.json b/config/quality/api-typecheck-baseline.json index c39759bae34c..e7f228e7ef35 100644 --- a/config/quality/api-typecheck-baseline.json +++ b/config/quality/api-typecheck-baseline.json @@ -8,9 +8,6 @@ "open-sse/utils/sseHeartbeat.ts": { "TS2353": 1 }, - "open-sse/utils/stream.ts": { - "TS2353": 1 - }, "src/app/api/assess/route.ts": { "TS2339": 1 }, @@ -23,33 +20,18 @@ "src/app/api/cli-tools/claude-settings/route.ts": { "TS2339": 1 }, - "src/app/api/cli-tools/cline-settings/route.ts": { - "TS2339": 5 - }, "src/app/api/cli-tools/codex-settings/route.ts": { "TS2345": 2 }, "src/app/api/cli-tools/grok-build-settings/route.ts": { "TS2304": 1 }, - "src/app/api/cli-tools/hermes-agent-settings/route.ts": { - "TS2345": 1 - }, - "src/app/api/cli-tools/letta-settings/route.ts": { - "TS2339": 1 - }, - "src/app/api/cli-tools/omp-settings/route.ts": { - "TS2339": 8 - }, "src/app/api/cli-tools/qwen-settings/route.ts": { "TS2322": 1 }, "src/app/api/combos/auto/route.ts": { "TS2322": 1 }, - "src/app/api/combos/test/route.ts": { - "TS2345": 1 - }, "src/app/api/compression/compare/route.ts": { "TS2345": 1 }, @@ -65,9 +47,6 @@ "src/app/api/copilot/chat/route.ts": { "TS2345": 1 }, - "src/app/api/guardrails/test/route.ts": { - "TS2554": 1 - }, "src/app/api/internal/codex-responses-ws/route.ts": { "TS2339": 4 }, @@ -86,9 +65,6 @@ "src/app/api/model-combo-mappings/route.ts": { "TS2339": 1 }, - "src/app/api/models/alias/route.ts": { - "TS2339": 5 - }, "src/app/api/models/route.ts": { "TS2345": 3, "TS2538": 1 @@ -100,13 +76,9 @@ "TS2339": 3 }, "src/app/api/oauth/codex/import/route.ts": { - "TS2554": 1, "TS2353": 1, "TS2339": 3 }, - "src/app/api/oauth/cursor/login/poll/route.ts": { - "TS2554": 1 - }, "src/app/api/oauth/kiro/auto-import/route.ts": { "TS2345": 1 }, @@ -125,17 +97,14 @@ "src/app/api/providers/[id]/models/route.ts": { "TS2367": 1, "TS2339": 2, - "TS2322": 2, - "TS2554": 2, - "TS2345": 3 + "TS2322": 2 }, "src/app/api/providers/[id]/refresh-cursor/route.ts": { "TS2352": 1 }, "src/app/api/providers/[id]/refresh/route.ts": { "TS2345": 1, - "TS2698": 1, - "TS2339": 6 + "TS2698": 1 }, "src/app/api/providers/[id]/sync-models/route.ts": { "TS2345": 1 @@ -152,7 +121,6 @@ }, "src/app/api/providers/route.ts": { "TS2352": 1, - "TS2322": 2, "TS2345": 3 }, "src/app/api/providers/test-batch/route.ts": { @@ -189,12 +157,8 @@ "TS2339": 1 }, "src/app/api/settings/cache-config/route.ts": { - "TS2339": 1, "TS2322": 1 }, - "src/app/api/settings/database/route.ts": { - "TS2345": 1 - }, "src/app/api/settings/models-dev/route.ts": { "TS2339": 1 }, @@ -234,25 +198,17 @@ "src/app/api/tools/agent-bridge/agents/[id]/detected-models/route.ts": { "TS2769": 1 }, - "src/app/api/tools/traffic-inspector/internal/ingest/route.ts": { - "TS1117": 2, - "TS2345": 1 - }, "src/app/api/tools/traffic-inspector/ws/route.ts": { "TS2578": 1 }, "src/app/api/translator/send/route.ts": { "TS2345": 1, - "TS2322": 1, - "TS2339": 1 + "TS2322": 1 }, "src/app/api/translator/translate/route.ts": { "TS2345": 1, "TS2322": 1 }, - "src/app/api/usage/analytics/route.ts": { - "TS2352": 15 - }, "src/app/api/usage/combo-health-autopilot/route.ts": { "TS2769": 2 }, @@ -269,11 +225,11 @@ "TS2339": 1 }, "src/app/api/v1/images/edits/route.ts": { - "TS2339": 18, - "TS2322": 4 + "TS2339": 15, + "TS2322": 3 }, "src/app/api/v1/messages/count_tokens/route.ts": { - "TS2339": 2, + "TS2339": 1, "TS2322": 1 }, "src/app/api/v1/music/generations/route.ts": { @@ -290,9 +246,6 @@ "TS2339": 3, "TS2322": 1 }, - "src/app/api/v1/providers/[provider]/images/generations/route.ts": { - "TS2339": 5 - }, "src/app/api/v1/rerank/route.ts": { "TS2339": 2 }, @@ -322,33 +275,15 @@ "src/app/api/version-manager/start/route.ts": { "TS2339": 1 }, - "src/app/api/version-manager/stop/route.ts": { - "TS2339": 1 - }, - "src/app/api/webhooks/[id]/route.ts": { - "TS2554": 1 - }, "src/app/api/webhooks/[id]/test/route.ts": { "TS2352": 2 }, - "src/app/api/webhooks/route.ts": { - "TS2554": 1, - "TS2345": 1 - }, "src/lib/db/tierConfig.ts": { "TS2345": 2 }, - "src/lib/monitoring/comboHealthAutopilot.ts": { - "TS2305": 1, - "TS2345": 1 - }, "src/lib/monitoring/providerHealthAutopilot.ts": { "TS2352": 4 }, - "src/lib/omnirouteStatus.ts": { - "TS2322": 1, - "TS2558": 1 - }, "src/lib/providerModels/managedModelImport.ts": { "TS2352": 4 }, @@ -364,32 +299,105 @@ "src/lib/usage/comboForecast.ts": { "TS2345": 1 }, - "src/lib/usage/comboHealth.ts": { - "TS2345": 1 - }, "src/lib/usage/comboScoringInspector.ts": { "TS2352": 1, "TS2741": 1 }, - "src/lib/usage/providerWindowCosts.ts": { - "TS2322": 2, - "TS2558": 5, - "TS2339": 12, - "TS2345": 1 - }, - "src/lib/vscode/modelPresentation.ts": { - "TS2554": 1 - }, "src/lib/ws/handshake.ts": { "TS2339": 1 }, - "src/mitm/detection/index.ts": { - "TS2741": 1 - }, - "src/mitm/inspector/httpProxyServer.ts": { - "TS2769": 1 - }, - "src/shared/schemas/cliCatalog.ts": { - "TS2554": 2 + "_program": { + "files": 4372, + "include": [ + "src/app/api/**/*.ts", + "src/app/api/**/*.tsx" + ], + "exclude": [], + "compilerOptions": { + "target": "ES2022", + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": true, + "checkJs": false, + "skipLibCheck": true, + "strict": false, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "forceConsistentCasingInFileNames": true, + "paths": { + "@/*": [ + "./src/*" + ], + "@omniroute/open-sse": [ + "./open-sse" + ], + "@omniroute/open-sse/*": [ + "./open-sse/*" + ], + "@omniroute/browser-pool": [ + "./packages/browser-pool/src" + ], + "@omniroute/ui-kit": [ + "./packages/ui-kit/src" + ], + "@omniroute/ui-kit/*": [ + "./packages/ui-kit/src/*" + ] + }, + "plugins": [ + { + "name": "next" + } + ], + "tsBuildInfoFile": ".tsbuildinfo-typecheck-api" + } + }, + "_nocheck": [ + "open-sse/executors/bedrock.ts", + "open-sse/services/provider.ts", + "open-sse/services/tokenRefresh.ts", + "open-sse/services/tokenRefresh/casGuard.ts", + "open-sse/services/tokenRefresh/circuitBreaker.ts", + "open-sse/services/tokenRefresh/providers/claudeOAuth.ts", + "open-sse/services/tokenRefresh/providers/cline.ts", + "open-sse/services/tokenRefresh/providers/codebuddyCn.ts", + "open-sse/services/tokenRefresh/providers/codebuddyIntl.ts", + "open-sse/services/tokenRefresh/providers/codebuddyShared.ts", + "open-sse/services/tokenRefresh/providers/codex.ts", + "open-sse/services/tokenRefresh/providers/copilot.ts", + "open-sse/services/tokenRefresh/providers/github.ts", + "open-sse/services/tokenRefresh/providers/gitlabDuo.ts", + "open-sse/services/tokenRefresh/providers/google.ts", + "open-sse/services/tokenRefresh/providers/kimiCoding.ts", + "open-sse/services/tokenRefresh/providers/kiro.ts", + "open-sse/services/tokenRefresh/providers/openference.ts", + "open-sse/services/tokenRefresh/providers/qoder.ts", + "open-sse/services/tokenRefresh/providers/workbuddy.ts", + "open-sse/services/tokenRefresh/rotationMap.ts", + "open-sse/services/tokenRefresh/shared.ts", + "open-sse/services/wildcardRouter.ts", + "open-sse/utils/proxyFetch.ts", + "src/lib/tokenHealthCheck.ts", + "src/lib/tokenHealthCheckCopilot.ts", + "src/lib/usage/migrations.ts" + ], + "_suppressions": { + "open-sse/executors/copilot-web.ts": 1, + "open-sse/mcp-server/server.ts": 21, + "open-sse/services/usage/qoder.ts": 1, + "src/app/api/system/env/repair/route.ts": 1, + "src/app/api/tools/traffic-inspector/ws/route.ts": 1, + "src/lib/cli-helper/claudeProfileAutoSync.ts": 1, + "src/lib/cli-helper/codexProfileAutoSync.ts": 1 } } diff --git a/config/quality/build-typecheck-baseline.json b/config/quality/build-typecheck-baseline.json new file mode 100644 index 000000000000..2390584478a2 --- /dev/null +++ b/config/quality/build-typecheck-baseline.json @@ -0,0 +1,444 @@ +{ + "bin/cli/tray/tray.ts": { + "TS2339": 4 + }, + "scripts/ad-hoc/claude-mem-live-check.ts": { + "TS2554": 1 + }, + "scripts/ad-hoc/dump-auto-combos.ts": { + "TS2322": 1 + }, + "scripts/perf/messages-route-memory-profile.ts": { + "TS2540": 1, + "TS2322": 1 + }, + "scripts/perf/parse-model-ab-child.ts": { + "TS2307": 1 + }, + "scripts/perf/routing-events-bench.ts": { + "TS2741": 1 + }, + "scripts/router-eval/index.ts": { + "TS2345": 1, + "TS2307": 1 + }, + "src/app/(dashboard)/dashboard/cache/components/CachePerformance.tsx": { + "TS2339": 1 + }, + "src/app/(dashboard)/dashboard/cli-code/components/ClaudeToolCard.tsx": { + "TS2339": 2 + }, + "src/app/(dashboard)/dashboard/cli-code/components/CodexToolCard.tsx": { + "TS2345": 3 + }, + "src/app/(dashboard)/dashboard/cli-code/components/CustomCliCard.tsx": { + "TS2345": 1 + }, + "src/app/(dashboard)/dashboard/compression/studio/EncoderComparisonTable.tsx": { + "TS2322": 1 + }, + "src/app/(dashboard)/dashboard/costs/quota-share/QuotaSharePageClient.tsx": { + "TS2719": 2, + "TS2322": 1 + }, + "src/app/(dashboard)/dashboard/costs/quota-share/components/UsageLogCard.tsx": { + "TS2869": 2 + }, + "src/app/(dashboard)/dashboard/playground/components/PresetPicker.tsx": { + "TS2352": 1 + }, + "src/app/(dashboard)/dashboard/playground/components/ToolsBuilder.tsx": { + "TS2339": 1 + }, + "src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx": { + "TS2322": 4 + }, + "src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionsListPanel.tsx": { + "TS2322": 2 + }, + "src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModelsSection.tsx": { + "TS2322": 1 + }, + "src/app/(dashboard)/dashboard/providers/[id]/components/ProviderParamFilterSection.tsx": { + "TS2339": 6 + }, + "src/app/(dashboard)/dashboard/providers/components/onboarding/providerOnboardingCatalog.ts": { + "TS2339": 4, + "TS2345": 2 + }, + "src/app/(dashboard)/dashboard/providers/providerPageUtils.ts": { + "TS2345": 1 + }, + "src/app/(dashboard)/dashboard/quota/page.tsx": { + "TS2339": 4 + }, + "src/app/(dashboard)/dashboard/tools/traffic-inspector/components/CustomHostsManager.tsx": { + "TS2339": 1 + }, + "src/app/(dashboard)/dashboard/tools/traffic-inspector/components/tabs/ResponseBodyTab.tsx": { + "TS2339": 5 + }, + "src/app/(dashboard)/dashboard/translator/components/MonitorTab.tsx": { + "TS2345": 1 + }, + "src/app/(dashboard)/dashboard/translator/components/advanced/StreamTransformerAccordion.tsx": { + "TS2345": 1 + }, + "src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx": { + "TS2339": 2 + }, + "src/app/api/assess/route.ts": { + "TS2339": 1 + }, + "src/app/api/cache/route.ts": { + "TS2339": 1 + }, + "src/app/api/cli-tools/all-statuses/route.ts": { + "TS2339": 1 + }, + "src/app/api/cli-tools/claude-settings/route.ts": { + "TS2339": 1 + }, + "src/app/api/cli-tools/codex-settings/route.ts": { + "TS2345": 2 + }, + "src/app/api/cli-tools/grok-build-settings/route.ts": { + "TS2304": 1 + }, + "src/app/api/cli-tools/qwen-settings/route.ts": { + "TS2322": 1 + }, + "src/app/api/combos/auto/route.ts": { + "TS2322": 1 + }, + "src/app/api/compression/compare/route.ts": { + "TS2345": 1 + }, + "src/app/api/compression/preview/route.ts": { + "TS2345": 1 + }, + "src/app/api/context/combos/[id]/route.ts": { + "TS2345": 1 + }, + "src/app/api/context/combos/route.ts": { + "TS2345": 1 + }, + "src/app/api/copilot/chat/route.ts": { + "TS2345": 1 + }, + "src/app/api/internal/codex-responses-ws/route.ts": { + "TS2339": 4 + }, + "src/app/api/local/redis/start/route.ts": { + "TS2339": 1 + }, + "src/app/api/local/redis/stop/route.ts": { + "TS2339": 1 + }, + "src/app/api/logs/[id]/route.ts": { + "TS2322": 1 + }, + "src/app/api/model-capability-overrides/route.ts": { + "TS2339": 1 + }, + "src/app/api/model-combo-mappings/route.ts": { + "TS2339": 1 + }, + "src/app/api/models/route.ts": { + "TS2345": 3, + "TS2538": 1 + }, + "src/app/api/monitoring/health/route.ts": { + "TS2322": 1 + }, + "src/app/api/oauth/codex/import-token/route.ts": { + "TS2339": 3 + }, + "src/app/api/oauth/codex/import/route.ts": { + "TS2353": 1, + "TS2339": 3 + }, + "src/app/api/oauth/kiro/auto-import/route.ts": { + "TS2345": 1 + }, + "src/app/api/omniroute/route/preview/route.ts": { + "TS2345": 1 + }, + "src/app/api/playground/presets/[id]/route.ts": { + "TS2339": 3 + }, + "src/app/api/provider-nodes/validate/route.ts": { + "TS2339": 2 + }, + "src/app/api/providers/[id]/login/route.ts": { + "TS2739": 1 + }, + "src/app/api/providers/[id]/models/route.ts": { + "TS2367": 1, + "TS2339": 2, + "TS2322": 2 + }, + "src/app/api/providers/[id]/refresh-cursor/route.ts": { + "TS2352": 1 + }, + "src/app/api/providers/[id]/refresh/route.ts": { + "TS2345": 1, + "TS2698": 1 + }, + "src/app/api/providers/[id]/sync-models/route.ts": { + "TS2345": 1 + }, + "src/app/api/providers/[id]/test/route.ts": { + "TS2362": 1, + "TS2698": 1 + }, + "src/app/api/providers/free-onboarding/route.ts": { + "TS2345": 1 + }, + "src/app/api/providers/health-autopilot/actions/route.ts": { + "TS2339": 1 + }, + "src/app/api/providers/route.ts": { + "TS2352": 1, + "TS2345": 3 + }, + "src/app/api/providers/test-batch/route.ts": { + "TS2345": 4 + }, + "src/app/api/providers/validate/route.ts": { + "TS2322": 1 + }, + "src/app/api/providers/volcengine-plan/connect/[sessionId]/cancel/route.ts": { + "TS2739": 1 + }, + "src/app/api/providers/volcengine-plan/connect/[sessionId]/code/route.ts": { + "TS2739": 1 + }, + "src/app/api/providers/volcengine-plan/connect/[sessionId]/identity/route.ts": { + "TS2739": 1 + }, + "src/app/api/providers/volcengine-plan/connect/[sessionId]/resend/route.ts": { + "TS2739": 1 + }, + "src/app/api/providers/volcengine-plan/connect/[sessionId]/status/route.ts": { + "TS2739": 1 + }, + "src/app/api/providers/volcengine-plan/connect/route.ts": { + "TS2739": 1 + }, + "src/app/api/radar/local-model-state/route.ts": { + "TS2339": 4 + }, + "src/app/api/resilience/model-cooldowns/route.ts": { + "TS2339": 1 + }, + "src/app/api/services/_shared/installRoute.ts": { + "TS2339": 1 + }, + "src/app/api/settings/cache-config/route.ts": { + "TS2322": 1 + }, + "src/app/api/settings/models-dev/route.ts": { + "TS2339": 1 + }, + "src/app/api/settings/obsidian/webdav/route.ts": { + "TS2339": 1 + }, + "src/app/api/settings/proxies/bulk-import/route.ts": { + "TS2345": 1 + }, + "src/app/api/settings/proxy/cloudflare-deploy/route.ts": { + "TS2769": 1, + "TS2322": 2 + }, + "src/app/api/settings/proxy/deno-deploy/route.ts": { + "TS2322": 4 + }, + "src/app/api/settings/proxy/vercel-deploy/route.ts": { + "TS2322": 3 + }, + "src/app/api/settings/reasoning-routing-rules/[id]/route.ts": { + "TS2339": 1 + }, + "src/app/api/settings/reasoning-routing-rules/route.ts": { + "TS2339": 1 + }, + "src/app/api/settings/reasoning-routing-rules/simulate/route.ts": { + "TS2322": 1, + "TS2339": 1 + }, + "src/app/api/system/env/repair/route.ts": { + "TS2578": 1, + "TS2353": 3 + }, + "src/app/api/tools/agent-bridge/agents/[id]/detected-models/route.ts": { + "TS2769": 1 + }, + "src/app/api/tools/traffic-inspector/ws/route.ts": { + "TS2578": 1 + }, + "src/app/api/translator/send/route.ts": { + "TS2345": 1, + "TS2322": 1 + }, + "src/app/api/translator/translate/route.ts": { + "TS2345": 1, + "TS2322": 1 + }, + "src/app/api/usage/combo-health-autopilot/route.ts": { + "TS2769": 2 + }, + "src/app/api/v1/batches/route.ts": { + "TS2339": 1 + }, + "src/app/api/v1/classify/route.ts": { + "TS2322": 1 + }, + "src/app/api/v1/files/[id]/content/route.ts": { + "TS2345": 1 + }, + "src/app/api/v1/files/route.ts": { + "TS2339": 1 + }, + "src/app/api/v1/messages/count_tokens/route.ts": { + "TS2339": 1, + "TS2322": 1 + }, + "src/app/api/v1/music/generations/route.ts": { + "TS2322": 1, + "TS2345": 1 + }, + "src/app/api/v1/ocr/route.ts": { + "TS2345": 1 + }, + "src/app/api/v1/provider-plugin-manifest/route.ts": { + "TS2345": 1 + }, + "src/app/api/v1/providers/[provider]/embeddings/route.ts": { + "TS2339": 3, + "TS2322": 1 + }, + "src/app/api/v1/rerank/route.ts": { + "TS2339": 2 + }, + "src/app/api/v1/segment/route.ts": { + "TS2322": 1 + }, + "src/app/api/v1/session-leases/route.ts": { + "TS2339": 4, + "TS2345": 1 + }, + "src/app/api/v1/speech-to-text/route.ts": { + "TS2353": 1 + }, + "src/app/api/v1/text-to-speech/[voiceId]/route.ts": { + "TS2353": 1 + }, + "src/app/api/v1/web/fetch/route.ts": { + "TS2339": 1 + }, + "src/app/api/v1beta/models/route.ts": { + "TS2345": 1, + "TS2538": 1 + }, + "src/app/api/version-manager/restart/route.ts": { + "TS2339": 1 + }, + "src/app/api/version-manager/start/route.ts": { + "TS2339": 1 + }, + "src/app/api/webhooks/[id]/test/route.ts": { + "TS2352": 2 + }, + "src/app/docs/[...slug]/page.tsx": { + "TS2339": 1 + }, + "src/app/docs/layout.tsx": { + "TS2739": 1 + }, + "src/app/manifest.ts": { + "TS2820": 1 + }, + "src/lib/apiBridgeServer.ts": { + "TS2345": 2 + }, + "src/lib/combos/builderDraft.ts": { + "TS2741": 1 + }, + "src/lib/config/hotReload.ts": { + "TS2339": 1 + }, + "src/lib/db/tierConfig.ts": { + "TS2345": 2 + }, + "src/lib/docsSanitizer.ts": { + "TS2345": 1 + }, + "src/lib/env/runtimeEnv.ts": { + "TS2345": 2 + }, + "src/lib/monitoring/providerHealthAutopilot.ts": { + "TS2352": 4 + }, + "src/lib/plugins/testRunner.ts": { + "TS2739": 2 + }, + "src/lib/providerModels/managedModelImport.ts": { + "TS2352": 4 + }, + "src/lib/providers/codexConnectionDefaults.ts": { + "TS2345": 2 + }, + "src/lib/proxyHealth/scheduler.ts": { + "TS2345": 2 + }, + "src/lib/proxyHealth/statusPolicy.ts": { + "TS2559": 2 + }, + "src/lib/proxySubscription/parse.ts": { + "TS2345": 3 + }, + "src/lib/quota/quotaAnalytics.ts": { + "TS2769": 1 + }, + "src/lib/quota/quotaResetTimers.ts": { + "TS2769": 2 + }, + "src/lib/routerEval/index.ts": { + "TS2554": 1 + }, + "src/lib/services/htmlRewriter.ts": { + "TS2322": 2, + "TS2345": 2 + }, + "src/lib/usage/comboForecast.ts": { + "TS2345": 1 + }, + "src/lib/usage/comboScoringInspector.ts": { + "TS2352": 1, + "TS2741": 1 + }, + "src/lib/ws/handshake.ts": { + "TS2339": 1 + }, + "src/server/authz/pipeline.ts": { + "TS2345": 1 + }, + "src/server/ws/liveServer.ts": { + "TS2345": 1, + "TS2322": 1 + }, + "src/shared/components/OAuthModal.tsx": { + "TS2769": 2, + "TS2345": 3 + }, + "src/shared/components/analytics/charts.tsx": { + "TS2345": 1 + }, + "src/shared/components/analytics/rechartsDonuts.tsx": { + "TS2739": 2 + }, + "src/sse/handlers/chatHelpers.ts": { + "TS2559": 1 + } +} diff --git a/config/quality/dashboard-typecheck-baseline.json b/config/quality/dashboard-typecheck-baseline.json index 307343373032..79a8c2f97300 100644 --- a/config/quality/dashboard-typecheck-baseline.json +++ b/config/quality/dashboard-typecheck-baseline.json @@ -1,174 +1,102 @@ { "src/app/(dashboard)/dashboard/HomePageClient.tsx": { - "TS2339": 12 - }, - "src/app/(dashboard)/dashboard/agent-skills/AgentSkillsPageClient.tsx": { - "TS2503": 4 - }, - "src/app/(dashboard)/dashboard/agent-skills/components/CoverageBar.tsx": { - "TS2503": 2 - }, - "src/app/(dashboard)/dashboard/agent-skills/components/McpA2aLinksBar.tsx": { - "TS2503": 3 - }, - "src/app/(dashboard)/dashboard/agent-skills/components/SkillCard.tsx": { - "TS2503": 2 - }, - "src/app/(dashboard)/dashboard/agent-skills/components/SkillPreviewPane.tsx": { - "TS2503": 3 + "TS2339": 7 }, "src/app/(dashboard)/dashboard/cache/__tests__/CachePage.test.tsx": { - "TS1117": 2 + "TS1117": 1 }, "src/app/(dashboard)/dashboard/cache/__tests__/MemoryCards.test.tsx": { - "TS2322": 2 + "TS2322": 1 }, "src/app/(dashboard)/dashboard/cache/components/CachePerformance.tsx": { - "TS2339": 2 + "TS2339": 1 }, "src/app/(dashboard)/dashboard/cli-code/components/ClaudeToolCard.tsx": { - "TS2339": 3 + "TS2339": 2 }, "src/app/(dashboard)/dashboard/cli-code/components/CodexToolCard.tsx": { - "TS2345": 4 + "TS2345": 3 }, "src/app/(dashboard)/dashboard/cli-code/components/CustomCliCard.tsx": { - "TS2345": 2 - }, - "src/app/(dashboard)/dashboard/cli-code/components/DroidToolCard.tsx": { - "TS2554": 3 - }, - "src/app/(dashboard)/dashboard/combos/page.tsx": { - "TS2339": 5, - "TS2345": 6, - "TS2698": 2, - "TS2322": 15 + "TS2345": 1 }, "src/app/(dashboard)/dashboard/compression/studio/EncoderComparisonTable.tsx": { - "TS2322": 2 + "TS2322": 1 }, "src/app/(dashboard)/dashboard/costs/quota-share/QuotaSharePageClient.tsx": { - "TS2551": 9, - "TS2322": 3, - "TS2719": 3, - "TS2739": 2 - }, - "src/app/(dashboard)/dashboard/costs/quota-share/components/StackedAllocationBar.tsx": { - "TS2503": 2 + "TS2719": 2, + "TS2322": 1 }, "src/app/(dashboard)/dashboard/costs/quota-share/components/UsageLogCard.tsx": { - "TS2869": 3 - }, - "src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx": { - "TS2322": 22 - }, - "src/app/(dashboard)/dashboard/omni-skills/OmniSkillsPageClient.tsx": { - "TS2503": 2 - }, - "src/app/(dashboard)/dashboard/omni-skills/components/OmniExecutionsTab.tsx": { - "TS2503": 2 - }, - "src/app/(dashboard)/dashboard/omni-skills/components/OmniMarketplaceTab.tsx": { - "TS2503": 2 - }, - "src/app/(dashboard)/dashboard/omni-skills/components/OmniSandboxTab.tsx": { - "TS2503": 2 - }, - "src/app/(dashboard)/dashboard/omni-skills/components/OmniSkillCard.tsx": { - "TS2503": 2 - }, - "src/app/(dashboard)/dashboard/omni-skills/components/OmniSkillsList.tsx": { - "TS2503": 2 - }, - "src/app/(dashboard)/dashboard/omni-skills/components/SkillInspectorPane.tsx": { - "TS2503": 2 + "TS2869": 2 }, "src/app/(dashboard)/dashboard/playground/components/PresetPicker.tsx": { - "TS2352": 2 + "TS2352": 1 }, "src/app/(dashboard)/dashboard/playground/components/ToolsBuilder.tsx": { - "TS2339": 2 + "TS2339": 1 }, "src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx": { - "TS2322": 5 + "TS2322": 4 }, "src/app/(dashboard)/dashboard/providers/[id]/__tests__/phase1e.test.tsx": { - "TS2741": 3 + "TS2741": 2 }, "src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionsListPanel.tsx": { - "TS2322": 3 - }, - "src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModalsPanel.tsx": { - "TS2322": 4, - "TS2739": 2, - "TS2345": 4 + "TS2322": 2 }, "src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModelsSection.tsx": { - "TS2322": 2 + "TS2322": 1 }, "src/app/(dashboard)/dashboard/providers/[id]/components/ProviderParamFilterSection.tsx": { - "TS2339": 8 - }, - "src/app/(dashboard)/dashboard/providers/[id]/components/ProviderPlaygroundPanel.tsx": { - "TS2503": 2 - }, - "src/app/(dashboard)/dashboard/providers/[id]/hooks/useModelVisibilityHandlers.ts": { - "TS2339": 18 + "TS2339": 6 }, "src/app/(dashboard)/dashboard/providers/components/onboarding/providerOnboardingCatalog.ts": { - "TS2339": 5, - "TS2345": 3 + "TS2339": 4, + "TS2345": 2 }, "src/app/(dashboard)/dashboard/providers/providerPageUtils.ts": { - "TS2345": 2 + "TS2345": 1 }, "src/app/(dashboard)/dashboard/quota/page.tsx": { - "TS2339": 5 + "TS2339": 4 }, "src/app/(dashboard)/dashboard/settings/components/AppearanceTab.tsx": { - "TS2339": 5 + "TS2339": 4 }, "src/app/(dashboard)/dashboard/tools/traffic-inspector/components/CustomHostsManager.tsx": { - "TS2339": 2 + "TS2339": 1 }, "src/app/(dashboard)/dashboard/tools/traffic-inspector/components/tabs/ResponseBodyTab.tsx": { - "TS2339": 6 + "TS2339": 5 }, "src/app/(dashboard)/dashboard/translator/components/MonitorTab.tsx": { - "TS2345": 2 + "TS2345": 1 }, "src/app/(dashboard)/dashboard/translator/components/advanced/StreamTransformerAccordion.tsx": { - "TS2345": 2 + "TS2345": 1 }, "src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx": { - "TS2339": 3 + "TS2339": 2 }, "src/lib/combos/builderDraft.ts": { - "TS2741": 2 + "TS2741": 1 }, "src/lib/services/htmlRewriter.ts": { - "TS2322": 3, - "TS2345": 3 + "TS2322": 2, + "TS2345": 2 }, "src/shared/components/OAuthModal.tsx": { - "TS2769": 3, - "TS2345": 4 - }, - "src/shared/components/SkillsConceptCard.tsx": { - "TS2503": 2 + "TS2769": 2, + "TS2345": 3 }, "src/shared/components/analytics/charts.tsx": { - "TS2345": 2 + "TS2345": 1 }, "src/shared/components/analytics/rechartsDonuts.tsx": { - "TS2739": 3 + "TS2739": 2 }, "src/shared/hooks/useElectron.ts": { - "TS2339": 23 - }, - "src/shared/schemas/cliCatalog.ts": { - "TS2554": 3 - }, - "_relax_velocity_2026_08_30": "per-file TS diagnostic counts raised by 20% (229 \u2192 324); velocity phase, see quality-baseline.json _policy.", - "_ratchet_2026_10_22_kea7886": "Surgical ratchet-down after the UI kit migration (KEA-7886, Task 42): (file, code) pairs now at 0 removed; the others set to min(baseline, ceil(live * 1.2)) so the velocity-phase 20% headroom is kept. Live 194; sum of counts 322 -> 270 (16 pairs)." + "TS2339": 19 + } } diff --git a/config/quality/gate-manifest.json b/config/quality/gate-manifest.json index 86e98289d49a..8f59425575eb 100644 --- a/config/quality/gate-manifest.json +++ b/config/quality/gate-manifest.json @@ -113,6 +113,11 @@ "command": "node scripts/check/check-dashboard-typecheck.mjs", "disposition": "separately-invoked" }, + { + "name": "check:build-typecheck", + "command": "node scripts/check/check-build-typecheck.mjs", + "disposition": "separately-invoked" + }, { "name": "check:db-rules", "command": "node scripts/check/check-db-rules.mjs", diff --git a/config/quality/ts-nocheck-baseline.json b/config/quality/ts-nocheck-baseline.json new file mode 100644 index 000000000000..4969f46aab4c --- /dev/null +++ b/config/quality/ts-nocheck-baseline.json @@ -0,0 +1,101 @@ +{ + "_comment": "Files that opt out of type checking entirely with @ts-nocheck. tsc never reports an error inside them, so they are invisible to the diagnostic baseline and would otherwise be a place where debt can hide forever. This list is frozen: adding a file here (or adding @ts-nocheck to a new file) fails the gate. Removing an entry by fixing the file is always allowed and should be paired with `--update` to ratchet the list down.", + "_measured_2026_10_30": "With all of these disabled at once the whole-app program reports 326 diagnostics instead of 272, so 54 errors are currently hidden behind them. The bulk sits in tokenHealthCheck.ts (23), bedrock.ts (21) and tokenRefresh.ts (6) - token and credential paths.", + "files": [ + "open-sse/services/provider.ts", + "open-sse/services/tokenRefresh.ts", + "open-sse/services/tokenRefresh/circuitBreaker.ts", + "open-sse/services/tokenRefresh/providers/claudeOAuth.ts", + "open-sse/services/tokenRefresh/providers/cline.ts", + "open-sse/services/tokenRefresh/providers/codex.ts", + "open-sse/services/tokenRefresh/providers/copilot.ts", + "open-sse/services/tokenRefresh/providers/github.ts", + "open-sse/services/tokenRefresh/providers/google.ts", + "open-sse/services/tokenRefresh/providers/kiro.ts", + "open-sse/services/tokenRefresh/providers/openference.ts", + "open-sse/services/tokenRefresh/providers/qoder.ts", + "open-sse/services/tokenRefresh/providers/workbuddy.ts", + "open-sse/services/tokenRefresh/rotationMap.ts", + "open-sse/services/tokenRefresh/shared.ts", + "open-sse/services/wildcardRouter.ts", + "open-sse/utils/proxyFetch.ts", + "src/lib/usage/migrations.ts" + ], + "_program": { + "files": 6072, + "include": [ + "electron/**/*.d.ts", + "src/**/*.ts", + "src/**/*.tsx", + "open-sse/**/*.ts", + "open-sse/**/*.tsx", + "packages/**/*.ts", + "packages/**/*.tsx", + "bin/**/*.ts", + "bin/**/*.tsx", + "scripts/**/*.ts", + "*.ts" + ], + "exclude": [ + "node_modules", + "tests", + "**/*.test.ts", + "**/*.test.tsx", + "**/*.spec.ts", + "**/__tests__/**", + ".build", + ".next", + ".next-playwright", + ".claude", + ".scratch", + ".source" + ], + "compilerOptions": { + "target": "ES2022", + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": true, + "checkJs": false, + "skipLibCheck": true, + "strict": false, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "forceConsistentCasingInFileNames": true, + "paths": { + "@/*": [ + "./src/*" + ], + "@omniroute/open-sse": [ + "./open-sse" + ], + "@omniroute/open-sse/*": [ + "./open-sse/*" + ], + "@omniroute/browser-pool": [ + "./packages/browser-pool/src" + ], + "@omniroute/ui-kit": [ + "./packages/ui-kit/src" + ], + "@omniroute/ui-kit/*": [ + "./packages/ui-kit/src/*" + ] + }, + "plugins": [ + { + "name": "next" + } + ] + } + } +} diff --git a/config/quality/ts-suppressions-baseline.json b/config/quality/ts-suppressions-baseline.json new file mode 100644 index 000000000000..9e94f2acdb0c --- /dev/null +++ b/config/quality/ts-suppressions-baseline.json @@ -0,0 +1,10 @@ +{ + "open-sse/executors/copilot-web.ts": 1, + "open-sse/mcp-server/server.ts": 21, + "open-sse/services/usage/qoder.ts": 1, + "src/app/api/system/env/repair/route.ts": 1, + "src/app/api/tools/traffic-inspector/ws/route.ts": 1, + "src/lib/cli-helper/claudeProfileAutoSync.ts": 1, + "src/lib/cli-helper/codexProfileAutoSync.ts": 1, + "src/lib/services/reverseProxy.ts": 1 +} diff --git a/docs/architecture/QUALITY_GATES.md b/docs/architecture/QUALITY_GATES.md index 56b79009a206..5c1b417cb6ae 100644 --- a/docs/architecture/QUALITY_GATES.md +++ b/docs/architecture/QUALITY_GATES.md @@ -101,38 +101,54 @@ assertion weakening and other masking remain owned by the independently blocking Runs on every PR to `main`. Blocks merge on failure. -| Script (`npm run ...`) | Validates | Blocking | -| --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------- | -| `check:node-runtime` | Node.js version is within the supported range | Yes | -| `check:cycles` | Circular imports across all of `src/` + `open-sse/` (AST-based, tsconfig `paths` resolved). Bare exits 1 whenever any cycle exists (fail-closed since #15281) and lists the SCCs. `check:cycles:ratchet` (what CI runs) blocks when the count exceeds the `metrics.cycles` ceiling in `quality-baseline.json` — currently 14, `direction: down`, so it can only fall (#15159 G-01/G-02) | Yes (ratchet) | -| `check:boundary` | `open-sse → src` coupling: unique file-to-file imports from the engine into the app (`scripts/quality/measure-boundary.mjs`; the edge list is the v4 core/module-split remediation backlog). Bare = advisory report (same as `quality:boundary`). `check:boundary:ratchet` (what CI runs, also in the `quality.yml` fast-gates ratchet loop) blocks when the count exceeds the `metrics.boundaryOpenSseToSrc` ceiling in `quality-baseline.json` — seeded at the measured 771, `direction: down`; `--update` only lowers it; base-relative on PRs (`--base-ref`, see below) | Yes (ratchet) | -| `check:route-validation:t06` | Zod schemas present on all routes (Tier 6 policy) | Yes | -| `check:any-budget:t11` | `@ts-expect-error // any` count does not exceed budget (Tier 11 catraca) | Yes | -| `check:provider-consistency` | Every provider in `providers.ts` has a matching entry in `providerRegistry.ts` (and vice-versa, within the allowlist) | Yes | -| `check:model-lifecycle` | The three hand-maintained routing tables stay consistent with the checked-in lifecycle snapshot (#11503): `FITNESS_TABLE` (`taskFitness.ts`) scores no retired id that `REGISTRY` can route; every `BUILT_IN_ALIASES` target is present in `REGISTRY` and absent from the retired-id snapshot; every retired id still in `REGISTRY` is forwarded or listed in `allowedRetiredInCatalog`; and no `DEFAULT_DEGRADATION_MAP` source or target appears retired in that snapshot. This does not prove that a model is currently served by a live upstream. Offline — compares against `config/quality/model-lifecycle.json`, refreshed by hand with `npm run quality:refresh-model-lifecycle` (network; not wired into CI). `allowedRetiredInCatalog` is a burn-down ratchet: add an entry only with a tracking issue. | Yes | -| `check:fetch-targets` | Every `fetch("/api/...")` in client-side `src/` resolves to a real `route.ts` | Yes | -| `check:deps` | All `npm install`-able deps across every `package.json` in the repo are in `dependency-allowlist.json`; new unpinned or slopsquatted packages flagged | Yes | -| `audit:deps` | `npm audit` (root + electron) — no high/critical advisories (overlaps osv `check:vuln-ratchet`; see Rationalization Backlog) | Yes | -| `check:lockfile` | `package-lock.json` integrity — https registry, integrity hashes, no host overrides | Yes | -| `check:licenses` | SPDX license allowlist for production dependencies | Yes | -| `check:tracked-artifacts` | No build artifacts / committed `node_modules` symlinks (also runs in husky pre-commit; pre-push is intentionally light — #6716) | Yes | -| `check:ai-attribution` | No AI/bot `Co-Authored-By` trailer or AI-generation footer in PR commits, title or body — Hard Rule #16 (in the `quality.yml` fast-gates loop for PR→`release/**` — reads the event payload, no-op off PRs — and a PR-only step in `ci.yml` lint for PR→`main`; also the husky `commit-msg` hook; human co-authors allowed; #14436) | -| `check:vitest-exclusions` | Every Vitest exclusion names a tracking issue and appears in `config/quality/vitest-exclusions.json` (#13204) | Yes | -| `check:file-size` | Source files (`.ts`/`.tsx` in `src/`, `open-sse/`, `electron/`, `bin/`) stay within `cap` and test files (`*.test.ts(x)`) within `testCap`; files frozen in `file-size-baseline.json` (`frozen` / `testFrozen`) must not grow past their recorded size | Yes | -| `check:error-helper` | Error responses in executors/handlers use `buildErrorBody()` / `sanitizeErrorMessage()` (Hard Rule #12) | Yes | -| `check:migration-numbering` | Migration SQL files are sequentially numbered, no gaps or duplicates | Yes | -| `check:public-creds` | No literal OAuth `client_id`/`client_secret` or Firebase Web keys outside `publicCreds.ts` (Hard Rule #11) | Yes | -| `check:db-rules` | No raw SQL outside `src/lib/db/` modules; no barrel-imports from `localDb.ts` (Hard Rules #2/#5) | Yes | -| `check:known-symbols` | Provider executors, routing strategies, and translators registered in their dispatch tables match the files on disk — no orphaned or undeclared symbols | Yes | -| `check:route-guard-membership` | Every route that spawns a child process is classified by `isLocalOnlyPath()` (Hard Rules #15/#17) | Yes | -| `check:test-discovery` | Every `*.test.ts` / `*.spec.ts` file in the repo is collected by at least one test runner (ratchet: orphan list in `test-discovery-baseline.json` can only shrink) | Yes | -| `check:test-tla-order` | No `node:test` file under `tests/unit` registers a test, hits a top-level `await`, then registers more (#18045; ratchet: `test-tla-order-allowlist.json` can only shrink) | Yes | -| `check:agent-skills-sync` | Generated agent-skills artifacts match their source catalog (no drift) | -| `check:provider-asset-provenance` | Provider logos/assets carry a recorded provenance entry | -| `lint:json` | JSON config files parse and satisfy the repo lint rules | -| `typecheck:core` | TypeScript compilation without errors (advisory warnings only) | Yes | -| `typecheck:noimplicit:core` | Strict `noImplicitAny` — forward-looking; many pre-existing call sites still need annotations | **Advisory** (`continue-on-error: true`) | -| `check:dashboard-typecheck` | `tsc` scoped to `src/app/(dashboard)/**` (#7033) — `typecheck:core`'s curated 27-file allowlist does not include any dashboard TSX, and `next build` never type-checks it either (`next.config.mjs` sets `ignoreBuildErrors: true`), so orphaned-identifier regressions there (#6625/#6909) were invisible to CI. Diffs against a frozen per-file/per-TS-code count baseline (`config/quality/dashboard-typecheck-baseline.json`, same stale-enforcement pattern as `check:known-symbols`) — only NEW errors beyond the baselined count fail the gate; ratchet down with `--update` when a pre-existing error is fixed. | Yes | +| Script (`npm run ...`) | Validates | Blocking | +| --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------- | +| `check:node-runtime` | Node.js version is within the supported range | Yes | +| `check:cycles` | Circular imports across all of `src/` + `open-sse/` (AST-based, tsconfig `paths` resolved). Bare exits 1 whenever any cycle exists (fail-closed since #15281) and lists the SCCs. `check:cycles:ratchet` (what CI runs) blocks when the count exceeds the `metrics.cycles` ceiling in `quality-baseline.json` — currently 14, `direction: down`, so it can only fall (#15159 G-01/G-02) | Yes (ratchet) | +| `check:boundary` | `open-sse → src` coupling: unique file-to-file imports from the engine into the app (`scripts/quality/measure-boundary.mjs`; the edge list is the v4 core/module-split remediation backlog). Bare = advisory report (same as `quality:boundary`). `check:boundary:ratchet` (what CI runs, also in the `quality.yml` fast-gates ratchet loop) blocks when the count exceeds the `metrics.boundaryOpenSseToSrc` ceiling in `quality-baseline.json` — seeded at the measured 771, `direction: down`; `--update` only lowers it; base-relative on PRs (`--base-ref`, see below) | Yes (ratchet) | +| `check:route-validation:t06` | Zod schemas present on all routes (Tier 6 policy) | Yes | +| `check:any-budget:t11` | `@ts-expect-error // any` count does not exceed budget (Tier 11 catraca) | Yes | +| `check:provider-consistency` | Every provider in `providers.ts` has a matching entry in `providerRegistry.ts` (and vice-versa, within the allowlist) | Yes | +| `check:model-lifecycle` | The three hand-maintained routing tables stay consistent with the checked-in lifecycle snapshot (#11503): `FITNESS_TABLE` (`taskFitness.ts`) scores no retired id that `REGISTRY` can route; every `BUILT_IN_ALIASES` target is present in `REGISTRY` and absent from the retired-id snapshot; every retired id still in `REGISTRY` is forwarded or listed in `allowedRetiredInCatalog`; and no `DEFAULT_DEGRADATION_MAP` source or target appears retired in that snapshot. This does not prove that a model is currently served by a live upstream. Offline — compares against `config/quality/model-lifecycle.json`, refreshed by hand with `npm run quality:refresh-model-lifecycle` (network; not wired into CI). `allowedRetiredInCatalog` is a burn-down ratchet: add an entry only with a tracking issue. | Yes | +| `check:fetch-targets` | Every `fetch("/api/...")` in client-side `src/` resolves to a real `route.ts` | Yes | +| `check:deps` | All `npm install`-able deps across every `package.json` in the repo are in `dependency-allowlist.json`; new unpinned or slopsquatted packages flagged | Yes | +| `audit:deps` | `npm audit` (root + electron) — no high/critical advisories (overlaps osv `check:vuln-ratchet`; see Rationalization Backlog) | Yes | +| `check:lockfile` | `package-lock.json` integrity — https registry, integrity hashes, no host overrides | Yes | +| `check:licenses` | SPDX license allowlist for production dependencies | Yes | +| `check:tracked-artifacts` | No build artifacts / committed `node_modules` symlinks (also runs in husky pre-commit; pre-push is intentionally light — #6716) | Yes | +| `check:ai-attribution` | No AI/bot `Co-Authored-By` trailer or AI-generation footer in PR commits, title or body — Hard Rule #16 (in the `quality.yml` fast-gates loop for PR→`release/**` — reads the event payload, no-op off PRs — and a PR-only step in `ci.yml` lint for PR→`main`; also the husky `commit-msg` hook; human co-authors allowed; #14436) | +| `check:vitest-exclusions` | Every Vitest exclusion names a tracking issue and appears in `config/quality/vitest-exclusions.json` (#13204) | Yes | +| `check:file-size` | Source files (`.ts`/`.tsx` in `src/`, `open-sse/`, `electron/`, `bin/`) stay within `cap` and test files (`*.test.ts(x)`) within `testCap`; files frozen in `file-size-baseline.json` (`frozen` / `testFrozen`) must not grow past their recorded size | Yes | +| `check:error-helper` | Error responses in executors/handlers use `buildErrorBody()` / `sanitizeErrorMessage()` (Hard Rule #12) | Yes | +| `check:migration-numbering` | Migration SQL files are sequentially numbered, no gaps or duplicates | Yes | +| `check:public-creds` | No literal OAuth `client_id`/`client_secret` or Firebase Web keys outside `publicCreds.ts` (Hard Rule #11) | Yes | +| `check:db-rules` | No raw SQL outside `src/lib/db/` modules; no barrel-imports from `localDb.ts` (Hard Rules #2/#5) | Yes | +| `check:known-symbols` | Provider executors, routing strategies, and translators registered in their dispatch tables match the files on disk — no orphaned or undeclared symbols | Yes | +| `check:route-guard-membership` | Every route that spawns a child process is classified by `isLocalOnlyPath()` (Hard Rules #15/#17) | Yes | +| `check:test-discovery` | Every `*.test.ts` / `*.spec.ts` file in the repo is collected by at least one test runner (ratchet: orphan list in `test-discovery-baseline.json` can only shrink) | Yes | +| `check:test-tla-order` | No `node:test` file under `tests/unit` registers a test, hits a top-level `await`, then registers more (#18045; ratchet: `test-tla-order-allowlist.json` can only shrink) | Yes | +| `check:agent-skills-sync` | Generated agent-skills artifacts match their source catalog (no drift) | +| `check:provider-asset-provenance` | Provider logos/assets carry a recorded provenance entry | +| `lint:json` | JSON config files parse and satisfy the repo lint rules | +| `typecheck:core` | TypeScript compilation without errors (advisory warnings only) | Yes | +| `typecheck:noimplicit:core` | Strict `noImplicitAny` — forward-looking; many pre-existing call sites still need annotations | **Advisory** (`continue-on-error: true`) | +| `check:dashboard-typecheck` | `tsc` scoped to `src/app/(dashboard)/**` (#7033) — `typecheck:core`'s curated 27-file allowlist does not include any dashboard TSX, and `next build` never type-checks it either (`next.config.mjs` sets `ignoreBuildErrors: true`), so orphaned-identifier regressions there (#6625/#6909) were invisible to CI. Diffs against a frozen per-file/per-TS-code count baseline (`config/quality/dashboard-typecheck-baseline.json`, same stale-enforcement pattern as `check:known-symbols`) — only NEW errors beyond the baselined count fail the gate; ratchet down with `--update` when a pre-existing error is fixed. | Yes | +| `check:build-typecheck` | Whole-app production type check over `tsconfig.build.json` (`src`, `open-sse`, `packages`, `bin`, `scripts`, `electron/**/*.d.ts`), the surface `typecheck:core`'s 27-file allowlist and the dashboard gate do not cover. Diffs against `config/quality/build-typecheck-baseline.json`; a new or raised per-(file, code) count fails, a lower count asks you to ratchet. `--update` refuses while the run contains a new or raised pair unless `--force-regressions` is passed, and refuses a newly silenced file unless `--force-nocheck` is passed, so widening the baseline to get green and granting a permanent silence are two separate deliberate acts. A bare `--force` is rejected. Also scans the build scope for `@ts-nocheck` against `config/quality/ts-nocheck-baseline.json`, because a file using it reports no diagnostics at all and would otherwise be invisible to every gate. | Yes | + +#### Two typecheck programs answer differently + +`typecheck:core` and `check:build-typecheck` compile different programs under different +options, and the same file can be clean in one and full of errors in the other. + +`tsconfig.build.json` sets `"strict": false`, so `noImplicitAny` is off. The core +config compiles a curated 27-file allowlist with stricter options and follows each +entry's import graph, so files that never appear in the build program are still +checked there. + +The consequence matters when judging whether a file is typed: **zero diagnostics +under `check:build-typecheck` does not mean zero**. Stripping `@ts-nocheck` from 26 +files showed 23 of them clean in the build program; sixteen of those surfaced about +120 implicit-any errors in the strict one. Check both before calling a file clean. ### Job: `quality-gate` diff --git a/electron/types.d.ts b/electron/types.d.ts index c78fbaf2b1b7..c710da07f27c 100644 --- a/electron/types.d.ts +++ b/electron/types.d.ts @@ -25,12 +25,34 @@ export interface ServerStatus { remoteUrl?: string; } +/** Payload of the `update-status` channel; electron/main.js emits these fields. */ +export interface UpdateStatus { + status: string; + version?: string; + percent?: number; + [key: string]: unknown; +} + export interface ElectronAPI { // ── Invoke (async) ───────────────────────────────────── getAppInfo(): Promise; openExternal(url: string): Promise; getDataDir(): Promise; restartServer(): Promise<{ success: boolean }>; + getAppVersion(): Promise; + + // ── Auto-update (preload.js: check-for-updates / download-update / + // install-update, plus the update-status receive channel) ──────────── + checkForUpdates(): Promise; + downloadUpdate(): Promise; + installUpdate(): Promise; + + // ── Autostart ────────────────────────────────────────────────────────── + // main.js returns a plain boolean on every platform branch (true on success, + // false on a thrown error), and AppearanceTab branches on it. + getAutostartStatus(): Promise; + enableAutostart(): Promise; + disableAutostart(): Promise; // ── Send (fire-and-forget) ───────────────────────────── minimizeWindow(): void; @@ -40,6 +62,7 @@ export interface ElectronAPI { // ── Receive (returns disposer for cleanup) ───────────── onServerStatus(callback: (data: ServerStatus) => void): () => void; onPortChanged(callback: (port: number) => void): () => void; + onUpdateStatus(callback: (data: UpdateStatus) => void): () => void; // ── Static Properties ────────────────────────────────── isElectron: boolean; diff --git a/open-sse/config/imageRegistry.ts b/open-sse/config/imageRegistry.ts index 8c097ecd4701..33915d8c7709 100644 --- a/open-sse/config/imageRegistry.ts +++ b/open-sse/config/imageRegistry.ts @@ -49,7 +49,7 @@ function visibleImageProviders(): Record { return { ...IMAGE_PROVIDERS, ...XAI_SUBSCRIPTION_IMAGE_PROVIDERS }; } -function resolveImageModelAlias(modelStr) { +function resolveImageModelAlias(modelStr: string) { const alias = IMAGE_MODEL_ALIASES[modelStr]; return alias ? { provider: alias.provider, model: alias.model } : null; } @@ -60,12 +60,12 @@ function resolveImageModelAlias(modelStr) { // must NOT override an explicit prefix — #9982 removed the unconditional bare // fallback because `fal-ai/flux-2-max` was being hijacked to black-forest-labs // by the bare `flux-2-max` alias. -function resolveSameProviderBareAlias(providerId, model) { +function resolveSameProviderBareAlias(providerId: string, model: string) { const aliased = resolveImageModelAlias(model); return aliased && aliased.provider === providerId ? aliased : null; } -function findImageModelConfig(providerId, modelId) { +function findImageModelConfig(providerId: string, modelId: string): ImageModelEntry | null { const provider = visibleImageProviders()[providerId]; if (!provider) return null; return ( @@ -73,21 +73,24 @@ function findImageModelConfig(providerId, modelId) { ); } -function resolveImageProviderModelId(providerId, modelId) { +function resolveImageProviderModelId(providerId: string, modelId: string): string { return findImageModelConfig(providerId, modelId)?.id || modelId; } // Kept out of getImageModelEntry() (which sits at the complexity-ratchet cap) — an // alias can override imageRequired directly, else it falls back to its target // model's own flag. Consumers coerce the result with Boolean(), so no `?? false`. -function resolveAliasImageRequired(alias, modelConfig) { +function resolveAliasImageRequired( + alias: ImageModelAliasEntry, + modelConfig: ImageModelEntry | null +) { return alias.imageRequired ?? modelConfig?.imageRequired; } /** * Get image provider config by ID */ -export function getImageProvider(providerId) { +export function getImageProvider(providerId: string): ImageProviderConfig | null { const providers = visibleImageProviders(); if (providers[providerId]) return providers[providerId]; if (!providerId) return null; @@ -101,7 +104,7 @@ export function getImageProvider(providerId) { * Parse image model string (format: "provider/model") * Returns { provider, model } */ -export function parseImageModel(modelStr) { +export function parseImageModel(modelStr: string) { if (!modelStr || hasUnsafeModelIdSyntax(modelStr)) return { provider: null, model: null }; const directAlias = resolveImageModelAlias(modelStr); @@ -208,11 +211,11 @@ export function getImageModelAliases() { * `/v1/models`, which returns image/diffusion models with no modality field) * out of the chat listing when they are already known image-only models. */ -export function isRegisteredImageModel(providerId, modelId) { +export function isRegisteredImageModel(providerId: string, modelId: string): boolean { return Boolean(findImageModelConfig(providerId, modelId)); } -export function getImageModelEntry(modelStr) { +export function getImageModelEntry(modelStr: string) { if (!modelStr) return null; const alias = IMAGE_MODEL_ALIASES[modelStr]; @@ -261,7 +264,7 @@ export function isImageOnlyModel(modelStr: string): boolean { } /** Image input is mandatory only for edit-only models (`["image"]`, no `"text"`). Dual-modality models also accept pure t2i. */ -export function modalitiesRequireImageInput(inputModalities) { +export function modalitiesRequireImageInput(inputModalities: unknown): boolean { const list = Array.isArray(inputModalities) ? inputModalities : ["text"]; return list.includes("image") && !list.includes("text"); } diff --git a/open-sse/executors/bedrock.ts b/open-sse/executors/bedrock.ts index 31150bf59219..154c12f97dd2 100644 --- a/open-sse/executors/bedrock.ts +++ b/open-sse/executors/bedrock.ts @@ -1,8 +1,12 @@ -// @ts-nocheck import { BedrockRuntimeClient, ConverseCommand, ConverseStreamCommand, + type ConverseStreamCommandInput, + type InferenceConfiguration, + type Message, + type SystemContentBlock, + type ToolConfiguration, } from "@aws-sdk/client-bedrock-runtime"; import { randomUUID } from "node:crypto"; @@ -110,7 +114,14 @@ function createToolUseIdNormalizer(messages) { }; } -function textBlocksFromContent(content, options = {}) { +function textBlocksFromContent( + content: unknown, + options: { + normalizeId?: (id: string) => string; + skipToolUseIds?: ReadonlySet; + answeredToolUseIds?: ReadonlySet; + } = {} +) { if (typeof content === "string") return content.trim() ? [{ text: content }] : []; if (!Array.isArray(content)) return []; @@ -349,7 +360,9 @@ function messagesFromOpenAI(messages) { } const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : []; - const toolCallIds = new Set(toolCalls.map((call) => normalizeId(call?.id)).filter(Boolean)); + const toolCallIds: Set = new Set( + toolCalls.map((call) => normalizeId(call?.id)).filter((id): id is string => Boolean(id)) + ); const content = textBlocksFromContent(message.content, { skipToolUseIds: toolCallIds, answeredToolUseIds, @@ -391,7 +404,7 @@ function messagesFromOpenAI(messages) { return sanitizeBedrockToolPairs(converted); } -function toolConfigFromOpenAI(tools, toolChoice) { +function toolConfigFromOpenAI(tools, toolChoice): ToolConfiguration | undefined { if (!Array.isArray(tools) || tools.length === 0) return undefined; const bedrockTools = []; for (const tool of tools) { @@ -409,7 +422,10 @@ function toolConfigFromOpenAI(tools, toolChoice) { } if (bedrockTools.length === 0) return undefined; - const config = { tools: bedrockTools }; + const config: { + tools: typeof bedrockTools; + toolChoice?: ToolConfiguration["toolChoice"]; + } = { tools: bedrockTools }; if (toolChoice === "required") config.toolChoice = { any: {} }; else if (toolChoice === "auto") config.toolChoice = { auto: {} }; else if (toolChoice && typeof toolChoice === "object") { @@ -423,7 +439,7 @@ function toolConfigFromOpenAI(tools, toolChoice) { export function openAIToBedrockConverse(model, body) { const request = asRecord(body); const messages = Array.isArray(request.messages) ? request.messages : []; - const inferenceConfig = {}; + const inferenceConfig: Record = {}; const maxTokens = request.max_tokens ?? request.max_completion_tokens; if (typeof maxTokens === "number") inferenceConfig.maxTokens = Math.max(1, Math.floor(maxTokens)); @@ -433,7 +449,16 @@ export function openAIToBedrockConverse(model, body) { else if (typeof request.stop === "string" && request.stop) inferenceConfig.stopSequences = [request.stop]; - const payload = { + // A concrete shape, not a Record: the result is handed to ConverseCommand, whose + // input type requires modelId. + const payload: { + modelId: string; + messages: Message[]; + system?: SystemContentBlock[]; + inferenceConfig?: InferenceConfiguration; + toolConfig?: ToolConfiguration; + additionalModelRequestFields?: ConverseStreamCommandInput["additionalModelRequestFields"]; + } = { modelId: model, messages: messagesFromOpenAI(messages), }; @@ -508,7 +533,7 @@ function contentBlocksToOpenAIMessage(blocks) { } } - const message = { role: "assistant", content: text.join("") }; + const message: Record = { role: "assistant", content: text.join("") }; if (reasoning.length > 0) message.reasoning_content = reasoning.join(""); if (toolCalls.length > 0) { message.content = message.content || null; @@ -544,7 +569,7 @@ function done() { } function openAIChunk(model, delta, finishReason = null, usage = undefined) { - const chunk = { + const chunk: Record = { id: `chatcmpl-bedrock-${model}`, object: "chat.completion.chunk", created: Math.floor(Date.now() / 1000), @@ -740,8 +765,28 @@ function createOpenAIStreamFromBedrock(stream, model) { }); } +// The executor consumes only these fields; injected clients need not supply SDK +// metadata or a message role that the OpenAI response converter does not read. +interface BedrockClientOutput { + output?: { message?: { content?: unknown[] } }; + stopReason?: string; + usage?: unknown; + stream?: AsyncIterable; +} + +interface BedrockClientLike { + send: ( + command: ConverseCommand | ConverseStreamCommand, + options?: { abortSignal?: AbortSignal } + ) => Promise; +} + export class BedrockExecutor extends BaseExecutor { - constructor(clientFactory = null) { + // Tests inject a stub, so this names only the method the executor calls rather + // than the SDK client class. + private readonly clientFactory: ((credentials: unknown) => BedrockClientLike) | null; + + constructor(clientFactory: ((credentials: unknown) => BedrockClientLike) | null = null) { super("bedrock", PROVIDERS.bedrock || { format: "openai" }); this.clientFactory = clientFactory; } @@ -761,7 +806,7 @@ export class BedrockExecutor extends BaseExecutor { }; } - createClient(credentials) { + createClient(credentials): BedrockClientLike { if (this.clientFactory) return this.clientFactory(credentials); const region = resolveBedrockRegion(credentials?.providerSpecificData); const customUserAgent = getCustomUserAgent(credentials?.providerSpecificData); diff --git a/open-sse/services/antigravityFamilyCooldown.ts b/open-sse/services/antigravityFamilyCooldown.ts index 622d8301be18..7a86fc17dc64 100644 --- a/open-sse/services/antigravityFamilyCooldown.ts +++ b/open-sse/services/antigravityFamilyCooldown.ts @@ -108,6 +108,10 @@ export async function persistAntigravityPreflightFamilyLock(params: { } /** Read the saved deadline without installing any process-local model locks. */ +// Accept every family persistAntigravityFamilyCooldown() can write, not just the +// gemini/claude pair this reader's sibling loop happens to rehydrate. argon is a +// real bucket (gemini-4*), so narrowing the parameter here made the reader +// disagree with both the writer and its callers. export function getAntigravityFamilyCooldownUntil( providerSpecificData: unknown, family: AntigravityQuotaFamily, diff --git a/open-sse/services/tokenRefresh.ts b/open-sse/services/tokenRefresh.ts index d0628e0c2ba4..e35f5c3f652f 100755 --- a/open-sse/services/tokenRefresh.ts +++ b/open-sse/services/tokenRefresh.ts @@ -395,10 +395,16 @@ async function _getAccessTokenInternal(provider, credentials, log, proxyConfig: try { const discovered = await ensureAntigravityProjectAssigned(result.accessToken, fetch); if (isUsableAntigravityProjectId(discovered)) { - result.projectId = discovered; - result.providerSpecificData = { + // The refresh result type declares only the token fields; the antigravity + // recovery adds these two alongside them. + const recovery = result as { + projectId?: string; + providerSpecificData?: Record; + }; + recovery.projectId = discovered; + recovery.providerSpecificData = { ...(credentials.providerSpecificData || {}), - ...(result.providerSpecificData || {}), + ...(recovery.providerSpecificData || {}), projectId: discovered, }; if (credentials.connectionId) { @@ -600,9 +606,11 @@ export async function getAccessToken( log, proxyConfig ); + // The refresh helpers return one of nine shapes; name the token once. + const token = result as { accessToken?: string } | null; // Invoke onPersist INSIDE the mutex so [network call + DB write] are one atomic step. // This prevents a concurrent waiter from reading stale credentials before the DB is updated. - if (result?.accessToken && effectiveOnPersist) { + if (token?.accessToken && effectiveOnPersist) { // #4038: skip the persist if a concurrent writer already rotated this row past the // refresh_token we presented (compare-and-swap) — overwriting would revert it. if (await casGuardShouldSkipPersist(log)) { @@ -657,7 +665,9 @@ export async function getAccessToken( // resolve the promise to all waiters with the refreshed credentials. const refreshPromise = _getAccessTokenWithStalenessCheck(provider, credentials, log, proxyConfig) .then(async (result) => { - if (result?.accessToken && effectiveOnPersist) { + // The refresh helpers return one of nine shapes; name the token once. + const token = result as { accessToken?: string } | null; + if (token?.accessToken && effectiveOnPersist) { // #4038: same compare-and-swap guard as Layer 1 — skip the persist if a concurrent // writer already rotated this row past the refresh_token we presented. if (await casGuardShouldSkipPersist(log)) { @@ -673,7 +683,7 @@ export async function getAccessToken( ); throw persistErr; } - } else if (result?.accessToken && !effectiveOnPersist) { + } else if (token?.accessToken && !effectiveOnPersist) { log?.warn?.( "TOKEN_REFRESH", `Layer 2 refresh succeeded for ${provider} without onPersist — DB row will not be updated with rotated token. Callers should pass connectionId for Layer 1 atomicity.` diff --git a/open-sse/services/tokenRefresh/casGuard.ts b/open-sse/services/tokenRefresh/casGuard.ts index 3b4a13eb45d7..a3a3a2b8f80a 100644 --- a/open-sse/services/tokenRefresh/casGuard.ts +++ b/open-sse/services/tokenRefresh/casGuard.ts @@ -1,4 +1,3 @@ -// @ts-nocheck // // Compare-and-swap (CAS) guard on the refresh persist — extracted from // open-sse/services/tokenRefresh.ts. See ../shared.ts for provenance notes. diff --git a/open-sse/services/tokenRefresh/circuitBreaker.ts b/open-sse/services/tokenRefresh/circuitBreaker.ts index bdccf0e44e9b..ca1434fddbcb 100644 --- a/open-sse/services/tokenRefresh/circuitBreaker.ts +++ b/open-sse/services/tokenRefresh/circuitBreaker.ts @@ -246,7 +246,7 @@ export async function refreshWithRetry( if (result === NO_REFRESH_NEEDED) return null; log?.warn?.( "TOKEN_REFRESH", - `Unrecoverable refresh error for ${key}: ${result.error} — skipping retries` + `Unrecoverable refresh error for ${key}: ${(result as { error?: string }).error} — skipping retries` ); return result; } diff --git a/open-sse/services/tokenRefresh/providers/codebuddyCn.ts b/open-sse/services/tokenRefresh/providers/codebuddyCn.ts index 97dd9ee3778f..eaf84a02370e 100644 --- a/open-sse/services/tokenRefresh/providers/codebuddyCn.ts +++ b/open-sse/services/tokenRefresh/providers/codebuddyCn.ts @@ -1,4 +1,3 @@ -// @ts-nocheck import type { RefreshLogger } from "../shared.ts"; import { refreshCodebuddyToken } from "./codebuddyShared.ts"; diff --git a/open-sse/services/tokenRefresh/providers/codebuddyIntl.ts b/open-sse/services/tokenRefresh/providers/codebuddyIntl.ts index 91e869c59e3b..f8dd24117fb6 100644 --- a/open-sse/services/tokenRefresh/providers/codebuddyIntl.ts +++ b/open-sse/services/tokenRefresh/providers/codebuddyIntl.ts @@ -1,4 +1,3 @@ -// @ts-nocheck import type { RefreshLogger } from "../shared.ts"; import { refreshCodebuddyToken } from "./codebuddyShared.ts"; diff --git a/open-sse/services/tokenRefresh/providers/codebuddyShared.ts b/open-sse/services/tokenRefresh/providers/codebuddyShared.ts index ab39dd237dd7..ca7a06cd9241 100644 --- a/open-sse/services/tokenRefresh/providers/codebuddyShared.ts +++ b/open-sse/services/tokenRefresh/providers/codebuddyShared.ts @@ -1,4 +1,3 @@ -// @ts-nocheck // Extracted from open-sse/services/tokenRefresh.ts — see ../shared.ts for // provenance notes (ported idea from KooshaPari's PR #7338, redone on tip). import { runWithProxyContext } from "../../../utils/proxyFetch.ts"; diff --git a/open-sse/services/tokenRefresh/providers/gitlabDuo.ts b/open-sse/services/tokenRefresh/providers/gitlabDuo.ts index 573a7f16f667..649224747ed6 100644 --- a/open-sse/services/tokenRefresh/providers/gitlabDuo.ts +++ b/open-sse/services/tokenRefresh/providers/gitlabDuo.ts @@ -1,4 +1,3 @@ -// @ts-nocheck // Extracted from open-sse/services/tokenRefresh.ts — see ../shared.ts for // provenance notes (ported idea from KooshaPari's PR #7338, redone on tip). import { PROVIDERS } from "../../../config/constants.ts"; diff --git a/open-sse/services/tokenRefresh/providers/kimiCoding.ts b/open-sse/services/tokenRefresh/providers/kimiCoding.ts index 0ae3698f46f7..f4e8d3e90fb3 100644 --- a/open-sse/services/tokenRefresh/providers/kimiCoding.ts +++ b/open-sse/services/tokenRefresh/providers/kimiCoding.ts @@ -1,4 +1,3 @@ -// @ts-nocheck // Extracted from open-sse/services/tokenRefresh.ts — see ../shared.ts for // provenance notes (ported idea from KooshaPari's PR #7338, redone on tip). import { pbkdf2Sync } from "node:crypto"; diff --git a/package.json b/package.json index 4a55047c6c79..117535310300 100644 --- a/package.json +++ b/package.json @@ -268,6 +268,7 @@ "typecheck:core": "tsc --pretty false -p tsconfig.typecheck-core.json", "typecheck:noimplicit:core": "tsc --pretty false -p tsconfig.typecheck-noimplicit-core.json", "check:dashboard-typecheck": "node scripts/check/check-dashboard-typecheck.mjs", + "check:build-typecheck": "node scripts/check/check-build-typecheck.mjs", "check:open-sse-typecheck": "node scripts/check/check-open-sse-typecheck.mjs", "check:ts7-diagnostics-ratchet": "node scripts/check/check-ts7-diagnostics-ratchet.mjs", "backfill-aggregation": "node --import tsx src/scripts/backfillAggregation.ts", diff --git a/scripts/ad-hoc/dump-auto-combos.ts b/scripts/ad-hoc/dump-auto-combos.ts index 0e3c35201156..f388aeffb9a8 100644 --- a/scripts/ad-hoc/dump-auto-combos.ts +++ b/scripts/ad-hoc/dump-auto-combos.ts @@ -50,3 +50,5 @@ for (const name of allTemplates) { } console.log(JSON.stringify(results, null, 2)); + +export {}; diff --git a/scripts/check/check-api-typecheck.mjs b/scripts/check/check-api-typecheck.mjs index b3db6bd87c5f..e32438459d4f 100644 --- a/scripts/check/check-api-typecheck.mjs +++ b/scripts/check/check-api-typecheck.mjs @@ -1,25 +1,41 @@ #!/usr/bin/env node // scripts/check/check-api-typecheck.mjs -// API-route-scoped typecheck gate (#11601). +// API-route-scoped typecheck ratchet (#11601). // -// `typecheck:core` uses a curated file allowlist, the dashboard typecheck gate -// only covers src/app/(dashboard)/**, and Next builds ignore TypeScript build -// errors. That leaves src/app/api/** without a blocking typecheck gate. +// `typecheck:core` uses a curated file allowlist, the dashboard gate covers only +// src/app/(dashboard)/**, and Next builds ignore TypeScript build errors, which +// left src/app/api/** without a blocking typecheck gate. // -// This gate runs `tsc` scoped to src/app/api/**/*.{ts,tsx} via -// tsconfig.typecheck-api.json and compares live diagnostics against a frozen -// per-file/per-TS-code count baseline. New diagnostics or count increases fail; -// reductions are reported as improvements and can be ratcheted with --update. +// This compiles tsconfig.typecheck-api.json and compares live diagnostics against +// a frozen per-(file, TS code) count baseline. The ratchet rules — refuse to bank a +// regression, freeze @ts-nocheck by name, count line-level suppressions, freeze the +// program's own shape — live in ./ratchet.mjs and are shared with the build gate so +// the two cannot drift apart. +// +// Metadata lives in the same JSON under `_`-prefixed keys, which the baseline +// differ skips. // // Run: -// node scripts/check/check-api-typecheck.mjs -// node scripts/check/check-api-typecheck.mjs --update +// node scripts/check/check-api-typecheck.mjs # verify (CI) +// node scripts/check/check-api-typecheck.mjs --update # ratchet down +// node scripts/check/check-api-typecheck.mjs --update --force-regressions # accept a new counted error +// --force-nocheck # accept a permanent silence import fs from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { runScopedTypecheck } from "./run-scoped-typecheck.mjs"; import { diffAgainstBaseline, parseTscOutput } from "./typecheckBaseline.mjs"; +import { + countLineSuppressions, + fingerprintDrift, + programFiles, + programFingerprint, + scanNoCheckFiles, + suppressionDrift, + suppressedIntersection, + updateRefusal, +} from "./ratchet.mjs"; export { diffAgainstBaseline, parseTscOutput } from "./typecheckBaseline.mjs"; @@ -27,14 +43,31 @@ const ROOT = process.cwd(); const TSCONFIG = path.join(ROOT, "tsconfig.typecheck-api.json"); const BASELINE_PATH = path.join(ROOT, "config/quality/api-typecheck-baseline.json"); const UPDATE = process.argv.includes("--update"); +// Accepting a new counted error and granting a permanent silence are different +// decisions and must not share one flag. +const FORCE_REGRESSIONS = process.argv.includes("--force-regressions"); +const FORCE_NOCHECK = process.argv.includes("--force-nocheck"); + +// `--force` used to mean "accept everything". Silently ignoring it now would be +// worse than either behaviour, so say so. +if (process.argv.includes("--force")) { + process.stderr.write( + `[api-typecheck] --force was split: use --force-regressions to accept a counted error, ` + + `or --force-nocheck to accept a permanent silence.\n` + ); + process.exit(2); +} function loadBaseline() { if (!fs.existsSync(BASELINE_PATH)) return {}; return JSON.parse(fs.readFileSync(BASELINE_PATH, "utf8")); } -function writeBaseline(counts) { - fs.writeFileSync(BASELINE_PATH, JSON.stringify(counts, null, 2) + "\n"); +function totalErrors(counts) { + return Object.values(counts).reduce( + (sum, codes) => sum + Object.values(codes).reduce((s, c) => s + c, 0), + 0 + ); } function main() { @@ -44,20 +77,123 @@ function main() { } console.log("[api-typecheck] Running tsc scoped to src/app/api/**…"); - const stdout = runScopedTypecheck(ROOT, TSCONFIG); + let stdout; + try { + stdout = runScopedTypecheck(ROOT, TSCONFIG, { incremental: false }); + } catch (err) { + process.stderr.write( + `[api-typecheck] FAIL — the compiler did not produce a complete result, so this run ` + + `proves nothing about the baseline:\n${err instanceof Error ? err.message : String(err)}\n` + ); + process.exit(2); + } + const live = parseTscOutput(stdout); - const baseline = loadBaseline(); - const { regressions, improvements } = diffAgainstBaseline(live, baseline); + const liveErrorCount = totalErrors(live); + console.log(`apiTypecheckErrors=${liveErrorCount}`); - const liveErrorCount = Object.values(live).reduce( - (sum, codes) => sum + Object.values(codes).reduce((s, c) => s + c, 0), - 0 + const stored = loadBaseline(); + const { _program, _nocheck, _suppressions, ...baselineCounts } = stored; + const { regressions, improvements } = diffAgainstBaseline(live, baselineCounts); + + const files = programFiles(ROOT, TSCONFIG); + if (files.length === 0) { + process.stderr.write( + `[api-typecheck] FAIL — the program resolved to zero files, so this run proves nothing\n` + ); + process.exit(2); + } + + const noCheckNow = scanNoCheckFiles(files, ROOT); + const noCheckAllowed = new Set(Array.isArray(_nocheck) ? _nocheck : []); + const addedNoCheck = noCheckNow.filter((f) => !noCheckAllowed.has(f)); + const removedNoCheck = [...noCheckAllowed].filter((f) => !noCheckNow.includes(f)); + + const lineSuppressions = countLineSuppressions(files, ROOT); + const suppressionRegressions = suppressionDrift( + _suppressions && typeof _suppressions === "object" ? _suppressions : {}, + lineSuppressions ); - console.log(`apiTypecheckErrors=${liveErrorCount}`); + + const fingerprint = programFingerprint(ROOT, TSCONFIG, files.length); + const programDrift = fingerprintDrift(_program || null, fingerprint); + + const suppressionViolations = suppressedIntersection(Object.keys(baselineCounts), noCheckNow); if (UPDATE) { - writeBaseline(live); - console.log(`[api-typecheck] baseline rewritten (${liveErrorCount} errors frozen).`); + const refusal = updateRefusal(regressions, { force: FORCE_REGRESSIONS }); + if (refusal) { + process.stderr.write( + `[api-typecheck] REFUSING to update — ${refusal}:\n` + + regressions + .map( + (r) => ` + ${r.file} ${r.code} (baseline ${r.baselineCount} -> live ${r.liveCount})` + ) + .join("\n") + + `\n\nFix those, or pass --force-regressions if accepting the debt is a deliberate decision.\n` + ); + process.exit(1); + } + if (addedNoCheck.length > 0 && !FORCE_NOCHECK) { + process.stderr.write( + `[api-typecheck] REFUSING to update — ${addedNoCheck.length} new @ts-nocheck file(s):\n` + + addedNoCheck.map((f) => ` + ${f}`).join("\n") + + `\n\n@ts-nocheck makes a file invisible to every diagnostic. Fix the errors instead.\n` + ); + process.exit(1); + } + if (suppressionRegressions.length > 0 && !FORCE_NOCHECK) { + process.stderr.write( + `[api-typecheck] REFUSING to update — ${suppressionRegressions.length} file(s) gained ` + + `line-level @ts-ignore/@ts-expect-error:\n` + + suppressionRegressions.map((r) => ` + ${r.file} (${r.was} -> ${r.live})`).join("\n") + + `\n\nA suppression deletes the diagnostic; banking it here would record the ` + + `deletion as progress.\n` + ); + process.exit(1); + } + if (programDrift.length > 0 && !FORCE_NOCHECK) { + process.stderr.write( + `[api-typecheck] REFUSING to update — the program changed shape:\n` + + programDrift.map((d) => ` ~ ${d}`).join("\n") + + `\n\nShrinking the program removes diagnostics without fixing them.\n` + ); + process.exit(1); + } + if (suppressionViolations.length > 0 && !FORCE_NOCHECK) { + process.stderr.write( + `[api-typecheck] REFUSING to update — ${suppressionViolations.length} file(s) carry ` + + `baselined errors while silencing the compiler:\n` + + suppressionViolations.map((f) => ` + ${f}`).join("\n") + + `\n\nRewriting the baseline here would delete the frozen debt and hide the file.\n` + ); + process.exit(1); + } + + const next = + JSON.stringify( + { + ...live, + _program: fingerprint, + _nocheck: [...noCheckNow].sort(), + _suppressions: lineSuppressions, + }, + null, + 2 + ) + "\n"; + try { + fs.writeFileSync(BASELINE_PATH, next); + } catch (err) { + process.stderr.write( + `[api-typecheck] FAIL — could not persist the baseline: ` + + `${err instanceof Error ? err.message : String(err)}\n` + ); + process.exit(2); + } + console.log( + `[api-typecheck] baseline rewritten (${liveErrorCount} errors frozen; ` + + `${noCheckNow.length} @ts-nocheck files; ${Object.values(lineSuppressions).reduce((a, b) => a + b, 0)} suppressions).` + ); process.exit(0); } @@ -73,6 +209,53 @@ function main() { ); } + if (addedNoCheck.length > 0) { + process.stderr.write( + `[api-typecheck] FAIL — ${addedNoCheck.length} file(s) added @ts-nocheck, which silences ` + + `every diagnostic in them:\n` + + addedNoCheck.map((f) => ` ✗ ${f}`).join("\n") + + `\n\nThis is not a fix: the file stops being checked at all.\n` + ); + process.exit(1); + } + + if (suppressionRegressions.length > 0) { + process.stderr.write( + `[api-typecheck] FAIL — ${suppressionRegressions.length} file(s) gained line-level ` + + `@ts-ignore/@ts-expect-error:\n` + + suppressionRegressions.map((r) => ` ✗ ${r.file} (${r.was} -> ${r.live})`).join("\n") + + `\n\nA suppression makes a diagnostic disappear without fixing it.\n` + ); + process.exit(1); + } + + if (programDrift.length > 0) { + process.stderr.write( + `[api-typecheck] FAIL — the program changed shape since the baseline was recorded:\n` + + programDrift.map((d) => ` ✗ ${d}`).join("\n") + + `\n\nDiagnostics disappear when the program shrinks, so a lower count is not progress.\n` + ); + process.exit(1); + } + + if (suppressionViolations.length > 0) { + process.stderr.write( + `[api-typecheck] FAIL — ${suppressionViolations.length} file(s) have baselined errors and ` + + `also silence the compiler:\n` + + suppressionViolations.map((f) => ` ✗ ${f}`).join("\n") + + `\n\nThe frozen count describes a file that is no longer checked.\n` + ); + process.exit(1); + } + + if (removedNoCheck.length > 0) { + console.log( + `[api-typecheck] ${removedNoCheck.length} file(s) no longer use @ts-nocheck — ` + + `run with --update to ratchet the list down:\n` + + removedNoCheck.map((f) => ` - ${f}`).join("\n") + ); + } + if (regressions.length > 0) { process.stderr.write( `[api-typecheck] FAIL — ${regressions.length} new/regressed TypeScript error(s) ` + @@ -86,7 +269,8 @@ function main() { } console.log( - `[api-typecheck] OK — ${liveErrorCount} pre-existing error(s), all within frozen baseline.` + `[api-typecheck] OK — ${liveErrorCount} pre-existing error(s), all within frozen baseline; ` + + `${noCheckNow.length} @ts-nocheck file(s) unchanged.` ); process.exit(0); } diff --git a/scripts/check/check-build-typecheck.mjs b/scripts/check/check-build-typecheck.mjs new file mode 100644 index 000000000000..b9ec221fc5bd --- /dev/null +++ b/scripts/check/check-build-typecheck.mjs @@ -0,0 +1,346 @@ +#!/usr/bin/env node +// scripts/check/check-build-typecheck.mjs +// Whole-app production typecheck ratchet. +// +// Why this exists: `next build` sets `typescript.ignoreBuildErrors: true`, the +// blocking `typecheck:core` gate covers a curated 27-file allowlist, and the +// dashboard gate covers only `src/app/(dashboard)`. The root tsconfig would cover +// the rest, but it also pulls tests/ in through `**/*.ts` and cannot finish on a +// normal machine, so it is not runnable as a gate. This program narrows to +// shipped code and is held against a frozen count baseline. +// +// A live count above the baseline is a regression and fails; a lower count is an +// improvement and only asks you to ratchet the baseline down. +// +// Two escapes are closed: +// +// 1. `--update` refuses to rewrite the baseline while the live run contains a +// new or raised pair, so widening it to get green has to be a deliberate +// `--force-regressions` (or `--force-nocheck` for the nocheck/program guards). +// 2. `@ts-nocheck` is scanned across the build scope and frozen. A file using +// it reports no diagnostics, so nothing else here can see that it opted out. +// +// Run: +// node scripts/check/check-build-typecheck.mjs # verify (CI) +// node scripts/check/check-build-typecheck.mjs --update # ratchet down after fixes +// node scripts/check/check-build-typecheck.mjs --update --force-regressions # accept a new counted error +// --force-nocheck # accept a permanent silence + +import fs from "node:fs"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { pathToFileURL } from "node:url"; +import { runScopedTypecheck } from "./run-scoped-typecheck.mjs"; +import { parseTscOutput, diffAgainstBaseline } from "./typecheckBaseline.mjs"; +import { + countLineSuppressions, + fingerprintDrift, + programFiles, + programFingerprint, + scanNoCheckFiles, + suppressionDrift, + suppressedIntersection, + updateRefusal, +} from "./ratchet.mjs"; + +const ROOT = process.cwd(); +const TSCONFIG = path.join(ROOT, "tsconfig.build.json"); +const BASELINE_PATH = path.join(ROOT, "config/quality/build-typecheck-baseline.json"); +const NOCHECK_BASELINE_PATH = path.join(ROOT, "config/quality/ts-nocheck-baseline.json"); +const SUPPRESSION_BASELINE_PATH = path.join(ROOT, "config/quality/ts-suppressions-baseline.json"); +const UPDATE = process.argv.includes("--update"); +// Accepting a new counted error and granting a permanent silence are different +// decisions and must not share one flag. +const FORCE_REGRESSIONS = process.argv.includes("--force-regressions"); +const FORCE_NOCHECK = process.argv.includes("--force-nocheck"); + +// `--force` used to mean "accept everything". Silently ignoring it now would be +// worse than either behaviour, so say so. +if (process.argv.includes("--force")) { + process.stderr.write( + `[build-typecheck] --force was split: use --force-regressions to accept a counted error, ` + + `or --force-nocheck to accept a permanent silence.\n` + ); + process.exit(2); +} +// The whole-app program needs several GB of heap; the default is not enough and +// tsc dies with an opaque "Ineffective mark-compacts" OOM instead of diagnostics. +const MAX_HEAP_MB = 8192; + +function totalErrors(counts) { + return Object.values(counts).reduce( + (sum, codes) => sum + Object.values(codes).reduce((s, c) => s + c, 0), + 0 + ); +} + +function readJson(file, fallback) { + if (!fs.existsSync(file)) return fallback; + return JSON.parse(fs.readFileSync(file, "utf8")); +} + +function loadNoCheckBaseline() { + const parsed = readJson(NOCHECK_BASELINE_PATH, {}); + return Array.isArray(parsed.files) ? parsed.files : []; +} + +/** + * Regenerate the docs collection before compiling. + * + * `src/lib/source.ts` imports `.source/server`, which fumadocs-mdx generates and + * git ignores. Without it the import is TS2307, so a checkout that has never run + * `next build` reports six more errors than one that has — the same code, two + * different verdicts. Generating it here costs about a second and makes the + * gate's answer depend on the tree rather than on someone's build history. + */ +function ensureGeneratedSources() { + const bin = path.join(ROOT, "node_modules/.bin/fumadocs-mdx"); + if (!fs.existsSync(bin)) return; + const result = spawnSync(bin, [], { cwd: ROOT, stdio: "ignore" }); + if (result.error || result.status !== 0) { + throw new Error( + "fumadocs-mdx could not generate .source, so the program would be missing " + + "the docs collection and report errors that belong to the build, not the code" + ); + } +} + +function main() { + if (!fs.existsSync(TSCONFIG)) { + process.stderr.write(`[build-typecheck] FAIL — tsconfig not found at ${TSCONFIG}\n`); + process.exit(2); + } + + try { + ensureGeneratedSources(); + } catch (err) { + process.stderr.write( + `[build-typecheck] FAIL — ${err instanceof Error ? err.message : String(err)}\n` + ); + process.exit(2); + } + + console.log("[build-typecheck] Running tsc over the production program…"); + let stdout; + try { + stdout = runScopedTypecheck(ROOT, TSCONFIG, { + maxOldSpaceSizeMb: MAX_HEAP_MB, + incremental: false, + }); + } catch (err) { + process.stderr.write( + `[build-typecheck] FAIL — the compiler did not produce a complete result, so this run ` + + `proves nothing about the baseline:\n${err instanceof Error ? err.message : String(err)}\n` + ); + process.exit(2); + } + + const live = parseTscOutput(stdout); + const liveErrorCount = totalErrors(live); + console.log(`buildTypecheckErrors=${liveErrorCount}`); + + const baseline = readJson(BASELINE_PATH, {}); + const { regressions, improvements } = diffAgainstBaseline(live, baseline); + + const files = programFiles(ROOT, TSCONFIG, MAX_HEAP_MB); + if (files.length === 0) { + // An empty program reports zero diagnostics, which would read as "all clear". + process.stderr.write( + `[build-typecheck] FAIL — the program resolved to zero files, so this run proves nothing\n` + ); + process.exit(2); + } + const noCheckNow = scanNoCheckFiles(files, ROOT); + const fingerprint = programFingerprint(ROOT, TSCONFIG, files.length); + const recordedProgram = readJson(NOCHECK_BASELINE_PATH, {})._program || null; + const programDrift = fingerprintDrift(recordedProgram, fingerprint); + const noCheckAllowed = new Set(loadNoCheckBaseline()); + const lineSuppressions = countLineSuppressions(files, ROOT); + const suppressionRegressions = suppressionDrift( + readJson(SUPPRESSION_BASELINE_PATH, {}), + lineSuppressions + ); + const addedNoCheck = noCheckNow.filter((f) => !noCheckAllowed.has(f)); + const removedNoCheck = [...noCheckAllowed].filter((f) => !noCheckNow.includes(f)); + // Same check on both paths, so verify and --update cannot disagree about it. + // The first argument must be the BASELINE's files, not the live ones: a file + // that gains @ts-nocheck drops out of the live diagnostics entirely, so + // intersecting with `live` is empty by construction and would never fire. + const suppressionViolations = suppressedIntersection(Object.keys(baseline), noCheckNow); + + if (UPDATE) { + const refusal = updateRefusal(regressions, { force: FORCE_REGRESSIONS }); + if (refusal) { + process.stderr.write( + `[build-typecheck] REFUSING to update — ${refusal}:\n` + + regressions + .map( + (r) => ` + ${r.file} ${r.code} (baseline ${r.baselineCount} -> live ${r.liveCount})` + ) + .join("\n") + + `\n\nFix those, or pass --force-regressions if accepting the debt is a deliberate decision.\n` + ); + process.exit(1); + } + if (addedNoCheck.length > 0 && !FORCE_NOCHECK) { + process.stderr.write( + `[build-typecheck] REFUSING to update — ${addedNoCheck.length} new @ts-nocheck file(s):\n` + + addedNoCheck.map((f) => ` + ${f}`).join("\n") + + `\n\n@ts-nocheck makes a file invisible to every diagnostic, so it is not a way ` + + `to record debt. Fix the file, or pass --force-nocheck with a written reason.\n` + ); + process.exit(1); + } + if (suppressionViolations.length > 0 && !FORCE_NOCHECK) { + process.stderr.write( + `[build-typecheck] REFUSING to update — ${suppressionViolations.length} file(s) carry ` + + `baselined errors while silencing the compiler:\n` + + suppressionViolations.map((f) => ` + ${f}`).join("\n") + + `\n\nRewriting the baseline here would delete the frozen debt and hide the file at ` + + `the same time.\n` + ); + process.exit(1); + } + + // A program that shrank is not an improvement to bank. Without this, adding a + // directory to `exclude` is the cheapest way to make this gate green. + if (programDrift.length > 0 && !FORCE_NOCHECK) { + process.stderr.write( + `[build-typecheck] REFUSING to update — the program changed shape:\n` + + programDrift.map((d) => ` ~ ${d}`).join("\n") + + `\n\nShrinking the program removes diagnostics without fixing them, so the ` + + `frozen counts stop describing this codebase. Revert the change, or pass ` + + `--force-nocheck if the program really is meant to change.\n` + ); + process.exit(1); + } + + if (suppressionRegressions.length > 0 && !FORCE_NOCHECK) { + process.stderr.write( + `[build-typecheck] REFUSING to update — ${suppressionRegressions.length} file(s) gained ` + + `line-level @ts-ignore/@ts-expect-error:\n` + + suppressionRegressions.map((r) => ` + ${r.file} (${r.was} -> ${r.live})`).join("\n") + + `\n\nA suppression deletes the diagnostic, so rewriting the baseline here would ` + + `record the deletion as progress.\n` + ); + process.exit(1); + } + + // Serialise both documents before writing either, so a failure cannot leave + // one file updated and the other stale. + const nextDiagnostics = JSON.stringify(live, null, 2) + "\n"; + const priorNoCheck = readJson(NOCHECK_BASELINE_PATH, {}); + const nextNoCheck = + JSON.stringify( + { ...priorNoCheck, _program: fingerprint, files: [...noCheckNow].sort() }, + null, + 2 + ) + "\n"; + const nextSuppressions = JSON.stringify(lineSuppressions, null, 2) + "\n"; + try { + fs.writeFileSync(BASELINE_PATH, nextDiagnostics); + fs.writeFileSync(NOCHECK_BASELINE_PATH, nextNoCheck); + fs.writeFileSync(SUPPRESSION_BASELINE_PATH, nextSuppressions); + } catch (err) { + process.stderr.write( + `[build-typecheck] FAIL — could not persist the baselines: ` + + `${err instanceof Error ? err.message : String(err)}\n` + ); + process.exit(2); + } + console.log( + `[build-typecheck] baseline rewritten (${liveErrorCount} errors frozen over ` + + `${Object.keys(live).length} files; ${noCheckNow.length} @ts-nocheck files).` + ); + process.exit(0); + } + + if (improvements.length > 0) { + console.log( + `[build-typecheck] ${improvements.length} baselined error(s) are gone ` + + `— run 'node scripts/check/check-build-typecheck.mjs --update' to ratchet the baseline down:\n` + + improvements + .map( + (i) => ` - ${i.file} ${i.code} (baseline ${i.baselineCount} -> live ${i.liveCount})` + ) + .join("\n") + ); + } + + if (suppressionRegressions.length > 0) { + process.stderr.write( + `[build-typecheck] FAIL — ${suppressionRegressions.length} file(s) gained line-level ` + + `@ts-ignore/@ts-expect-error since the baseline:\n` + + suppressionRegressions.map((r) => ` ✗ ${r.file} (${r.was} -> ${r.live})`).join("\n") + + `\n\nA suppression makes a diagnostic disappear without fixing it. If the suppression ` + + `is correct (a genuine upstream type bug), record it with ` + + `'node scripts/check/check-build-typecheck.mjs --update'.\n` + ); + process.exit(1); + } + + if (programDrift.length > 0) { + process.stderr.write( + `[build-typecheck] FAIL — the program changed shape since the baseline was recorded:\n` + + programDrift.map((d) => ` ✗ ${d}`).join("\n") + + `\n\nDiagnostics disappear when the program shrinks, so a lower count here is not ` + + `progress. Revert the config change, or if the program is meant to change, record ` + + `it with 'node scripts/check/check-build-typecheck.mjs --update'.\n` + ); + process.exit(1); + } + + if (addedNoCheck.length > 0) { + process.stderr.write( + `[build-typecheck] FAIL — ${addedNoCheck.length} file(s) added @ts-nocheck, which ` + + `silences every diagnostic in them:\n` + + addedNoCheck.map((f) => ` ✗ ${f}`).join("\n") + + `\n\nThis is not a fix: the file stops being checked at all. Fix the errors, or if ` + + `the file genuinely cannot be typed yet, add it to ` + + `config/quality/ts-nocheck-baseline.json deliberately.\n` + ); + process.exit(1); + } + + if (suppressionViolations.length > 0) { + process.stderr.write( + `[build-typecheck] FAIL — ${suppressionViolations.length} file(s) have baselined errors ` + + `and also silence the compiler:\n` + + suppressionViolations.map((f) => ` ✗ ${f}`).join("\n") + + `\n\nThe frozen count describes a file that is no longer checked. Remove the directive ` + + `or fix the errors.\n` + ); + process.exit(1); + } + + if (regressions.length > 0) { + process.stderr.write( + `[build-typecheck] FAIL — ${regressions.length} new/regressed TypeScript error(s) outside ` + + `the frozen baseline:\n` + + regressions + .map((r) => ` ✗ ${r.file} ${r.code} (baseline ${r.baselineCount}, live ${r.liveCount})`) + .join("\n") + + `\n\nFix the new errors. Do not widen the baseline to get green — that is the exact ` + + `failure mode this gate exists to prevent.\n` + ); + process.exit(1); + } + + if (removedNoCheck.length > 0) { + console.log( + `[build-typecheck] ${removedNoCheck.length} file(s) no longer use @ts-nocheck — ` + + `run with --update to ratchet the list down:\n` + + removedNoCheck.map((f) => ` - ${f}`).join("\n") + ); + } + + console.log( + `[build-typecheck] OK — ${liveErrorCount} pre-existing error(s) within the frozen baseline, ` + + `${noCheckNow.length} @ts-nocheck file(s) unchanged.` + ); + process.exit(0); +} + +if (import.meta.url === pathToFileURL(process.argv[1] || "").href) { + main(); +} diff --git a/scripts/check/check-dashboard-typecheck.mjs b/scripts/check/check-dashboard-typecheck.mjs index 983e2378aaa3..fff16791f9c5 100644 --- a/scripts/check/check-dashboard-typecheck.mjs +++ b/scripts/check/check-dashboard-typecheck.mjs @@ -110,7 +110,7 @@ function main() { } console.log("[dashboard-typecheck] Running tsc scoped to src/app/(dashboard)/**…"); - const stdout = runScopedTypecheck(ROOT, TSCONFIG); + const stdout = runScopedTypecheck(ROOT, TSCONFIG, { incremental: false }); const live = parseTscOutput(stdout); const baseline = loadBaseline(); const { regressions, improvements } = diffAgainstBaseline(live, baseline); diff --git a/scripts/check/check-open-sse-typecheck.mjs b/scripts/check/check-open-sse-typecheck.mjs index d9a4a4fac089..650b678739e6 100644 --- a/scripts/check/check-open-sse-typecheck.mjs +++ b/scripts/check/check-open-sse-typecheck.mjs @@ -47,7 +47,7 @@ function main() { } console.log("[open-sse-typecheck] Running tsc scoped to open-sse/ workspace…"); - const stdout = runScopedTypecheck(ROOT, TSCONFIG); + const stdout = runScopedTypecheck(ROOT, TSCONFIG, { incremental: false }); const live = parseTscOutput(stdout); const baseline = loadBaseline(); const { regressions, improvements } = diffAgainstBaseline(live, baseline); diff --git a/scripts/check/ratchet.mjs b/scripts/check/ratchet.mjs new file mode 100644 index 000000000000..db27798906f6 --- /dev/null +++ b/scripts/check/ratchet.mjs @@ -0,0 +1,196 @@ +// scripts/check/ratchet.mjs +// Shared machinery for the per-file/per-TS-code typecheck ratchets. +// +// Three gates use this: the build program, the API-route program, and (via its +// own copy) the dashboard one. They differ in which tsconfig they compile and +// which baseline they compare against; everything about *how* a ratchet behaves +// is here so the three cannot drift apart. +// +// The rules this encodes, all learned from a gate that could be satisfied without +// fixing anything: +// +// - a new or raised count is a regression; a lower count is an improvement you +// may bank with --update +// - --update refuses to bank a regression, because turning a red build green by +// widening the baseline is the failure mode the ratchet exists to prevent +// - @ts-nocheck makes a file report nothing, so it is invisible to every +// diagnostic-based check; it is frozen by name and scanned separately +// - @ts-ignore / @ts-expect-error are per-line and are counted, not frozen +// - the program's own shape is frozen too, because removing a directory from +// `include` makes diagnostics disappear and reads as an improvement + +import fs from "node:fs"; +import path from "node:path"; +import { createRequire } from "node:module"; +import { runScopedTypecheck } from "./run-scoped-typecheck.mjs"; + +// tsc's pragma shape: a single-line comment whose first word is the pragma, +// terminated by whitespace, a colon (`// @ts-nocheck: reason`), or end of line. +const PRAGMA = /^\/\/\/?\s*@ts-nocheck(?=[\s:]|$)/; + +// tsc honours the line pragma as a leading comment, trailing after code, and +// inside both block-comment forms. Deliberately unanchored: over-counting a +// string literal fails a legitimate commit rather than passing an illegitimate one. +const LINE_PRAGMA = /(?:\/\/|\/\*)[^\n]*@ts-(?:expect-error|ignore)(?![a-z-])/; + +/** + * Offset of a `@ts-nocheck` that TypeScript will actually honour, or -1. + * + * tsc disables checking for the whole file when a single-line comment matching the + * pragma appears in the file's leading trivia. This walks that trivia the way the + * compiler does rather than approximating it, because every approximation misses a + * different real case: a fixed read window misses a directive behind a long licence + * banner, judging only the first textual occurrence is defeated by a comment that + * mentions the pragma above the real directive, and treating `#!` as a statement + * misses every file with a shebang. + */ +export function noCheckOffset(source) { + let i = source.startsWith("#!") ? source.indexOf("\n") + 1 || source.length : 0; + for (;;) { + while (i < source.length && /\s/.test(source[i])) i++; + if (source.startsWith("//", i)) { + const end = source.indexOf("\n", i); + const text = source.slice(i, end === -1 ? source.length : end); + if (PRAGMA.test(text)) return i; + if (end === -1) return -1; + i = end; + } else if (source.startsWith("/*", i)) { + const end = source.indexOf("*/", i + 2); + if (end === -1) return -1; + i = end + 2; + } else { + return -1; + } + } +} + +export function scanNoCheckFiles(files, root) { + const found = []; + for (const rel of files) { + // Generated trees are rebuilt from their sources on every run, so a directive + // inside one describes no file that anyone maintains. + if (rel.startsWith(".source/")) continue; + let source; + try { + source = fs.readFileSync(path.join(root, rel), "utf8"); + } catch { + continue; + } + if (noCheckOffset(source) !== -1) found.push(rel); + } + return found.sort(); +} + +export function countLineSuppressions(files, root) { + const counts = {}; + for (const rel of files) { + let source; + try { + source = fs.readFileSync(path.join(root, rel), "utf8"); + } catch { + continue; + } + let n = 0; + for (const line of source.split(/\r?\n/)) { + if (LINE_PRAGMA.test(line)) n++; + } + if (n > 0) counts[rel] = n; + } + return counts; +} + +export function suppressionDrift(baselineCounts, liveCounts) { + const regressions = []; + for (const [file, live] of Object.entries(liveCounts)) { + const was = baselineCounts[file] || 0; + if (live > was) regressions.push({ file, was, live }); + } + return regressions; +} + +/** + * Files that carry baselined errors and also opt out of checking. + * + * The two lists do not overlap in practice, because a file with @ts-nocheck + * reports no diagnostics. That is why it is worth asserting: the day they overlap, + * a frozen count is describing a file nobody checks, and the count will not shrink + * on its own because nothing produces errors to clear. + * + * The first argument must be the baseline's file list, not the live one — a file + * that gains the directive is exactly the file that leaves the live run, so + * intersecting against live is empty by construction. + */ +export function suppressedIntersection(baselineFiles, noCheckFiles) { + const optedOut = new Set(noCheckFiles); + return [...baselineFiles].filter((f) => optedOut.has(f)).sort(); +} + +/** + * Decides whether `--update` may rewrite the baseline. Returns null when it may, + * or a reason when it may not. Exported for unit testing. + */ +export function updateRefusal(regressions, { force } = {}) { + if (force) return null; + if (regressions.length === 0) return null; + return `${regressions.length} pair(s) would freeze a new or raised error`; +} + +/** The exact set of files the compiler resolved, relative to `root`, slash-separated. */ +export function programFiles(root, config, maxOldSpaceSizeMb) { + const out = runScopedTypecheck(root, config, { + maxOldSpaceSizeMb, + incremental: false, + listOnly: true, + }); + return out + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line && !line.includes("node_modules")) + .map((abs) => path.relative(root, abs).split(path.sep).join("/")) + .filter((rel) => rel && !rel.startsWith("..")) + .sort(); +} + +/** + * Reads a tsconfig the way the compiler does. A regex that strips comments also + * eats the `/*` inside a string like `"@/*"` and silently corrupts the config. + */ +function readTsConfig(root, file) { + const require = createRequire(import.meta.url); + const ts = require(path.join(root, "node_modules/typescript")); + const { config, error } = ts.readConfigFile(file, ts.sys.readFile); + if (error) { + throw new Error( + `cannot read ${path.relative(root, file)}: ${ts.flattenDiagnosticMessageText(error.messageText, " ")}` + ); + } + return config; +} + +/** + * The program's own shape, recorded in the baseline. Without this, the cheapest way + * to satisfy a ratchet is not to silence a file but to shrink the program. + */ +export function programFingerprint(root, configPath, fileCount) { + const build = readTsConfig(root, configPath); + const base = build.extends ? readTsConfig(root, path.resolve(path.dirname(configPath), build.extends)) : {}; + return { + files: fileCount, + include: build.include || [], + exclude: build.exclude || [], + compilerOptions: { ...(base.compilerOptions || {}), ...(build.compilerOptions || {}) }, + }; +} + +export function fingerprintDrift(recorded, current) { + if (!recorded) return []; + const drift = []; + for (const key of ["include", "exclude", "compilerOptions"]) { + const a = JSON.stringify(recorded[key] ?? null); + const b = JSON.stringify(current[key] ?? null); + if (a !== b) drift.push(`${key} changed`); + } + // The file count is recorded for the audit trail but is not a drift signal: + // adding or deleting a source file is ordinary work. + return drift; +} diff --git a/scripts/check/run-scoped-typecheck.mjs b/scripts/check/run-scoped-typecheck.mjs index 6d1c53a97f65..6c6f9fe9912c 100644 --- a/scripts/check/run-scoped-typecheck.mjs +++ b/scripts/check/run-scoped-typecheck.mjs @@ -3,24 +3,52 @@ import { resolve } from "node:path"; const FILE_DIAGNOSTIC = /^.+?\(\d+,\d+\): error TS\d+:/; -/** Return complete, baseline-compatible diagnostics; never accept a partial run. */ -export function runScopedTypecheck(root, config) { +/** + * Return complete, baseline-compatible diagnostics; never accept a partial run. + * + * `maxOldSpaceSizeMb` raises the compiler's heap for programs large enough to + * exhaust the default (the whole-app build program needs several GB). + * + * `incremental: false` forces a full compile. A gate must not accept a verdict + * computed from a stale `.tsbuildinfo`: a reuse of someone else's build info can + * report a clean tree for code that no longer is one. + */ +export function runScopedTypecheck( + root, + config, + { maxOldSpaceSizeMb, incremental = true, listOnly = false } = {} +) { // Invoke only this checkout's compiler. npx may download or select an ancestor's // dependency, which cannot establish the candidate's toolchain identity. - const result = spawnSync( - process.execPath, - [ - resolve(root, "node_modules/typescript/bin/tsc"), - "--pretty", - "false", - "--noEmit", - "-p", - config, - ], - { cwd: root, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 } + const nodeArgs = []; + if (maxOldSpaceSizeMb) nodeArgs.push(`--max-old-space-size=${maxOldSpaceSizeMb}`); + nodeArgs.push( + resolve(root, "node_modules/typescript/bin/tsc"), + "--pretty", + "false", + "--noEmit" ); + if (!incremental) nodeArgs.push("--incremental", "false"); + if (listOnly) nodeArgs.push("--listFilesOnly"); + nodeArgs.push("-p", config); + const result = spawnSync(process.execPath, nodeArgs, { + cwd: root, + encoding: "utf8", + maxBuffer: 64 * 1024 * 1024, + }); const stdout = result.stdout || ""; const stderr = result.stderr || ""; + if (listOnly) { + // The file list is only meaningful from a run that resolved the whole program. + if (result.error || result.signal || result.status !== 0) { + throw new Error( + `TypeScript could not list the program's files ` + + `(exit=${result.status}, signal=${result.signal || "none"}).\n` + + [result.error?.message, stderr, stdout].filter(Boolean).join("\n").slice(0, 4000) + ); + } + return stdout; + } const errors = stdout.split(/\r?\n/).filter((line) => /\berror TS\d+:/.test(line)); const complete = !result.error && diff --git a/scripts/perf/log-visibility-check.ts b/scripts/perf/log-visibility-check.ts index 817c6e718575..34a7389892d0 100644 --- a/scripts/perf/log-visibility-check.ts +++ b/scripts/perf/log-visibility-check.ts @@ -15,3 +15,5 @@ mod.parseModel("../etc/passwd"); // malformed → log.warn (security) // Emit a marker so the harness can attribute output to this run. process.stderr.write(`VISIBILITY level=${level}\n`); + +export {}; diff --git a/scripts/quality/baseline-headroom.mjs b/scripts/quality/baseline-headroom.mjs index a4b388319047..ebc1545c5583 100644 --- a/scripts/quality/baseline-headroom.mjs +++ b/scripts/quality/baseline-headroom.mjs @@ -197,6 +197,13 @@ export function buildManifest() { measure: () => runAndMatch("scripts/check/check-open-sse-typecheck.mjs", /openSseTypecheckErrors=(\d+)/), }, + { + id: "buildTypecheck", + direction: "down", + baseline: () => sumTypecheckBaseline(readJson("build-typecheck-baseline.json")), + measure: () => + runAndMatch("scripts/check/check-build-typecheck.mjs", /buildTypecheckErrors=(\d+)/), + }, { id: "cognitiveComplexity", direction: "down", diff --git a/scripts/quality/relax-baselines.mjs b/scripts/quality/relax-baselines.mjs index 625e8e0113a1..e414ba4b3b79 100644 --- a/scripts/quality/relax-baselines.mjs +++ b/scripts/quality/relax-baselines.mjs @@ -193,6 +193,9 @@ for (const f of [ "api-typecheck-baseline.json", "dashboard-typecheck-baseline.json", "open-sse-typecheck-baseline.json", + // build-typecheck-baseline.json is deliberately absent: it was frozen after the + // 2026-08-30 velocity phase and holds raw counts, so loosening it here would + // grant new headroom rather than preserve historical headroom. ]) { if (!fs.existsSync(Q(f))) continue; const j = read(f); diff --git a/src/app/(dashboard)/dashboard/HomePageClient.tsx b/src/app/(dashboard)/dashboard/HomePageClient.tsx index b0ad0a2198fa..d3b36e23e9c6 100644 --- a/src/app/(dashboard)/dashboard/HomePageClient.tsx +++ b/src/app/(dashboard)/dashboard/HomePageClient.tsx @@ -233,7 +233,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) { // Fetch the pin settings (lightweight) fetch("/api/settings") .then((r) => (r.ok ? r.json() : {})) - .then((data) => { + .then((data: Record | null) => { if (data) { if (typeof data.showQuickStartOnHome === "boolean") { setShowQuickStartOnHome(data.showQuickStartOnHome); diff --git a/src/app/(dashboard)/dashboard/agent-skills/AgentSkillsPageClient.tsx b/src/app/(dashboard)/dashboard/agent-skills/AgentSkillsPageClient.tsx index 50b9e69fed00..ee0ea569681e 100644 --- a/src/app/(dashboard)/dashboard/agent-skills/AgentSkillsPageClient.tsx +++ b/src/app/(dashboard)/dashboard/agent-skills/AgentSkillsPageClient.tsx @@ -16,6 +16,7 @@ import { McpA2aLinksBar } from "./components/McpA2aLinksBar"; import { SkillCard } from "./components/SkillCard"; import { SkillPreviewPane } from "./components/SkillPreviewPane"; import type { AgentSkill, SkillCoverage } from "@/lib/agentSkills/types"; +import type { JSX } from "react"; type FilterCategory = "all" | "api" | "cli" | "config"; diff --git a/src/app/(dashboard)/dashboard/agent-skills/components/CoverageBar.tsx b/src/app/(dashboard)/dashboard/agent-skills/components/CoverageBar.tsx index 3a8a1e6b0809..77cf93ef3642 100644 --- a/src/app/(dashboard)/dashboard/agent-skills/components/CoverageBar.tsx +++ b/src/app/(dashboard)/dashboard/agent-skills/components/CoverageBar.tsx @@ -2,6 +2,7 @@ import { useTranslations } from "next-intl"; import type { SkillCoverage } from "@/lib/agentSkills/types"; +import type { JSX } from "react"; interface CoverageBarProps { coverage: SkillCoverage; diff --git a/src/app/(dashboard)/dashboard/agent-skills/components/McpA2aLinksBar.tsx b/src/app/(dashboard)/dashboard/agent-skills/components/McpA2aLinksBar.tsx index c28492e24cf7..542fc81864c7 100644 --- a/src/app/(dashboard)/dashboard/agent-skills/components/McpA2aLinksBar.tsx +++ b/src/app/(dashboard)/dashboard/agent-skills/components/McpA2aLinksBar.tsx @@ -5,6 +5,7 @@ import { useTranslations } from "next-intl"; import Icon from "@/shared/components/Icon"; import Button from "@/shared/components/Button"; import { cn } from "@/shared/utils/cn"; +import type { JSX } from "react"; // SSR-safe origin via useSyncExternalStore. // Server snapshot returns "" (avoids hydration mismatch). diff --git a/src/app/(dashboard)/dashboard/agent-skills/components/SkillCard.tsx b/src/app/(dashboard)/dashboard/agent-skills/components/SkillCard.tsx index 2eafcab58cbe..be41811d047d 100644 --- a/src/app/(dashboard)/dashboard/agent-skills/components/SkillCard.tsx +++ b/src/app/(dashboard)/dashboard/agent-skills/components/SkillCard.tsx @@ -5,6 +5,7 @@ import { useTranslations } from "next-intl"; import Icon from "@/shared/components/Icon"; import Badge from "@/shared/components/Badge"; import type { AgentSkill } from "@/lib/agentSkills/types"; +import type { JSX } from "react"; interface SkillCardProps { skill: AgentSkill; diff --git a/src/app/(dashboard)/dashboard/agent-skills/components/SkillPreviewPane.tsx b/src/app/(dashboard)/dashboard/agent-skills/components/SkillPreviewPane.tsx index 7a428b1f96a3..5aea624cf44f 100644 --- a/src/app/(dashboard)/dashboard/agent-skills/components/SkillPreviewPane.tsx +++ b/src/app/(dashboard)/dashboard/agent-skills/components/SkillPreviewPane.tsx @@ -6,6 +6,7 @@ import { useTranslations } from "next-intl"; import { buttonVariants } from "@omniroute/ui-kit/button"; import Icon from "@/shared/components/Icon"; import Button from "@/shared/components/Button"; +import type { JSX } from "react"; // Lazy-load react-markdown to reduce initial bundle size. const ReactMarkdown = dynamic(() => import("react-markdown"), { diff --git a/src/app/(dashboard)/dashboard/cli-code/components/DroidToolCard.tsx b/src/app/(dashboard)/dashboard/cli-code/components/DroidToolCard.tsx index ab235d8ea565..47dbf9856ccb 100644 --- a/src/app/(dashboard)/dashboard/cli-code/components/DroidToolCard.tsx +++ b/src/app/(dashboard)/dashboard/cli-code/components/DroidToolCard.tsx @@ -146,7 +146,7 @@ export default function DroidToolCard({ }, [isExpanded, droidStatus, checkDroidStatus, fetchModelAliases, fetchBackups]); // (#618) Multi-model list manipulation helpers. - const addModel = (value) => { + const addModel = (value?: string) => { const v = (value ?? modelInput).trim(); if (!v || modelList.includes(v)) return; setModelList((prev) => [...prev, v]); diff --git a/src/app/(dashboard)/dashboard/combos/page.tsx b/src/app/(dashboard)/dashboard/combos/page.tsx index 3c52f31deeb4..80336db531fd 100644 --- a/src/app/(dashboard)/dashboard/combos/page.tsx +++ b/src/app/(dashboard)/dashboard/combos/page.tsx @@ -78,8 +78,11 @@ import { isIntelligentBuilderStrategy, parseQualifiedModel, resolveComboBuilderProviderId, + type ComboBuilderStage, } from "@/lib/combos/builderDraft"; import { normalizeComboConfigMode } from "@/shared/constants/comboConfigMode"; +import type { z } from "zod"; +import { comboRuntimeConfigSchema } from "@/shared/validation/schemas/combo"; import AutoComboCatalog from "./AutoComboCatalog"; import ComboHeaderActions from "./ComboHeaderActions"; import { @@ -136,11 +139,13 @@ const STRATEGY_OPTIONS = ROUTING_STRATEGIES.map((strategy) => ({ })); const STRATEGY_LABEL_FALLBACK = { + priority: "Priority", "context-relay": "Context Relay", "reset-aware": "Reset-Aware RR", }; const STRATEGY_DESC_FALLBACK = { + priority: "Use one primary model and keep the fallback chain short and reliable.", "context-relay": "Priority-style routing with automatic context handoffs when account rotation happens.", "reset-aware": @@ -259,14 +264,20 @@ const NON_PERSISTED_COMBO_CONFIG_KEYS = new Set([ "healthCheckTimeoutMs", ]); -function sanitizeComboRuntimeConfig(config) { +/** + * The combo runtime config as the API validates it. Named from the zod schema + * rather than hand-written so the client and the request validator cannot drift. + */ +type ComboRuntimeConfig = z.infer; + +function sanitizeComboRuntimeConfig(config?: Record | null): ComboRuntimeConfig { if (!config || typeof config !== "object") return {}; return Object.fromEntries( Object.entries(config).filter( ([key, value]) => value !== undefined && value !== null && !NON_PERSISTED_COMBO_CONFIG_KEYS.has(key) ) - ); + ) as ComboRuntimeConfig; } function updateFusionTuning(config, field, rawValue) { @@ -467,7 +478,12 @@ function isStaleIntelligentSelection( if (intelligentCombos.length === 0) return true; return !intelligentCombos.some((combo) => combo.id === selectedId); } -const COMBO_FORM_STAGE_META = [ +const COMBO_FORM_STAGE_META: ReadonlyArray<{ + id: ComboBuilderStage; + fallbackLabel: string; + fallbackDescription: string; + icon: string; +}> = [ { id: "basics", fallbackLabel: "Basics", @@ -2258,7 +2274,7 @@ function ComboFormModal({ const [manualModelError, setManualModelError] = useState(""); const [builderComboRefName, setBuilderComboRefName] = useState(""); const [builderError, setBuilderError] = useState(""); - const [builderStage, setBuilderStage] = useState(COMBO_BUILDER_STAGES[0]); + const [builderStage, setBuilderStage] = useState(COMBO_BUILDER_STAGES[0]); const [showAdvanced, setShowAdvanced] = useState(false); const [config, setConfig] = useState(sanitizeComboRuntimeConfig(combo?.config)); // Validate persisted enum; ensure reset on combo change not just first mount. diff --git a/src/app/(dashboard)/dashboard/costs/quota-share/components/StackedAllocationBar.tsx b/src/app/(dashboard)/dashboard/costs/quota-share/components/StackedAllocationBar.tsx index 3bc890778960..075c05170ae7 100644 --- a/src/app/(dashboard)/dashboard/costs/quota-share/components/StackedAllocationBar.tsx +++ b/src/app/(dashboard)/dashboard/costs/quota-share/components/StackedAllocationBar.tsx @@ -4,6 +4,7 @@ import { useTranslations } from "next-intl"; import type { PoolAllocation } from "@/lib/quota/dimensions"; import type { PoolUsageSnapshot } from "@/lib/quota/types"; import { getSeriesColor } from "@/shared/components/analytics/chartColors"; +import type { JSX } from "react"; export interface StackedAllocationBarProps { allocations: PoolAllocation[]; diff --git a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx index aa8db4e99a65..f1c49572dec3 100644 --- a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx +++ b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx @@ -109,7 +109,12 @@ type EndpointModelSummary = { root?: string; }; -type CopyHandler = (text: string, key?: string) => void | Promise; +/** + * Copy handlers report whether the write succeeded; useCopyToClipboard returns + * Promise. Promise is not assignable to Promise, so the + * type has to admit the boolean even though most call sites ignore it. + */ +type CopyHandler = (text: string, key?: string) => void | Promise; type EndpointTunnelVisibility = { showCloudflaredTunnel: boolean; diff --git a/src/app/(dashboard)/dashboard/omni-skills/OmniSkillsPageClient.tsx b/src/app/(dashboard)/dashboard/omni-skills/OmniSkillsPageClient.tsx index 8628eb8885c1..09be23d580ac 100644 --- a/src/app/(dashboard)/dashboard/omni-skills/OmniSkillsPageClient.tsx +++ b/src/app/(dashboard)/dashboard/omni-skills/OmniSkillsPageClient.tsx @@ -15,6 +15,7 @@ import { OmniExecutionsTab } from "./components/OmniExecutionsTab"; import { OmniSandboxTab } from "./components/OmniSandboxTab"; import { OmniMarketplaceTab } from "./components/OmniMarketplaceTab"; import type { OmniSkill } from "./components/OmniSkillCard"; +import type { JSX } from "react"; interface Execution { id: string; diff --git a/src/app/(dashboard)/dashboard/omni-skills/components/OmniExecutionsTab.tsx b/src/app/(dashboard)/dashboard/omni-skills/components/OmniExecutionsTab.tsx index b89149c16c1a..9471f42f5daf 100644 --- a/src/app/(dashboard)/dashboard/omni-skills/components/OmniExecutionsTab.tsx +++ b/src/app/(dashboard)/dashboard/omni-skills/components/OmniExecutionsTab.tsx @@ -11,6 +11,7 @@ import { } from "@omniroute/ui-kit/table"; import Button from "@/shared/components/Button"; import { Card } from "@/shared/components"; +import type { JSX } from "react"; interface Execution { id: string; diff --git a/src/app/(dashboard)/dashboard/omni-skills/components/OmniMarketplaceTab.tsx b/src/app/(dashboard)/dashboard/omni-skills/components/OmniMarketplaceTab.tsx index ed5d423d0492..0ed47c9d1e3f 100644 --- a/src/app/(dashboard)/dashboard/omni-skills/components/OmniMarketplaceTab.tsx +++ b/src/app/(dashboard)/dashboard/omni-skills/components/OmniMarketplaceTab.tsx @@ -6,6 +6,7 @@ import Button from "@/shared/components/Button"; import Input from "@/shared/components/Input"; import { Card } from "@/shared/components"; import type { SkillsProvider } from "@/lib/skills/providerSettings"; +import type { JSX } from "react"; interface MarketplaceSkill { name: string; diff --git a/src/app/(dashboard)/dashboard/omni-skills/components/OmniSandboxTab.tsx b/src/app/(dashboard)/dashboard/omni-skills/components/OmniSandboxTab.tsx index 81ad576b7bae..5724b175f0c9 100644 --- a/src/app/(dashboard)/dashboard/omni-skills/components/OmniSandboxTab.tsx +++ b/src/app/(dashboard)/dashboard/omni-skills/components/OmniSandboxTab.tsx @@ -2,6 +2,7 @@ import { useTranslations } from "next-intl"; import { Card } from "@/shared/components"; +import type { JSX } from "react"; export function OmniSandboxTab(): JSX.Element { const t = useTranslations("skills"); diff --git a/src/app/(dashboard)/dashboard/omni-skills/components/OmniSkillCard.tsx b/src/app/(dashboard)/dashboard/omni-skills/components/OmniSkillCard.tsx index 80fd62d8bf67..ae735b6635fc 100644 --- a/src/app/(dashboard)/dashboard/omni-skills/components/OmniSkillCard.tsx +++ b/src/app/(dashboard)/dashboard/omni-skills/components/OmniSkillCard.tsx @@ -5,6 +5,7 @@ import Icon from "@/shared/components/Icon"; import Badge from "@/shared/components/Badge"; import Button from "@/shared/components/Button"; import { cn } from "@/shared/utils/cn"; +import type { JSX } from "react"; export interface OmniSkill { id: string; diff --git a/src/app/(dashboard)/dashboard/omni-skills/components/OmniSkillsList.tsx b/src/app/(dashboard)/dashboard/omni-skills/components/OmniSkillsList.tsx index 20bb3bed457e..ff8811946ed6 100644 --- a/src/app/(dashboard)/dashboard/omni-skills/components/OmniSkillsList.tsx +++ b/src/app/(dashboard)/dashboard/omni-skills/components/OmniSkillsList.tsx @@ -10,6 +10,7 @@ import { Card } from "@/shared/components"; import { OmniSkillCard } from "./OmniSkillCard"; import { SkillInspectorPane } from "./SkillInspectorPane"; import type { OmniSkill } from "./OmniSkillCard"; +import type { JSX } from "react"; interface OmniSkillsListProps { skills: OmniSkill[]; diff --git a/src/app/(dashboard)/dashboard/omni-skills/components/SkillInspectorPane.tsx b/src/app/(dashboard)/dashboard/omni-skills/components/SkillInspectorPane.tsx index 180ece13b16f..2b106cc47edf 100644 --- a/src/app/(dashboard)/dashboard/omni-skills/components/SkillInspectorPane.tsx +++ b/src/app/(dashboard)/dashboard/omni-skills/components/SkillInspectorPane.tsx @@ -15,6 +15,7 @@ import Icon from "@/shared/components/Icon"; import Button from "@/shared/components/Button"; import { cn } from "@/shared/utils/cn"; import type { OmniSkill } from "./OmniSkillCard"; +import type { JSX } from "react"; interface SkillDetail extends OmniSkill { schema?: { diff --git a/src/app/(dashboard)/dashboard/providers/[id]/__tests__/useModelVisibilityHandlers.test.tsx b/src/app/(dashboard)/dashboard/providers/[id]/__tests__/useModelVisibilityHandlers.test.tsx index 1f044073ea6b..627df333e812 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/__tests__/useModelVisibilityHandlers.test.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/__tests__/useModelVisibilityHandlers.test.tsx @@ -7,6 +7,7 @@ import { useModelVisibilityHandlers, type UseModelVisibilityHandlersReturn, } from "../hooks/useModelVisibilityHandlers"; +import type { NotificationStore } from "@/store/notificationStore"; type HookResult = UseModelVisibilityHandlersReturn; @@ -15,12 +16,18 @@ const t = ((key: string) => key) as ((key: string) => string) & { }; t.has = () => false; +// The hook takes the notification store itself, so the stub has to satisfy the +// whole interface, not just the four methods the tests assert on. const notify = { + notifications: [], + addNotification: vi.fn(), + removeNotification: vi.fn(), + clearAll: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn(), -}; +} satisfies NotificationStore; const baseProps = { providerId: "anthropic-compatible-cc-test", diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModalsPanel.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModalsPanel.tsx index 819482a7647f..1b699c78258c 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModalsPanel.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModalsPanel.tsx @@ -1,5 +1,7 @@ "use client"; +import type { RiskNoticeVariant } from "@/shared/constants/providers"; + // Phase 1t.5 extraction — Issue #3501 // Pure composition of all modal elements rendered by ProviderDetailPageClient. import { @@ -10,6 +12,9 @@ import { TraeAuthModal, ProxyConfigModal, } from "@/shared/components"; +// The barrel does not re-export the level union, so name it from the module +// that owns it. +import type { ProxyConfigLevel } from "@/shared/components/ProxyConfigModal"; import RiskNoticeModal from "../../components/RiskNoticeModal"; import CodexCliGuideModal from "../../components/CodexCliGuideModal"; import SiliconFlowEndpointModal from "./SiliconFlowEndpointModal"; @@ -34,7 +39,7 @@ import { resolveProviderOAuthBackendId } from "../../providerPageUtils"; interface ProviderInfo { name: string; oauthProviderId?: string; - riskNoticeVariant?: string; + riskNoticeVariant?: RiskNoticeVariant; website?: string; [key: string]: unknown; } @@ -75,7 +80,7 @@ interface ProviderModalsPanelProps { // AddApiKey showAddApiKeyModal: boolean; siliconFlowInitialBaseUrl: string | undefined; - commandCodeAuthState: { phase: string; [key: string]: unknown }; + commandCodeAuthState: import("../providerPageHelpers").CommandCodeAuthFlowState; handleStartCommandCodeAuth: () => void; handleSaveApiKey: (data: any) => Promise; handleCloseAddApiKeyModal: () => void; @@ -235,7 +240,7 @@ export default function ProviderModalsPanel({ <> {showRiskNoticeModal && subscriptionRisk && ( setBatchDeleteConfirmOpen(false)} onConfirm={handleBatchDeleteConfirm} - title={t("batchDeleteConfirmTitle", "Delete connections")} + title={providerText(t, "batchDeleteConfirmTitle", "Delete connections")} message={t("batchDeleteConfirm", { count: selectedIds.size })} - confirmText={t("batchDeleteConfirmButton", "Delete")} - cancelText={t("cancel", "Cancel")} + confirmText={providerText(t, "batchDeleteConfirmButton", "Delete")} + cancelText={providerText(t, "cancel", "Cancel")} loading={batchDeleting} /> )} @@ -442,7 +447,7 @@ export default function ProviderModalsPanel({ setProxyTarget(null)} - level={proxyTarget.level} + level={proxyTarget.level as ProxyConfigLevel} levelId={proxyTarget.id} levelLabel={proxyTarget.label} onSaved={() => { diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderPlaygroundPanel.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderPlaygroundPanel.tsx index c7bb85f4f2f4..e636c707896e 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderPlaygroundPanel.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderPlaygroundPanel.tsx @@ -20,6 +20,7 @@ import { MusicExampleCard } from "@/app/(dashboard)/dashboard/media-providers/co import type { ServiceKind } from "@/shared/constants/providers"; import { AI_PROVIDERS } from "@/shared/constants/providers"; import { providerText } from "../providerPageHelpers"; +import type { JSX } from "react"; export const MEDIA_SERVICE_KINDS: ServiceKind[] = [ "embedding", diff --git a/src/app/(dashboard)/dashboard/providers/[id]/hooks/useModelVisibilityHandlers.ts b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useModelVisibilityHandlers.ts index 56851b498254..e44535b78399 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/hooks/useModelVisibilityHandlers.ts +++ b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useModelVisibilityHandlers.ts @@ -30,10 +30,10 @@ import { type ProviderMessageTranslator, type CompatByProtocolMap, } from "../providerPageHelpers"; -import { useNotificationStore } from "@/store/notificationStore"; +import type { NotificationStore } from "@/store/notificationStore"; import { extractApiErrorMessage } from "@/shared/http/apiErrorMessage"; -type NotifyStore = ReturnType; +type NotifyStore = NotificationStore; // ──── types ────────────────────────────────────────────────────────────────── diff --git a/src/app/api/cli-tools/cline-settings/route.ts b/src/app/api/cli-tools/cline-settings/route.ts index 6b36b6380f47..5920f30089fe 100644 --- a/src/app/api/cli-tools/cline-settings/route.ts +++ b/src/app/api/cli-tools/cline-settings/route.ts @@ -26,7 +26,8 @@ const SECRETS_PATH = path.join(CLINE_DATA_DIR, "secrets.json"); // Ported from upstream decolua/9router@6c10edf8: tolerate JSONC (trailing // commas) and return null on any parse error so the dashboard renders // "installed but not configured" instead of a 500 misread as "not installed". -const readGlobalState = async () => readJsoncConfig(GLOBAL_STATE_PATH); +const readGlobalState = async () => + readJsoncConfig>(GLOBAL_STATE_PATH); // Read secrets.json (same JSONC-tolerant behaviour; defaults to {} for compat). const readSecrets = async () => readJsoncConfig>(SECRETS_PATH, {}); diff --git a/src/app/api/guardrails/test/route.ts b/src/app/api/guardrails/test/route.ts index 20412d4e4101..81cf0278cad7 100644 --- a/src/app/api/guardrails/test/route.ts +++ b/src/app/api/guardrails/test/route.ts @@ -17,7 +17,7 @@ import { registerDefaultGuardrails } from "@/lib/guardrails/registry"; import { validateBody, isValidationFailure } from "@/shared/validation/helpers"; const TestRequestSchema = z.object({ - input: z.union([z.string(), z.record(z.unknown()), z.array(z.unknown())]), + input: z.union([z.string(), z.record(z.string(), z.unknown()), z.array(z.unknown())]), disabledGuardrails: z.array(z.string()).optional(), }); diff --git a/src/app/api/models/alias/route.ts b/src/app/api/models/alias/route.ts index 84480424c38a..54523060c735 100644 --- a/src/app/api/models/alias/route.ts +++ b/src/app/api/models/alias/route.ts @@ -26,7 +26,7 @@ export async function GET(request) { if (alias) { const resolved = await resolveModelAliasLookup(alias); - if (!resolved.ok) { + if (resolved.ok === false) { return NextResponse.json( { error: { diff --git a/src/app/api/oauth/codex/import/route.ts b/src/app/api/oauth/codex/import/route.ts index cda34c09cb7f..f6cf5467a8f9 100644 --- a/src/app/api/oauth/codex/import/route.ts +++ b/src/app/api/oauth/codex/import/route.ts @@ -85,7 +85,7 @@ async function validateCodexRefreshToken(payload: { */ const bodySchema = z.object({ - accounts: z.union([z.record(z.unknown()), z.array(z.unknown())], { + accounts: z.union([z.record(z.string(), z.unknown()), z.array(z.unknown())], { errorMap: () => ({ message: "accounts must be an object or an array of objects" }), }), }); diff --git a/src/app/api/oauth/cursor/login/poll/route.ts b/src/app/api/oauth/cursor/login/poll/route.ts index 1536ef32c609..4815fe56fe43 100644 --- a/src/app/api/oauth/cursor/login/poll/route.ts +++ b/src/app/api/oauth/cursor/login/poll/route.ts @@ -22,11 +22,12 @@ async function requireOAuthAuth(request: Request) { return requireManagementAuth(request, { invalidApiKeyStatus: 401 }); } -async function syncToCloudIfEnabled() { +async function syncToCloudIfEnabled(machineId: string) { try { - if (await isCloudEnabled()) { - await syncToCloud(); - } + if (!(await isCloudEnabled())) return; + // syncToCloud requires the machine id; every other caller passes it too. + // Calling it bare sent undefined and the sync silently failed. + await syncToCloud(machineId); } catch { // best-effort } @@ -92,7 +93,7 @@ export async function POST(request: Request) { authMethod: "deep_control", }); - await syncToCloudIfEnabled(); + await syncToCloudIfEnabled(machineId); return NextResponse.json({ status: "ok", diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index 0ad8faac8f61..fc1bdfd11def 100755 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -1603,8 +1603,9 @@ export async function GET( const modelsResp = await safeOutboundFetch( "https://platformapi.innerai.com/api/v1/ai_models", - { headers: innerAiHeaders }, - getProviderOutboundGuard(provider) + // The guard is an option, not a third positional argument; passing it + // separately left this request running under the default "none". + { headers: innerAiHeaders, guard: getProviderOutboundGuard() } ); if (!modelsResp.ok) { throw new Error(`Inner.ai models API returned HTTP ${modelsResp.status}`); diff --git a/src/app/api/providers/[id]/refresh/route.ts b/src/app/api/providers/[id]/refresh/route.ts index 7df436125708..129c042cdebb 100644 --- a/src/app/api/providers/[id]/refresh/route.ts +++ b/src/app/api/providers/[id]/refresh/route.ts @@ -13,7 +13,17 @@ import { rotationGroupFor } from "@omniroute/open-sse/services/refreshSerializer type RefreshResult = { accessToken?: string; expiresIn?: number; + /** Absolute expiry, preferred over expiresIn when the provider returns one. */ + expiresAt?: string; error?: string; + /** + * Set when the provider has been retired upstream. The caller surfaces a + * migration hint instead of asking the operator to re-authenticate in a loop + * against something that no longer exists. + */ + code?: string; + reason?: string; + migrateTo?: string; }; /** diff --git a/src/app/api/usage/analytics/route.ts b/src/app/api/usage/analytics/route.ts index 238b2571e44f..29309258e304 100644 --- a/src/app/api/usage/analytics/route.ts +++ b/src/app/api/usage/analytics/route.ts @@ -68,7 +68,6 @@ function getRangeStartIso(range: string): string | null { const WEEKDAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; type PricingByProvider = Record>>; -type UsageRows = Array>; type ComputeCostFromPricing = ( pricing: Record | null | undefined, tokens: Record | null | undefined, @@ -361,8 +360,13 @@ function resolveModelPricing( ); } +// `row` is declared as `object` rather than `Record` because the +// callers pass concrete row interfaces, which have no index signature and so are +// not assignable to a Record. The body reads camelCase and snake_case spellings +// off the same shape, so the widening happens once, here, instead of at 13 call +// sites with `as unknown as`. function computeUsageRowCost( - row: Record, + row: object, pricingByProvider: PricingByProvider, providerAliasMap: Record, normalizeModelName: (model: string) => string, @@ -370,12 +374,13 @@ function computeUsageRowCost( flatRateAsZero = true, pricingContext?: PricingResolutionContext ): number { - const provider = toStringValue(row.provider); - const model = toStringValue(row.model); + const r = row as Record; + const provider = toStringValue(r.provider); + const model = toStringValue(r.model); if (!provider || !model) return 0; - const serviceTier = normalizeServiceTier(row.serviceTier ?? row.service_tier); - const isAggregated = toNumber(row.isAggregated ?? row.is_aggregated) > 0; - const storedCost = toNumber(row.storedCost ?? row.stored_cost); + const serviceTier = normalizeServiceTier(r.serviceTier ?? r.service_tier); + const isAggregated = toNumber(r.isAggregated ?? r.is_aggregated) > 0; + const storedCost = toNumber(r.storedCost ?? r.stored_cost); // Billed-cost views show flat-rate providers as $0, whatever was stored. if (flatRateAsZero && isFlatRateProvider(provider)) return 0; @@ -406,11 +411,11 @@ function computeUsageRowCost( computeCostFromPricing( pricing, { - input: toNumber(row.costPromptTokens ?? row.promptTokens), - output: toNumber(row.costCompletionTokens ?? row.completionTokens), - cacheRead: toNumber(row.costCacheReadTokens ?? row.cacheReadTokens), - cacheCreation: toNumber(row.costCacheCreationTokens ?? row.cacheCreationTokens), - reasoning: toNumber(row.costReasoningTokens ?? row.reasoningTokens), + input: toNumber(r.costPromptTokens ?? r.promptTokens), + output: toNumber(r.costCompletionTokens ?? r.completionTokens), + cacheRead: toNumber(r.costCacheReadTokens ?? r.cacheReadTokens), + cacheCreation: toNumber(r.costCacheCreationTokens ?? r.cacheCreationTokens), + reasoning: toNumber(r.costReasoningTokens ?? r.reasoningTokens), }, { provider, @@ -423,7 +428,8 @@ function computeUsageRowCost( } function computeUsageRowStandardCost( - row: Record, + // Concrete rows have no index signature; widen once here. + row: object, pricingByProvider: PricingByProvider, providerAliasMap: Record, normalizeModelName: (model: string) => string, @@ -431,11 +437,12 @@ function computeUsageRowStandardCost( flatRateAsZero = true, pricingContext?: PricingResolutionContext ): number { + const r = row as Record; // A raw row's provider-reported cost has no standard-tier alternative to compare // against, so it counts the same on both sides and yields no savings. A rollup's // stored cost is tier-specific and is re-priced from its tokens instead. - const keepsReportedCost = toNumber(row.isAggregated ?? row.is_aggregated) === 0; - const reportedCost = keepsReportedCost ? toNumber(row.storedCost ?? row.stored_cost) : 0; + const keepsReportedCost = toNumber(r.isAggregated ?? r.is_aggregated) === 0; + const reportedCost = keepsReportedCost ? toNumber(r.storedCost ?? r.stored_cost) : 0; return computeUsageRowCost( { ...row, @@ -456,13 +463,15 @@ function computeUsageRowStandardCost( } function computeUsageSavingsTokens( - row: Record, + // Concrete rows have no index signature; widen once here. + row: object, serviceTier: string, getCodexFastCostMultiplier: GetCodexFastCostMultiplier ): number { - const provider = toStringValue(row.provider); - const model = toStringValue(row.model); - const totalTokens = toNumber(row.totalTokens); + const r = row as Record; + const provider = toStringValue(r.provider); + const model = toStringValue(r.model); + const totalTokens = toNumber(r.totalTokens); if (!provider || !model || totalTokens <= 0) return 0; const standardMultiplier = getCodexFastCostMultiplier(provider, model, "standard"); @@ -629,10 +638,13 @@ async function computeAnalyticsResponse( return NextResponse.json({ byApiKey }); } - const summaryRow = getUsageSummary(unifiedSource, unifiedParams) as Record; + const summaryRow = getUsageSummary(unifiedSource, unifiedParams) as unknown as Record< + string, + unknown + >; - const dailyRows = getDailyUsage(unifiedSource, unifiedParams) as UsageRows; - const dailyCostRows = getDailyCostRows(unifiedSource, unifiedParams) as UsageRows; + const dailyRows = getDailyUsage(unifiedSource, unifiedParams); + const dailyCostRows = getDailyCostRows(unifiedSource, unifiedParams); const heatmapStart = new Date(); heatmapStart.setUTCDate(heatmapStart.getUTCDate() - 364); @@ -654,26 +666,26 @@ async function computeAnalyticsResponse( }); } - const heatmapRows = getHeatmapRows(heatmapConditions, heatmapParams) as UsageRows; + const heatmapRows = getHeatmapRows(heatmapConditions, heatmapParams); - const modelRows = getModelUsageRows(unifiedSource, unifiedParams) as UsageRows; + const modelRows = getModelUsageRows(unifiedSource, unifiedParams); - const providerCostRows = getProviderCostRows(unifiedSource, unifiedParams) as UsageRows; + const providerCostRows = getProviderCostRows(unifiedSource, unifiedParams); - const providerRows = getProviderUsageRows(unifiedSource, unifiedParams) as UsageRows; + const providerRows = getProviderUsageRows(unifiedSource, unifiedParams); const accountCostWhereClause = whereClause .replace(/timestamp/g, "usage_history.timestamp") .replace(/api_key_/g, "usage_history.api_key_"); - const accountCostRows = getAccountCostRows(accountCostWhereClause, params) as UsageRows; + const accountCostRows = getAccountCostRows(accountCostWhereClause, params); - const accountRows = getAccountUsageRows(accountCostWhereClause, params) as UsageRows; + const accountRows = getAccountUsageRows(accountCostWhereClause, params); - const apiKeyRows = getApiKeyUsageRows(apiKeyWhereClause, params) as UsageRows; + const apiKeyRows = getApiKeyUsageRows(apiKeyWhereClause, params); - const serviceTierRows = getServiceTierUsageRows(unifiedSource, unifiedParams) as UsageRows; + const serviceTierRows = getServiceTierUsageRows(unifiedSource, unifiedParams); - const apiKeyMetadataRows = getApiKeyMetadataRows(apiKeyWhereClause, params) as UsageRows; + const apiKeyMetadataRows = getApiKeyMetadataRows(apiKeyWhereClause, params); const apiKeyMetadata = new Map }>(); for (const row of apiKeyMetadataRows) { @@ -690,9 +702,9 @@ async function computeAnalyticsResponse( apiKeyMetadata.set(groupKey, existing); } - const weeklyRows = getWeeklyPatternRows(unifiedSource, unifiedParams) as UsageRows; + const weeklyRows = getWeeklyPatternRows(unifiedSource, unifiedParams); - const fallbackRow = getFallbackStats(whereClause, params) as Record; + const fallbackRow = getFallbackStats(whereClause, params) as unknown as Record; const errorBreakdown = getErrorTypeBreakdown(whereClause, params); const summary = { @@ -1131,7 +1143,7 @@ async function computeAnalyticsResponse( apiKeyParams: apiKeyParamEntries, }); - const presetModelRows = getPresetCostModelRows(pSrc, pParams) as UsageRows; + const presetModelRows = getPresetCostModelRows(pSrc, pParams); let presetTotalCost = 0; for (const row of presetModelRows) { diff --git a/src/app/api/v1/images/edits/route.ts b/src/app/api/v1/images/edits/route.ts index 1c5f10f472f7..3a10efa88d96 100644 --- a/src/app/api/v1/images/edits/route.ts +++ b/src/app/api/v1/images/edits/route.ts @@ -807,12 +807,19 @@ async function postHandler(request: Request, _context?: unknown) { `No credentials for provider: ${parsed.provider}` ); } - if (credentials.allRateLimited) { + // The credential resolver returns a union of fifteen shapes; only the + // rate-limited one carries these fields. Same narrowing the call sites above use. + const rateLimitInfo = credentials as { + allRateLimited?: boolean; + retryAfter?: string | number | Date | null; + retryAfterHuman?: string; + }; + if (rateLimitInfo.allRateLimited) { return unavailableResponse( HTTP_STATUS.RATE_LIMITED, `[${parsed.provider}] All accounts rate limited`, - credentials.retryAfter, - credentials.retryAfterHuman + rateLimitInfo.retryAfter, + rateLimitInfo.retryAfterHuman ); } const credentialDetails = credentials as { @@ -839,7 +846,12 @@ async function postHandler(request: Request, _context?: unknown) { const editImage = () => runImageRequestWithAccounting( - editAccounting(accounting, parsed.provider, parsed.model, credentials), + editAccounting( + accounting, + parsed.provider, + parsed.model, + credentials as { apiKey?: string; accessToken?: string } + ), () => handleCodexImageEdit({ provider: parsed.provider, @@ -888,12 +900,19 @@ async function postHandler(request: Request, _context?: unknown) { `No credentials for provider: ${parsed.provider}` ); } - if (credentials.allRateLimited) { + // The credential resolver returns a union of fifteen shapes; only the + // rate-limited one carries these fields. Same narrowing the call sites above use. + const rateLimitInfo = credentials as { + allRateLimited?: boolean; + retryAfter?: string | number | Date | null; + retryAfterHuman?: string; + }; + if (rateLimitInfo.allRateLimited) { return unavailableResponse( HTTP_STATUS.RATE_LIMITED, `[${parsed.provider}] All accounts rate limited`, - credentials.retryAfter, - credentials.retryAfterHuman + rateLimitInfo.retryAfter, + rateLimitInfo.retryAfterHuman ); } @@ -911,12 +930,14 @@ async function postHandler(request: Request, _context?: unknown) { n: 1, }, images, - credentials, + credentials: credentials as Record, log, }) ); - if (result.success) { + // Both members declare `success: boolean`, so that is not a discriminant; the + // presence of `data` is. + if ("data" in result) { await clearRecoveredProviderState(credentials); return jsonResponse(result.data); } @@ -976,12 +997,19 @@ async function postHandler(request: Request, _context?: unknown) { `No credentials for provider: ${parsed.provider}` ); } - if (credentials.allRateLimited) { + // The credential resolver returns a union of fifteen shapes; only the + // rate-limited one carries these fields. Same narrowing the call sites above use. + const rateLimitInfo = credentials as { + allRateLimited?: boolean; + retryAfter?: string | number | Date | null; + retryAfterHuman?: string; + }; + if (rateLimitInfo.allRateLimited) { return unavailableResponse( HTTP_STATUS.RATE_LIMITED, `[${parsed.provider}] All accounts rate limited`, - credentials.retryAfter, - credentials.retryAfterHuman + rateLimitInfo.retryAfter, + rateLimitInfo.retryAfterHuman ); } @@ -992,7 +1020,7 @@ async function postHandler(request: Request, _context?: unknown) { provider: parsed.provider, model: parsed.model, baseUrl: providerConfig.baseUrl, - credentials, + credentials: credentials as { apiKey?: string; accessToken?: string }, prompt, imageBytes, imageMime, @@ -1114,22 +1142,44 @@ async function postHandler(request: Request, _context?: unknown) { `No credentials for custom image provider: ${customProviderId}` ); } - if (credentials.allRateLimited) { + // The credential resolver returns a union of fifteen shapes; only the + // rate-limited one carries these fields. Same narrowing the call sites above use. + const rateLimitInfo = credentials as { + allRateLimited?: boolean; + retryAfter?: string | number | Date | null; + retryAfterHuman?: string; + }; + if (rateLimitInfo.allRateLimited) { return unavailableResponse( HTTP_STATUS.RATE_LIMITED, `[${customProviderId}] All accounts rate limited`, - credentials.retryAfter, - credentials.retryAfterHuman + rateLimitInfo.retryAfter, + rateLimitInfo.retryAfterHuman ); } const result = await runImageRequestWithAccounting( - editAccounting(accounting, customProviderId, customModel, credentials), + editAccounting( + accounting, + customProviderId, + customModel, + credentials as { + apiKey?: string; + accessToken?: string; + baseUrl?: unknown; + providerSpecificData?: { baseUrl?: unknown }; + } + ), () => handleOpenAIImageEdit({ provider: customProviderId, model: customModel, - credentials, + credentials: credentials as { + apiKey?: string; + accessToken?: string; + baseUrl?: unknown; + providerSpecificData?: { baseUrl?: unknown }; + }, prompt, imageBytes, imageMime, diff --git a/src/app/api/webhooks/[id]/route.ts b/src/app/api/webhooks/[id]/route.ts index fab9daac12ef..9844464c5ae1 100644 --- a/src/app/api/webhooks/[id]/route.ts +++ b/src/app/api/webhooks/[id]/route.ts @@ -28,7 +28,7 @@ const updateWebhookSchema = z description: z.string().max(1000).optional(), enabled: z.boolean().optional(), kind: z.enum(WEBHOOK_KINDS).optional(), - metadata: z.record(z.string()).optional(), + metadata: z.record(z.string(), z.string()).optional(), }) .strict(); diff --git a/src/app/api/webhooks/route.ts b/src/app/api/webhooks/route.ts index f55ed2623ffd..7676313c9ef2 100644 --- a/src/app/api/webhooks/route.ts +++ b/src/app/api/webhooks/route.ts @@ -27,7 +27,7 @@ const createWebhookSchema = z secret: z.string().max(500).optional(), description: z.string().max(1000).optional().default(""), kind: z.enum(WEBHOOK_KINDS).optional().default("custom"), - metadata: z.record(z.string()).optional(), + metadata: z.record(z.string(), z.string()).optional(), }) .superRefine((data, ctx) => { if (data.kind === "telegram") return; diff --git a/src/lib/db/adapters/betterSqliteAdapter.ts b/src/lib/db/adapters/betterSqliteAdapter.ts index 13eb048e8afd..ce3b10ca7db3 100644 --- a/src/lib/db/adapters/betterSqliteAdapter.ts +++ b/src/lib/db/adapters/betterSqliteAdapter.ts @@ -43,12 +43,12 @@ export function createBetterSqliteAdapter(db: import("better-sqlite3").Database) return db.inTransaction; }, - prepare(sql: string): PreparedStatement { + prepare(sql: string): PreparedStatement { const stmt = getCached(sql); return { run: (...params: unknown[]): RunResult => stmt.run(...params) as unknown as RunResult, - get: (...params: unknown[]): unknown => stmt.get(...params), - all: (...params: unknown[]): unknown[] => stmt.all(...params), + get: (...params: unknown[]) => stmt.get(...params) as Row | undefined, + all: (...params: unknown[]) => stmt.all(...params) as Row[], }; }, diff --git a/src/lib/db/adapters/bunSqliteAdapter.ts b/src/lib/db/adapters/bunSqliteAdapter.ts index 8ea9bcf0d4b7..11ce5c4a31d4 100644 --- a/src/lib/db/adapters/bunSqliteAdapter.ts +++ b/src/lib/db/adapters/bunSqliteAdapter.ts @@ -74,19 +74,19 @@ export function createBunSqliteAdapter(db: BunSqliteDatabaseLike, filePath: stri return filePath; }, - prepare(sql: string): PreparedStatement { + prepare(sql: string): PreparedStatement { const statement = db.query(sql); return { run(...params: unknown[]): RunResult { return normalizeRunResult(statement.run(...normalizeParams(params))); }, - get(...params: unknown[]): unknown { + get(...params: unknown[]): Row | undefined { // bun:sqlite returns null when no row matches; better-sqlite3, node:sqlite and // sql.js return undefined, which is the contract the call sites are written to. - return statement.get(...normalizeParams(params)) ?? undefined; + return (statement.get(...normalizeParams(params)) ?? undefined) as Row | undefined; }, - all(...params: unknown[]): unknown[] { - return statement.all(...normalizeParams(params)); + all(...params: unknown[]): Row[] { + return statement.all(...normalizeParams(params)) as Row[]; }, }; }, diff --git a/src/lib/db/adapters/nodeSqliteShared.ts b/src/lib/db/adapters/nodeSqliteShared.ts index 1a8a538c3cf9..6871cb1cc1e7 100644 --- a/src/lib/db/adapters/nodeSqliteShared.ts +++ b/src/lib/db/adapters/nodeSqliteShared.ts @@ -167,7 +167,7 @@ export function createNodeSqliteAdapterFromDatabase( get name() { return filePath; }, - prepare(sql: string): PreparedStatement { + prepare(sql: string): PreparedStatement { const stmt = getCached(sql); return { run(...params: unknown[]): RunResult { @@ -177,13 +177,13 @@ export function createNodeSqliteAdapterFromDatabase( lastInsertRowid: Number(r.lastInsertRowid ?? 0), }; }, - get(...params: unknown[]): unknown { - return toPlainRow(stmt.get(...normalizeBindParams(params))); + get(...params: unknown[]): Row | undefined { + return toPlainRow(stmt.get(...normalizeBindParams(params))) as Row | undefined; }, - all(...params: unknown[]): unknown[] { + all(...params: unknown[]): Row[] { return (stmt.all(...normalizeBindParams(params)) as unknown[]).map((row) => toPlainRow(row) - ); + ) as Row[]; }, }; }, diff --git a/src/lib/db/adapters/slowDiagnostics.ts b/src/lib/db/adapters/slowDiagnostics.ts index b15107dc4378..0d48cacce193 100644 --- a/src/lib/db/adapters/slowDiagnostics.ts +++ b/src/lib/db/adapters/slowDiagnostics.ts @@ -1,5 +1,5 @@ import { performance } from "node:perf_hooks"; -import type { SqliteAdapter } from "./types"; +import type { PreparedStatement, SqliteAdapter } from "./types"; const WARN_EVERY_MS = 10_000; type Operation = @@ -66,12 +66,12 @@ export function instrumentSqliteAdapter( get raw() { return db.raw; }, - prepare(sql) { - const stmt = measure("prepare", () => db.prepare(sql)); + prepare(sql: string): PreparedStatement { + const stmt = measure("prepare", () => db.prepare(sql)) as PreparedStatement; return { run: (...params) => measure("run", () => stmt.run(...params)), - get: (...params) => measure("get", () => stmt.get(...params)), - all: (...params) => measure("all", () => stmt.all(...params)), + get: (...params) => measure("get", () => stmt.get(...params)) as Row | undefined, + all: (...params) => measure("all", () => stmt.all(...params)) as Row[], }; }, exec(sql) { diff --git a/src/lib/db/adapters/sqljsAdapter.ts b/src/lib/db/adapters/sqljsAdapter.ts index f60024033fa4..6e91adcea6bb 100644 --- a/src/lib/db/adapters/sqljsAdapter.ts +++ b/src/lib/db/adapters/sqljsAdapter.ts @@ -415,8 +415,8 @@ export async function createSqlJsAdapter(filePath: string): Promise(sql: string): PreparedStatement { + return makeStatement(sql) as PreparedStatement; }, exec(sql: string): void { diff --git a/src/lib/db/adapters/types.ts b/src/lib/db/adapters/types.ts index 76cbb90327e7..ed20a21fa163 100644 --- a/src/lib/db/adapters/types.ts +++ b/src/lib/db/adapters/types.ts @@ -3,10 +3,17 @@ export interface RunResult { lastInsertRowid: number | bigint; } -export interface PreparedStatement { +/** + * A statement whose row shape is known to the caller. + * + * Drivers hand back untyped rows, so the generic lives here rather than on each + * adapter: callers that know their SELECT list write `prepare(sql)` and get + * `Row[]` back. Omitting the type argument keeps the old `unknown` behaviour. + */ +export interface PreparedStatement { run(...params: unknown[]): RunResult; - get(...params: unknown[]): unknown; - all(...params: unknown[]): unknown[]; + get(...params: unknown[]): Row | undefined; + all(...params: unknown[]): Row[]; } export interface SqliteAdapter { @@ -16,7 +23,7 @@ export interface SqliteAdapter { /** Driver transaction state when exposed by the underlying SQLite implementation. */ readonly inTransaction?: boolean; - prepare(sql: string): PreparedStatement; + prepare(sql: string): PreparedStatement; exec(sql: string): void; pragma(pragmaStr: string, options?: { simple?: boolean }): unknown; diff --git a/src/lib/db/core.ts b/src/lib/db/core.ts index 5721c5221001..d0e7d3e3db11 100644 --- a/src/lib/db/core.ts +++ b/src/lib/db/core.ts @@ -1009,7 +1009,7 @@ export function getDbInstance(): SqliteDatabase { driver: "sql.js", open: true, name: ":memory:", - prepare: () => noopStatement, + prepare: () => noopStatement as PreparedStatement, exec: () => {}, pragma: () => undefined, transaction: (fn: (...args: unknown[]) => T) => fn, diff --git a/src/lib/quota/dimensions.ts b/src/lib/quota/dimensions.ts index df4c5ab9f9ac..51661be56e5f 100644 --- a/src/lib/quota/dimensions.ts +++ b/src/lib/quota/dimensions.ts @@ -35,8 +35,19 @@ export type ProviderPlan = z.infer; export const QuotaPoolSchema = z.object({ id: z.string().min(1), + /** Primary / legacy single connection. Kept for back-compat. */ connectionId: z.string().min(1), + /** + * Every member connection; the primary is always connectionIds[0]. + * + * Optional because older payloads and the legacy single-connection form carry + * only connectionId, and consumers already fall back to it. The DB layer has + * this as a required field after backfill. + */ + connectionIds: z.array(z.string()).optional(), name: z.string().min(1), + /** Group this pool belongs to. Defaults to 'group-demo' for legacy pools. */ + groupId: z.string().optional(), createdAt: z.string().datetime(), allocations: z.array(PoolAllocationSchema).default([]), }); diff --git a/src/lib/tokenHealthCheck.ts b/src/lib/tokenHealthCheck.ts index eba2a94b27fc..4175ca6e65f7 100644 --- a/src/lib/tokenHealthCheck.ts +++ b/src/lib/tokenHealthCheck.ts @@ -1,4 +1,3 @@ -// @ts-nocheck /** * Proactive Token Health Check Scheduler * @@ -120,6 +119,29 @@ function getExpiredRetryAt(conn: any): string | null { return conn?.providerSpecificData?.expiredRetry?.at ?? conn?.expiredRetryAt ?? null; } +interface RefreshCircuit { + streak: number; + // Absent on a circuit written by an older version, and absent in the failure + // path until the backoff window is computed from the streak. + until?: string; + lastFailAt?: string; +} + +// `providerSpecificData` is an untyped bag, so anything read out of it starts as +// unknown. Checking the two fields the backoff maths needs keeps a malformed +// circuit from turning into NaN inside the scheduler. +function getRefreshCircuit(psd: Record): RefreshCircuit | null { + const raw = psd.refreshCircuit; + if (typeof raw !== "object" || raw === null) return null; + const circuit = raw as Partial; + if (typeof circuit.streak !== "number") return null; + return { + streak: circuit.streak, + until: typeof circuit.until === "string" ? circuit.until : undefined, + lastFailAt: circuit.lastFailAt, + }; +} + function getPsd(conn: any): Record { const psd = conn?.providerSpecificData; return typeof psd === "object" && psd !== null ? psd : {}; @@ -200,7 +222,7 @@ export function buildRefreshFailureUpdate( // exponential backoff window so the next sweep skips this connection instead // of retrying every 60s. Cleared by a successful refresh (clearRefreshCircuit). const psd = getPsd(conn); - const prevStreak = psd.refreshCircuit?.streak ?? 0; + const prevStreak = getRefreshCircuit(psd)?.streak ?? 0; const streak = prevStreak + 1; const updatedPsd = { @@ -244,7 +266,7 @@ export function buildTransientRefreshRetryUpdate(conn: any, now: string) { // Preserve existing streak from any prior permanent failures so a transient // error does not reset the exponential backoff ladder. const psd = getPsd(conn); - const existingCircuit = psd.refreshCircuit; + const existingCircuit = getRefreshCircuit(psd); const existingStreak = existingCircuit?.streak ?? 0; const parsedExistingUntil = existingCircuit?.until ? new Date(existingCircuit.until).getTime() @@ -417,7 +439,12 @@ export function clearHealthCheckLogCache() { declare global { var __omnirouteTokenHC: - | { initialized: boolean; interval: ReturnType | null; sweeping: boolean } + | { + initialized: boolean; + interval: ReturnType | null; + initTimeout: ReturnType | null; + sweeping: boolean; + } | undefined; } function getHCState() { @@ -546,7 +573,8 @@ async function getRefreshRecoveryUpdate(connectionId: string) { // Refresh proves credentials work, not that inference quota has recovered. Read // after the network call: an inference request may have set a cooldown meanwhile. const current = await getProviderConnectionById(connectionId); - if (current?.rateLimitedUntil && Date.parse(current.rateLimitedUntil) > Date.now()) { + const rateLimitedUntil = current?.rateLimitedUntil; + if (typeof rateLimitedUntil === "string" && Date.parse(rateLimitedUntil) > Date.now()) { return {}; } return { @@ -559,7 +587,7 @@ async function getRefreshRecoveryUpdate(connectionId: string) { }; } -export async function checkConnection(conn) { +export async function checkConnection(conn: any) { if (!conn?.id) return; const latestConnection = (await getCachedProviderConnectionById(conn.id)) || conn; @@ -576,7 +604,7 @@ export async function checkConnection(conn) { const intervalMin = conn.healthCheckInterval ?? DEFAULT_HEALTH_CHECK_INTERVAL_MIN; if (intervalMin <= 0) return; if (tokenRefresh.isPassiveCodex(conn.provider)) { - await tokenRefresh.getAccessToken(conn.provider, { ...conn, connectionId: conn.id }); + await tokenRefresh.getAccessToken(conn.provider, { ...conn, connectionId: conn.id }, null); return; } if (!conn.isActive) { @@ -769,7 +797,12 @@ export async function checkConnection(conn) { }); return; } - if (copilotResult?.token && copilotAboutToExpire) { + if ( + copilotResult && + "token" in copilotResult && + copilotResult.token && + copilotAboutToExpire + ) { refreshedProviderSpecificData = { ...providerSpecificData, copilotToken: copilotResult.token, @@ -976,6 +1009,8 @@ export async function checkConnection(conn) { expiresAt?: string; expiresIn?: number; providerSpecificData?: Record; + error?: string; + code?: string; }; type ConnectionUpdate = Parameters[1]; @@ -1105,6 +1140,9 @@ export async function checkConnection(conn) { // Once used, the old token is permanently invalidated. // Retrying will never succeed → deactivate and stop the loop. if (tokenRefresh.isUnrecoverableRefreshError(result)) { + // The core gate compiles with strict null checks; the guard above already + // established that result carries a failure, so name it once. + const failure = result as { error?: string; code?: string }; const currentConnection = await getProviderConnectionById(conn.id); const credentialsChangedSinceSweep = !!currentConnection && @@ -1128,21 +1166,21 @@ export async function checkConnection(conn) { await updateProviderConnection(conn.id, { lastHealthCheckAt: now, testStatus: "active", - lastError: `Health check refresh failed (${result.error}). Re-authenticate before the current access token expires.`, + lastError: `Health check refresh failed (${failure.error}). Re-authenticate before the current access token expires.`, lastErrorAt: now, - lastErrorType: result.error, + lastErrorType: failure.error, lastErrorSource: "oauth", - errorCode: result.error, + errorCode: failure.error, }); logWarn( - `${LOG_PREFIX} ! ${conn.provider}/${getConnectionLogLabel(conn)} refresh token is invalid (${result.error}), but the current access token is still valid; keeping connection active` + `${LOG_PREFIX} ! ${conn.provider}/${getConnectionLogLabel(conn)} refresh token is invalid (${failure.error}), but the current access token is still valid; keeping connection active` ); return; } const expiredRetryCount = getExpiredRetryCount(conn) + 1; const isRetryBudgetExhausted = expiredRetryCount >= EXPIRED_RETRY_MAX; - const errorLabel = result.code || result.error; + const errorLabel = failure.code || failure.error; const psd = getPsd(conn); await updateProviderConnection(conn.id, { @@ -1152,7 +1190,7 @@ export async function checkConnection(conn) { ? `Refresh token consumed (${errorLabel}). Please re-authenticate this account.` : `Refresh token rejected (${errorLabel}). Please re-authenticate this account.`, lastErrorAt: now, - lastErrorType: result.error, + lastErrorType: failure.error, lastErrorSource: "oauth", errorCode: errorLabel, providerSpecificData: withExpiredRetry(psd, expiredRetryCount, now), diff --git a/src/lib/tokenHealthCheckCopilot.ts b/src/lib/tokenHealthCheckCopilot.ts index 70145349ac19..20cacedb9230 100644 --- a/src/lib/tokenHealthCheckCopilot.ts +++ b/src/lib/tokenHealthCheckCopilot.ts @@ -1,4 +1,3 @@ -// @ts-nocheck /** * GitHub Copilot sub-token refresh helper for the proactive token health check. * diff --git a/src/lib/usage/migrations.ts b/src/lib/usage/migrations.ts index 4b8d2b6debc6..c772c9f53bf1 100644 --- a/src/lib/usage/migrations.ts +++ b/src/lib/usage/migrations.ts @@ -461,6 +461,7 @@ export function migrateUsageJsonToSqlite() { out: log.tokens?.out ?? 0, cacheRead: null, cacheWrite: null, + compressed: 0, reasoning: null, }, requestType: log.requestType || null, diff --git a/src/lib/usage/providerDisplayNames.ts b/src/lib/usage/providerDisplayNames.ts index c5e66ea45180..6c9c1816da96 100644 --- a/src/lib/usage/providerDisplayNames.ts +++ b/src/lib/usage/providerDisplayNames.ts @@ -28,9 +28,7 @@ function getProviderDisplayName( const rawProvider = toStringValue(provider, "unknown"); // Configured node name wins; static catalog covers built-ins (e.g. codex → // "OpenAI Codex") the nodes table doesn't know about; raw id is the last resort. - return ( - providerDisplayNames.get(rawProvider) || getProviderById(rawProvider)?.name || rawProvider - ); + return providerDisplayNames.get(rawProvider) || getProviderById(rawProvider)?.name || rawProvider; } async function getProviderDisplayNames(): Promise> { @@ -68,21 +66,26 @@ export interface ByProviderRow { * id to its configured display name. */ export async function buildByProviderRows( - providerRows: Array>, + // Concrete row interfaces have no index signature, so they are not assignable to + // Record; widen once here instead of casting at every call site. + providerRows: object[], providerCostByProvider: Map ): Promise { const providerDisplayNames = await getProviderDisplayNames(); - return providerRows.map((row) => ({ - provider: getProviderDisplayName(row.provider, providerDisplayNames), - requests: Number(row.requests), - promptTokens: Number(row.promptTokens), - completionTokens: Number(row.completionTokens), - totalTokens: Number(row.totalTokens), - avgLatencyMs: Math.round(Number(row.avgLatencyMs)), - successRatePct: - Number(row.requests) > 0 - ? Number((Number(row.successfulRequests) / Number(row.requests)) * 100).toFixed(2) - : 0, - cost: roundCost(providerCostByProvider.get(toStringValue(row.provider)) || 0), - })); + return providerRows.map((raw) => { + const row = raw as Record; + return { + provider: getProviderDisplayName(row.provider, providerDisplayNames), + requests: Number(row.requests), + promptTokens: Number(row.promptTokens), + completionTokens: Number(row.completionTokens), + totalTokens: Number(row.totalTokens), + avgLatencyMs: Math.round(Number(row.avgLatencyMs)), + successRatePct: + Number(row.requests) > 0 + ? Number((Number(row.successfulRequests) / Number(row.requests)) * 100).toFixed(2) + : 0, + cost: roundCost(providerCostByProvider.get(toStringValue(row.provider)) || 0), + }; + }); } diff --git a/src/lib/vscode/modelPresentation.ts b/src/lib/vscode/modelPresentation.ts index 980f8722c9e1..3503f8f4175a 100644 --- a/src/lib/vscode/modelPresentation.ts +++ b/src/lib/vscode/modelPresentation.ts @@ -95,7 +95,7 @@ export function resolveVscodeModelMetadata(model: VscodeCatalogModel) { const normalizedModelId = resolveFamilyFirstPublishedModelId(rawModelId); const parsedTierModel = parseVscodeServiceTierVariantModelId(normalizedModelId); const canonicalBaseModelId = getReasoningVariantBaseModelId(parsedTierModel.baseModelId); - const parsed = parseModel(canonicalBaseModelId, ""); + const parsed = parseModel(canonicalBaseModelId); const provider = parsed.provider || model.owned_by || undefined; const providerModel = parsed.model || diff --git a/src/server/authz/entra/evaluate.ts b/src/server/authz/entra/evaluate.ts index a13696afb7f7..eb5bd87ea951 100644 --- a/src/server/authz/entra/evaluate.ts +++ b/src/server/authz/entra/evaluate.ts @@ -108,10 +108,18 @@ export async function evaluateSsoAuth(token: string): Promise { } const verified = await verifyEntraToken(token, config); - if (!verified.ok) return verified; + // `strict` is off in this tsconfig, so discriminating the union on `ok` does not + // narrow; the two failure shapes are identical, so state the read explicitly. + if (!verified.ok) { + const failure = verified as { status: number; code: string; message: string }; + return { ok: false, status: failure.status, code: failure.code, message: failure.message }; + } const groups = await resolveKeyGroups(verified.identity, config); - if (!groups.ok) return groups; + if (!groups.ok) { + const failure = groups as { status: number; code: string; message: string }; + return { ok: false, status: failure.status, code: failure.code, message: failure.message }; + } let secret: string | null; try { diff --git a/src/server/authz/policies/clientApi.ts b/src/server/authz/policies/clientApi.ts index 379e664738c7..f131d38f31d5 100644 --- a/src/server/authz/policies/clientApi.ts +++ b/src/server/authz/policies/clientApi.ts @@ -66,7 +66,8 @@ export const clientApiPolicy: RoutePolicy = { if (looksLikeEntraJwt(primary) && (await isEntraSsoEnabled())) { const verdict = await evaluateSsoAuth(primary); if (!verdict.ok) { - return reject(verdict.status, verdict.code, verdict.message); + const failure = verdict as { status: number; code: string; message: string }; + return reject(failure.status, failure.code, failure.message); } return allow({ kind: "sso_user", diff --git a/src/shared/components/ProxyConfigModal.tsx b/src/shared/components/ProxyConfigModal.tsx index edcae3832d30..4863656a621e 100644 --- a/src/shared/components/ProxyConfigModal.tsx +++ b/src/shared/components/ProxyConfigModal.tsx @@ -33,7 +33,7 @@ export function buildProxyTypes(socks5Enabled: boolean) { : ALL_PROXY_TYPES.filter((type) => type.value !== "socks5"); } -type ProxyConfigLevel = "global" | "provider" | "combo" | "key"; +export type ProxyConfigLevel = "global" | "provider" | "combo" | "key"; type ProxyRegistryItem = { id: string; diff --git a/src/shared/constants/cliTools.ts b/src/shared/constants/cliTools.ts index aac8f0086a36..0f1b107d8479 100644 --- a/src/shared/constants/cliTools.ts +++ b/src/shared/constants/cliTools.ts @@ -741,7 +741,6 @@ openclaude --provider openai settingsFile: "~/.pi/agent/models.json", // Hand-edit fallback shown by the guided card's manual-configuration block. codeBlock: { - title: "~/.pi/agent/models.json", language: "json", code: [ "{", diff --git a/src/shared/schemas/cliCatalog.ts b/src/shared/schemas/cliCatalog.ts index d68b76d97f8b..13e6ad3b6e24 100644 --- a/src/shared/schemas/cliCatalog.ts +++ b/src/shared/schemas/cliCatalog.ts @@ -16,7 +16,7 @@ export const CliCatalogEntrySchema = z.object({ description: z.string().min(1), docsUrl: z.string().min(1), configType: z.enum(["env", "custom", "guide", "custom-builder", "mitm"]), - envVars: z.record(z.string()).optional(), + envVars: z.record(z.string(), z.string()).optional(), modelAliases: z.array(z.string()).optional(), settingsFile: z.string().optional(), defaultCommand: z.string().optional(), @@ -59,7 +59,7 @@ export const CliCatalogEntrySchema = z.object({ export type CliCatalogEntry = z.infer; -export const CliCatalogSchema = z.record(CliCatalogEntrySchema); +export const CliCatalogSchema = z.record(z.string(), CliCatalogEntrySchema); /** Cardinalidade obrigatória (Plano §3.1/§3.2 + D15). +1 (crush, decolua/9router#1233). */ // +1 (2026-07-02): "codewhale" added as a dual entry alongside "deepseek-tui" diff --git a/src/store/notificationStore.ts b/src/store/notificationStore.ts index 5e436fe38f85..d44669b727ce 100644 --- a/src/store/notificationStore.ts +++ b/src/store/notificationStore.ts @@ -26,7 +26,7 @@ interface Notification { onClick?: () => void; } -interface NotificationStore { +export interface NotificationStore { notifications: Notification[]; addNotification: (notification: { type?: NotificationType; diff --git a/src/types/global.d.ts b/src/types/global.d.ts index d025f1edb951..8a63a5ab53ce 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -114,7 +114,9 @@ declare module "yazl" { export class ZipFile { addFile(realPath: string, metadataPath: string): void; addBuffer(buffer: Buffer, metadataPath: string): void; + addEmptyDirectory(metadataPath: string, options?: Record): void; end(options?: Record, callback?: () => void): void; + on(event: string, listener: (...args: unknown[]) => void): this; outputStream: NodeJS.ReadableStream; } } diff --git a/tests/unit/14089-batches-route-error-sanitization.test.ts b/tests/unit/14089-batches-route-error-sanitization.test.ts index d7f7346704cd..733e05d3bc7a 100644 --- a/tests/unit/14089-batches-route-error-sanitization.test.ts +++ b/tests/unit/14089-batches-route-error-sanitization.test.ts @@ -52,8 +52,8 @@ function installThrowingInsertAdapter(): SqliteAdapter { const real = core.getDbInstance() as SqliteAdapter; const wrapper: SqliteAdapter = { ...real, - prepare(sql: string) { - const stmt = real.prepare(sql); + prepare(sql: string) { + const stmt = real.prepare(sql); if (/\bINSERT\s+INTO\s+batches\b/i.test(sql)) { return { run() { @@ -134,8 +134,8 @@ describe("#14089 POST /api/v1/batches error sanitization", () => { const real = core.getDbInstance() as SqliteAdapter; const wrapper: SqliteAdapter = { ...real, - prepare(sql: string) { - const stmt = real.prepare(sql); + prepare(sql: string) { + const stmt = real.prepare(sql); if (/\bINSERT\s+INTO\s+batches\b/i.test(sql)) { return { run() { diff --git a/tests/unit/18603-cursor-poll-cloud-sync-machine-id.test.ts b/tests/unit/18603-cursor-poll-cloud-sync-machine-id.test.ts new file mode 100644 index 000000000000..790850ed1e4b --- /dev/null +++ b/tests/unit/18603-cursor-poll-cloud-sync-machine-id.test.ts @@ -0,0 +1,85 @@ +/** + * #18603 — the Cursor login poll route must hand the machine id to syncToCloud(). + * + * The route used to call syncToCloud() bare, so the cloud sync went out to + * `${CLOUD_URL}/sync/undefined` and failed silently (the helper swallows errors). + * mock.module() is not usable under this tsx/ESM runner, so the test drives the real + * route with a stubbed global fetch and inspects the URL of the cloud sync request. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omni-cursor-poll-sync-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = "cursor-poll-sync-api-key-secret"; +process.env.JWT_SECRET = "cursor-poll-sync-jwt-secret"; +// cloudSync.ts reads CLOUD_URL once at import time, so set it before the imports below. +process.env.CLOUD_URL = "https://cloud.example.test"; + +const core = await import("../../src/lib/db/core.ts"); +const { updateSettings } = await import("../../src/lib/db/settings.ts"); +const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); +const { getConsistentMachineId } = await import("../../src/shared/utils/machineId.ts"); +const cursorLogin = await import("../../src/lib/oauth/services/cursorLogin.ts"); +const cursorPollRoute = await import("../../src/app/api/oauth/cursor/login/poll/route.ts"); + +const originalFetch = globalThis.fetch; +let syncUrls: string[] = []; + +await updateSettings({ + requireLogin: true, + password: "configured-password-hash", + cloudEnabled: true, +}); +const manageKey = await apiKeysDb.createApiKey("manage", "machine1234567890", ["manage"]); + +test.beforeEach(() => { + syncUrls = []; + cursorLogin.clearCursorLoginSessions(); + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input instanceof Request ? input.url : input); + if (url.startsWith("https://api2.cursor.sh/auth/poll")) { + return Response.json({ accessToken: "cursor-access-token", refreshToken: "cursor-refresh" }); + } + if (url.startsWith("https://cloud.example.test/sync/")) { + syncUrls.push(url); + return new Response("{}", { status: 500 }); + } + return new Response("{}", { status: 404 }); + }) as typeof fetch; +}); + +test.afterEach(() => { + globalThis.fetch = originalFetch; +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +test("a successful Cursor login syncs to the cloud under the real machine id", async () => { + const { sessionId } = cursorLogin.createCursorLoginSession({ + verifier: "verifier", + challenge: "challenge", + uuid: "uuid-18603", + loginUrl: "https://cursor.com/loginDeepControl", + }); + + const response = await cursorPollRoute.POST( + new Request("http://omniroute.example/api/oauth/cursor/login/poll", { + method: "POST", + headers: { authorization: `Bearer ${manageKey.key}`, "content-type": "application/json" }, + body: JSON.stringify({ sessionId }), + }) + ); + assert.equal(response.status, 200); + + const machineId = await getConsistentMachineId(); + assert.ok(machineId && machineId !== "undefined"); + assert.deepEqual(syncUrls, [`https://cloud.example.test/sync/${machineId}`]); +}); diff --git a/tests/unit/18603-inner-ai-models-outbound-guard.test.ts b/tests/unit/18603-inner-ai-models-outbound-guard.test.ts new file mode 100644 index 000000000000..46dfe91ace02 --- /dev/null +++ b/tests/unit/18603-inner-ai-models-outbound-guard.test.ts @@ -0,0 +1,41 @@ +/** + * #18603 — the Inner.ai model discovery request must carry the provider outbound guard. + * + * safeOutboundFetch(url, options) reads the guard from `options.guard`. The route used + * to pass getProviderOutboundGuard() as a third positional argument, which the function + * ignores, so this request ran under the default guard "none" and skipped the SSRF + * policy every sibling discovery call applies. + * + * The Inner.ai host is a fixed public URL, so no guard mode changes the request's + * outcome and the behaviour cannot be observed through a stubbed fetch. The regression + * is therefore pinned on the call shape in the route source. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; + +const ROUTE = path.resolve(import.meta.dirname, "../../src/app/api/providers/[id]/models/route.ts"); + +function innerAiFetchCall(source: string): string { + const start = source.indexOf('"https://platformapi.innerai.com/api/v1/ai_models"'); + assert.notEqual(start, -1, "Inner.ai models URL not found in the route"); + const callStart = source.lastIndexOf("safeOutboundFetch(", start); + assert.notEqual(callStart, -1, "safeOutboundFetch call for Inner.ai not found"); + const end = source.indexOf("if (!modelsResp.ok)", start); + return source.slice(callStart, end); +} + +test("Inner.ai models fetch passes the outbound guard inside the options object", () => { + const call = innerAiFetchCall(fs.readFileSync(ROUTE, "utf8")); + assert.match( + call, + /\{\s*headers:\s*innerAiHeaders,\s*guard:\s*getProviderOutboundGuard\(\)\s*\}/ + ); +}); + +test("Inner.ai models fetch has no positional third argument", () => { + const call = innerAiFetchCall(fs.readFileSync(ROUTE, "utf8")); + assert.doesNotMatch(call, /\},\s*getProviderOutboundGuard\(/); +}); diff --git a/tests/unit/antigravity-family-cooldown-reader.test.ts b/tests/unit/antigravity-family-cooldown-reader.test.ts index 16c97f9e7262..c03cf2b0796e 100644 --- a/tests/unit/antigravity-family-cooldown-reader.test.ts +++ b/tests/unit/antigravity-family-cooldown-reader.test.ts @@ -83,6 +83,43 @@ test("saved family reader preserves the lock sets and deadlines of hydration", ( } }); +// argon is a real bucket (gemini-4* / gemini_4_argon_weekly), and +// persistAntigravityFamilyCooldown writes it, so the reader has to return it. +// Before the parameter type was widened this case could not even be expressed. +test("saved family reader returns an argon deadline without leaking it to other families", (t) => { + const now = Date.parse("2099-01-01T00:00:00Z"); + t.mock.method(Date, "now", () => now); + const until = now + 600_000; + + assert.equal( + getAntigravityFamilyCooldownUntil( + { antigravityFamilyRateLimitedUntil: { argon: until } }, + "argon", + now + ), + until, + "a stored argon deadline must be readable" + ); + assert.equal( + getAntigravityFamilyCooldownUntil( + { antigravityFamilyRateLimitedUntil: { argon: until } }, + "gemini", + now + ), + null, + "an argon deadline must not answer for gemini" + ); + assert.equal( + getAntigravityFamilyCooldownUntil( + { antigravityFamilyRateLimitedUntil: { gemini: until } }, + "argon", + now + ), + null, + "a gemini deadline must not answer for argon" + ); +}); + for (const provider of ["agy", "antigravity"]) { test(`${provider} restores only the saved Argon family for both aliases`, (t) => { const now = Date.parse("2099-01-01T00:00:00Z"); diff --git a/tests/unit/bedrock-client-contract.test.ts b/tests/unit/bedrock-client-contract.test.ts new file mode 100644 index 000000000000..41cdbe6a4c3b --- /dev/null +++ b/tests/unit/bedrock-client-contract.test.ts @@ -0,0 +1,72 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import ts from "typescript"; + +test("Bedrock client contract accepts the SDK and concrete injected clients", (t) => { + const root = fileURLToPath(new URL("../../", import.meta.url)); + const source = ts.createSourceFile( + "bedrock.ts", + readFileSync(path.join(root, "open-sse/executors/bedrock.ts"), "utf8"), + ts.ScriptTarget.Latest, + true + ); + // Check the production boundary without importing the executor's runtime graph + // or issuing provider requests. An arbitrary send promise rejects the + // concrete outputs used by executor tests; also keep the SDK assignable. + const contracts = source.statements.filter( + (node) => ts.isInterfaceDeclaration(node) && node.name.text.startsWith("BedrockClient") + ); + assert.ok( + contracts.some( + (node) => ts.isInterfaceDeclaration(node) && node.name.text === "BedrockClientLike" + ) + ); + const printer = ts.createPrinter(); + const temporary = mkdtempSync(path.join(os.tmpdir(), "bedrock-client-contract-")); + t.after(() => rmSync(temporary, { recursive: true, force: true })); + const fixture = path.join(temporary, "contract.ts"); + writeFileSync( + fixture, + `import { BedrockRuntimeClient, ConverseCommand, ConverseStreamCommand } from "@aws-sdk/client-bedrock-runtime"; +${contracts.map((node) => printer.printNode(ts.EmitHint.Unspecified, node, source)).join("\n")} +function createClient(): BedrockClientLike { return new BedrockRuntimeClient({}); } +const stopReason: string = "end_turn"; +const injected: BedrockClientLike = { + send: async () => ({ output: { message: { content: [{ text: "hello" }] } }, stopReason }) +}; +const streaming: BedrockClientLike = { + send: async () => ({ stream: (async function* () { yield { messageStop: { stopReason } }; })() }) +}; +void [createClient, injected, streaming]; +` + ); + const config = ts.readConfigFile(path.join(root, "tsconfig.json"), ts.sys.readFile); + assert.equal(config.error, undefined); + const converted = ts.convertCompilerOptionsFromJson(config.config.compilerOptions, root); + assert.equal(converted.errors.length, 0); + const program = ts.createProgram([fixture], { + ...converted.options, + incremental: false, + noEmit: true, + types: [], + paths: { + "@aws-sdk/client-bedrock-runtime": [ + path.join(root, "node_modules/@aws-sdk/client-bedrock-runtime"), + ], + }, + }); + const diagnostics = ts.getPreEmitDiagnostics(program); + assert.equal( + diagnostics.length, + 0, + ts.formatDiagnosticsWithColorAndContext(diagnostics, { + getCanonicalFileName: (name) => name, + getCurrentDirectory: () => root, + getNewLine: () => "\n", + }) + ); +}); diff --git a/tests/unit/build/check-build-typecheck.test.ts b/tests/unit/build/check-build-typecheck.test.ts new file mode 100644 index 000000000000..fa6aed13bbe6 --- /dev/null +++ b/tests/unit/build/check-build-typecheck.test.ts @@ -0,0 +1,285 @@ +// tests/unit/build/check-build-typecheck.test.ts +// Unit tests for the decision logic in check-build-typecheck.mjs. +// +// What is unique to this gate is the pair of guards that stop it being satisfied +// without fixing anything: `--update` refusing to bank a regression, and the +// @ts-nocheck scan that makes an opted-out file visible at all (such a file +// reports no diagnostics, so nothing else here can see it). +// +// `noCheckOffset` is pinned against the positions where tsc's rule and the +// obvious implementations disagree. Each of these was a real miss at some point: +// a fixed read window, a shebang, and a comment that merely mentions the pragma. + +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + countLineSuppressions, + fingerprintDrift, + noCheckOffset, + scanNoCheckFiles, + suppressionDrift, + suppressedIntersection, + updateRefusal, +} from "../../../scripts/check/ratchet.mjs"; + +const regression = { + file: "src/lib/example.ts", + code: "TS2322", + liveCount: 1, + baselineCount: 0, +}; + +function withTempTree(files: Record, run: (root: string) => void) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nocheck-")); + try { + for (const [rel, content] of Object.entries(files)) { + const full = path.join(root, rel); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, content); + } + run(root); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +} + +test("updateRefusal: allows the rewrite when nothing regressed", () => { + assert.equal(updateRefusal([], { force: false }), null); +}); + +test("updateRefusal: blocks the rewrite when a new error appeared", () => { + const reason = updateRefusal([regression], { force: false }); + assert.notEqual(reason, null); + assert.match(reason, /1 pair/); +}); + +test("updateRefusal: counts every regressed pair, not just the first", () => { + const reason = updateRefusal([regression, { ...regression, code: "TS2339" }], { force: false }); + assert.notEqual(reason, null); + assert.match(reason, /2 pair/); +}); + +test("updateRefusal: --force is the only way to freeze new debt", () => { + assert.equal(updateRefusal([regression], { force: true }), null); +}); + +test("updateRefusal: defaults to refusing (force is opt-in, not implicit)", () => { + assert.notEqual(updateRefusal([regression]), null); +}); + +test("scanNoCheckFiles: finds files that silence the whole file", () => { + withTempTree( + { + "src/opted-out.ts": "// @ts-nocheck\nexport {};\n", + "src/deep/also-out.ts": "\n// @ts-nocheck \nexport {};\n", + "src/clean.ts": "export {};\n", + }, + (root) => { + const found = scanNoCheckFiles(["src/opted-out.ts", "src/deep/also-out.ts", "src/clean.ts"], root); + assert.deepEqual(found, ["src/deep/also-out.ts", "src/opted-out.ts"]); + } + ); +}); + +test("scanNoCheckFiles: catches the directive behind a long header", () => { + const banner = Array.from({ length: 60 }, (_, i) => `// line ${i} of the header`).join("\n"); + withTempTree({ "src/hidden.ts": `${banner}\n// @ts-nocheck\nexport {};\n` }, (root) => { + assert.deepEqual(scanNoCheckFiles(["src/hidden.ts"], root), ["src/hidden.ts"]); + }); +}); + +test("scanNoCheckFiles: skips a file it cannot read rather than failing the run", () => { + withTempTree({ "src/present.ts": "export {};\n" }, (root) => { + assert.deepEqual(scanNoCheckFiles(["src/missing.ts", "src/present.ts"], root), []); + }); +}); + +// --- noCheckOffset against tsc's own rule --------------------------------- +// Each case below was, at some point, a way for a real directive to go unseen. +// Keep them as a table: these are one-character differences. + +const HONOURED = [ + ["// @ts-nocheck\nexport {};\n", "leading trivia"], + ["// @ts-nocheck\r\nexport {};\n", "CRLF line ending"], + ["// @ts-nocheck: because the SDK is untyped\nexport {};\n", "colon separator"], + ["//@ts-nocheck\nexport {};\n", "no space after the slashes"], + ["/// @ts-nocheck\nexport {};\n", "triple slash"], + ["#!/usr/bin/env node\n// @ts-nocheck\nexport {};\n", "after a shebang"], + ["/* (c) Someone */ // @ts-nocheck\nexport {};\n", "after a block comment"], + [ + Array.from({ length: 60 }, (_, i) => `// header line ${i}`).join("\n") + + "\n// @ts-nocheck\nexport {};\n", + "past a sixty-line licence banner", + ], +]; + +for (const [source, label] of HONOURED) { + test(`noCheckOffset: honoured — ${label}`, () => { + assert.notEqual(noCheckOffset(source), -1); + }); +} + +const IGNORED = [ + ["// @ts-nocheckX\nexport {};\n", "a letter continues the pragma word"], + ["// @ts-nocheck-ish\nexport {};\n", "a hyphen continues the pragma word"], + ["// Do not add @ts-nocheck here.\nexport {};\n", "prose only"], + ["/*\n@ts-nocheck\n*/\nexport {};\n", "inside a block comment"], + ["export const x = 1;\n// @ts-nocheck\n", "after the first statement"], +]; + +for (const [source, label] of IGNORED) { + test(`noCheckOffset: ignored — ${label}`, () => { + assert.equal(noCheckOffset(source), -1); + }); +} + +test("noCheckOffset: directive below a comment that merely mentions the pragma", () => { + // The first occurrence is prose, the second is the directive. Judging only the + // first match reports this file as checked while tsc ignores it entirely. + const source = + "// Do not ever add @ts-nocheck to this file.\n" + + "// @ts-nocheck\n" + + "export {};\n"; + assert.notEqual(noCheckOffset(source), -1); +}); + +// --- suppressedIntersection ------------------------------------------------- + +test("suppressedIntersection: reports files that are both counted and opted out", () => { + assert.deepEqual(suppressedIntersection(["src/a.ts", "src/b.ts"], ["src/b.ts", "src/c.ts"]), [ + "src/b.ts", + ]); +}); + +test("suppressedIntersection: must be asked about the baseline side, not the live one", () => { + // An opted-out file reports no diagnostics, so it disappears from the live run. + // Intersecting against the live side is empty by construction. + const baselineFiles = ["bin/cli/tray/tray.ts"]; + const liveFiles: string[] = []; + const noCheck = ["bin/cli/tray/tray.ts"]; + + assert.deepEqual(suppressedIntersection(baselineFiles, noCheck), ["bin/cli/tray/tray.ts"]); + assert.deepEqual(suppressedIntersection(liveFiles, noCheck), []); +}); + +// --- line-level suppressions ------------------------------------------------ +// @ts-ignore and @ts-expect-error are per-line, so they are counted rather than +// frozen by name. One added comment line removes a diagnostic; if that is not +// recorded, the gate reads the removal as progress. + +test("countLineSuppressions: counts both pragmas, per file", () => { + withTempTree( + { + "src/a.ts": "// @ts-ignore\nconst x = 1;\n// @ts-expect-error\nconst y = 2;\n", + "src/b.ts": "// @ts-expect-error: upstream type bug\nconst z = 3;\n", + "src/clean.ts": "const w = 4;\n", + }, + (root) => { + assert.deepEqual(countLineSuppressions(["src/a.ts", "src/b.ts", "src/clean.ts"], root), { + "src/a.ts": 2, + "src/b.ts": 1, + }); + } + ); +}); + +test("countLineSuppressions: counts every shape tsc honours", () => { + // tsc accepts the pragma as a leading comment, trailing after code, and inside + // both block-comment forms. Missing any of them is a one-line bypass. + withTempTree( + { + "src/leading.ts": "// @ts-ignore\nconst a = 1;\n", + "src/trailing.ts": "const b = 1; // @ts-ignore\n", + "src/block.ts": "/* @ts-expect-error */\nconst c = 1;\n", + "src/jsdoc.ts": "/** @ts-ignore */\nconst d = 1;\n", + "src/none.ts": "const e = 1;\n", + }, + (root) => { + const files = [ + "src/leading.ts", + "src/trailing.ts", + "src/block.ts", + "src/jsdoc.ts", + "src/none.ts", + ]; + assert.deepEqual(countLineSuppressions(files, root), { + "src/leading.ts": 1, + "src/trailing.ts": 1, + "src/block.ts": 1, + "src/jsdoc.ts": 1, + }); + } + ); +}); + +test("countLineSuppressions: handles CRLF files", () => { + withTempTree({ "src/crlf.ts": "// @ts-ignore\r\nconst x = 1;\r\n" }, (root) => { + assert.deepEqual(countLineSuppressions(["src/crlf.ts"], root), { "src/crlf.ts": 1 }); + }); +}); + +test("suppressionDrift: a newly added suppression is a regression", () => { + const drift = suppressionDrift({ "src/a.ts": 1 }, { "src/a.ts": 2, "src/b.ts": 1 }); + assert.deepEqual(drift, [ + { file: "src/a.ts", was: 1, live: 2 }, + { file: "src/b.ts", was: 0, live: 1 }, + ]); +}); + +test("suppressionDrift: removing a suppression is not a regression", () => { + assert.deepEqual(suppressionDrift({ "src/a.ts": 3 }, { "src/a.ts": 1 }), []); +}); + +// --- fingerprintDrift ------------------------------------------------------- +// A program that shrank produces fewer diagnostics without anything being fixed, +// so its shape is frozen too. + +const fingerprint = { + files: 6047, + include: ["src/**/*.ts"], + exclude: ["node_modules"], + compilerOptions: { strict: false, skipLibCheck: true }, +}; + +test("fingerprintDrift: an unchanged program reports nothing", () => { + assert.deepEqual(fingerprintDrift(fingerprint, { ...fingerprint }), []); +}); + +test("fingerprintDrift: adding or deleting a source file is not drift", () => { + // Ordinary work changes the count; failing on it would make the gate something + // people route around. Shrinking the *program* (exclude/include/options) is the + // signal that matters. + assert.deepEqual(fingerprintDrift(fingerprint, { ...fingerprint, files: 6100 }), []); + assert.deepEqual(fingerprintDrift(fingerprint, { ...fingerprint, files: 6000 }), []); +}); + +test("fingerprintDrift: a relaxed compiler option is reported", () => { + const drift = fingerprintDrift(fingerprint, { + ...fingerprint, + compilerOptions: { ...fingerprint.compilerOptions, noUncheckedIndexedAccess: false }, + }); + assert.ok(drift.some((d) => d.startsWith("compilerOptions"))); +}); + +test("fingerprintDrift: a changed include list is reported", () => { + const drift = fingerprintDrift(fingerprint, { + ...fingerprint, + include: ["src/**/*.ts", "electron/**/*.ts"], + }); + assert.ok(drift.some((d) => d.startsWith("include"))); +}); + +test("fingerprintDrift: a changed exclude list is reported", () => { + const drift = fingerprintDrift(fingerprint, { + ...fingerprint, + exclude: [...fingerprint.exclude, "src/broken"], + }); + assert.ok(drift.some((d) => d.startsWith("exclude"))); +}); + +test("fingerprintDrift: no recorded fingerprint means nothing to compare", () => { + assert.deepEqual(fingerprintDrift(null, fingerprint), []); +}); diff --git a/tests/unit/build/typecheck-process-result.test.mjs b/tests/unit/build/typecheck-process-result.test.mjs index 8374ee14e604..4d6ad2133d70 100644 --- a/tests/unit/build/typecheck-process-result.test.mjs +++ b/tests/unit/build/typecheck-process-result.test.mjs @@ -21,8 +21,22 @@ function fixture(t, scope) { writeFileSync(path.join(root, config), "{}\n"); writeFileSync(path.join(root, "package.json"), '{"private":true}\n'); writeFileSync(path.join(root, "node_modules/typescript/package.json"), '{"name":"typescript"}\n'); + // The gate reads a tsconfig through the compiler's own parser, so the boundary the + // fixture controls has to include it. + writeFileSync( + path.join(root, "node_modules/typescript/index.js"), + "module.exports = { readConfigFile: () => ({ config: {} }), sys: { readFile: () => '' }, " + + "flattenDiagnosticMessageText: (m) => String(m) };\n" + ); writeFileSync(baseline, JSON.stringify({ "src/example.ts": { TS2339: 1 } }) + "\n"); const compiler = `#!/usr/bin/env node +// The gate resolves the program before comparing counts. Answer that probe with a +// file list so these fixtures exercise the real path rather than tripping the +// empty-program guard. +if (process.argv.includes("--listFilesOnly")) { + process.stdout.write(process.env.QG_TSC_FILES || process.cwd() + "/src/example.ts\\n"); + process.exit(0); +} process.stdout.write(process.env.QG_TSC_STDOUT || ""); process.stderr.write(process.env.QG_TSC_STDERR || ""); if (process.env.QG_TSC_SIGNAL) process.kill(process.pid, process.env.QG_TSC_SIGNAL); @@ -162,7 +176,11 @@ for (const scope of ["dashboard", "api", "open-sse"]) { const result = project.run({ QG_TSC_EXIT: "0" }, ["--update"]); assert.equal(result.error, undefined); assert.equal(result.status, 0, result.stdout + result.stderr); - assert.deepEqual(JSON.parse(readFileSync(project.baseline, "utf8")), {}); + const written = JSON.parse(readFileSync(project.baseline, "utf8")); + const diagnostics = Object.fromEntries( + Object.entries(written).filter(([key]) => !key.startsWith("_")) + ); + assert.deepEqual(diagnostics, {}); }); } diff --git a/tests/unit/build/windows-cmd-spawn-einval.test.ts b/tests/unit/build/windows-cmd-spawn-einval.test.ts index 8c5a7f4c5799..1d02674dc12b 100644 --- a/tests/unit/build/windows-cmd-spawn-einval.test.ts +++ b/tests/unit/build/windows-cmd-spawn-einval.test.ts @@ -34,6 +34,17 @@ test("typecheck gates execute the local compiler without shims, including specia for (const directory of ["node_modules/typescript/bin", "config/quality", "open-sse"]) { fs.mkdirSync(path.join(root, directory), { recursive: true }); } + // The gate parses the tsconfig through the compiler's own reader, so the + // compiler boundary this fixture controls has to include that module. + fs.writeFileSync( + path.join(root, "node_modules/typescript/package.json"), + '{"name":"typescript"}\n' + ); + fs.writeFileSync( + path.join(root, "node_modules/typescript/index.js"), + "module.exports = { readConfigFile: () => ({ config: {} }), sys: { readFile: () => '' }, " + + "flattenDiagnosticMessageText: (m) => String(m) };\n" + ); fs.writeFileSync( path.join( root, @@ -47,7 +58,10 @@ test("typecheck gates execute the local compiler without shims, including specia ); fs.writeFileSync( path.join(root, "node_modules/typescript/bin/tsc"), - 'process.stdout.write("src/example.ts(1,1): error TS2339: Missing property.\\n"); process.exit(2);\n' + 'if (process.argv.includes("--listFilesOnly")) {' + + ' process.stdout.write("src/example.ts\\n"); process.exit(0);' + + "}" + + 'process.stdout.write("src/example.ts(1,1): error TS2339: Missing property.\\n"); process.exit(2);\n' ); const result = spawnSync(process.execPath, [path.join(ROOT, rel)], { cwd: root, diff --git a/tests/unit/db/stats-dbstat-optional.test.ts b/tests/unit/db/stats-dbstat-optional.test.ts index 7f92c1a06269..cc98637ea626 100644 --- a/tests/unit/db/stats-dbstat-optional.test.ts +++ b/tests/unit/db/stats-dbstat-optional.test.ts @@ -37,7 +37,7 @@ function createFakeDb({ failOnlyOn, nullSize, }: FakeOptions = {}): SqliteAdapter { - const prepare = (sql: string): PreparedStatement => { + const prepare = ((sql: string): PreparedStatement => { const touchesDbstat = /\bdbstat\b/i.test(sql); return { @@ -67,7 +67,7 @@ function createFakeDb({ throw new Error(`unexpected all(): ${sql}`); }, }; - }; + }) as unknown as SqliteAdapter["prepare"]; return { driver: "sql.js", diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 000000000000..f3ff97404747 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,46 @@ +{ + // Production-only type check. `tsc -p tsconfig.json` includes tests/ through + // `**/*.ts` and runs out of memory; this narrows to shipped code and finishes + // in about a minute. + // + // `next build` ignores type errors (next.config.mjs sets ignoreBuildErrors), so + // this program plus `npm run check:build-typecheck` (CI: ci.yml, quality.yml) is + // what actually checks this surface. The gate compares against + // config/quality/build-typecheck-baseline.json: the existing debt is frozen, not + // ignored, and must not grow. + // + // electron/**/*.d.ts is included on purpose. Without it the desktop ambient + // globals are missing and every electronAPI access becomes a phantom diagnostic. + // + // next-env.d.ts is deliberately absent: Next generates it, it is gitignored, and + // including a path that may not exist made the program differ between a fresh + // checkout and one that had run `next build`. + "extends": "./tsconfig.json", + "include": [ + "electron/**/*.d.ts", + "src/**/*.ts", + "src/**/*.tsx", + "open-sse/**/*.ts", + "open-sse/**/*.tsx", + "packages/**/*.ts", + "packages/**/*.tsx", + "bin/**/*.ts", + "bin/**/*.tsx", + "scripts/**/*.ts", + "*.ts" + ], + "exclude": [ + "node_modules", + "tests", + "**/*.test.ts", + "**/*.test.tsx", + "**/*.spec.ts", + "**/__tests__/**", + ".build", + ".next", + ".next-playwright", + ".claude", + ".scratch", + ".source" + ] +}