diff --git a/apps/sim/app/api/copilot/chat/route.ts b/apps/sim/app/api/copilot/chat/route.ts index 1b020709711..2197c862fee 100644 --- a/apps/sim/app/api/copilot/chat/route.ts +++ b/apps/sim/app/api/copilot/chat/route.ts @@ -2,14 +2,15 @@ import type { NextRequest } from 'next/server' import { copilotChatGetContract } from '@/lib/api/contracts/copilot' import { parseRequest } from '@/lib/api/server' import { handleUnifiedChatPost, maxDuration } from '@/lib/copilot/chat/post' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { GET as getChat } from '@/app/api/copilot/chat/queries' export { maxDuration } export const POST = handleUnifiedChatPost -export async function GET(request: NextRequest) { +export const GET = withRouteHandler(async (request: NextRequest) => { const parsed = await parseRequest(copilotChatGetContract, request, {}) if (!parsed.success) return parsed.response return getChat(request) -} +}) diff --git a/apps/sim/lib/copilot/request/go/stream.test.ts b/apps/sim/lib/copilot/request/go/stream.test.ts index 8d08f755d8f..0bae524384a 100644 --- a/apps/sim/lib/copilot/request/go/stream.test.ts +++ b/apps/sim/lib/copilot/request/go/stream.test.ts @@ -12,6 +12,7 @@ import { } from '@/lib/copilot/generated/mothership-stream-v1' vi.mock('@/lib/copilot/request/session', async () => { + // hygiene-suppress: session re-exports real domain functions (eventToStreamEvent, parsePersistedStreamEventEnvelope) used by stream.ts under test — must spread real implementations and override only hasAbortMarker const actual = await vi.importActual( '@/lib/copilot/request/session' ) diff --git a/apps/sim/lib/core/config/redis.test.ts b/apps/sim/lib/core/config/redis.test.ts index 85ccc6c2e1a..6a08f9b7243 100644 --- a/apps/sim/lib/core/config/redis.test.ts +++ b/apps/sim/lib/core/config/redis.test.ts @@ -181,7 +181,9 @@ describe('redis config', () => { }) it('returns true as a no-op when Redis is unavailable', async () => { + // hygiene-suppress: redis module caches a singleton client at import time — must re-evaluate to test the no-Redis path vi.resetModules() + // hygiene-suppress: redis module caches a singleton client at import time — must re-evaluate to test the no-Redis path vi.doMock('@/lib/core/config/env', () => createEnvMock({ REDIS_URL: undefined as unknown as string }) ) diff --git a/apps/sim/lib/core/rate-limiter/route-helpers.test.ts b/apps/sim/lib/core/rate-limiter/route-helpers.test.ts index 0f895e81e1a..7391528d342 100644 --- a/apps/sim/lib/core/rate-limiter/route-helpers.test.ts +++ b/apps/sim/lib/core/rate-limiter/route-helpers.test.ts @@ -12,15 +12,9 @@ const { mockAdapter } = vi.hoisted(() => ({ }, })) -vi.mock('@/lib/core/rate-limiter/storage', async () => { - const actual = await vi.importActual( - '@/lib/core/rate-limiter/storage' - ) - return { - ...actual, - createStorageAdapter: () => mockAdapter, - } -}) +vi.mock('@/lib/core/rate-limiter/storage', () => ({ + createStorageAdapter: () => mockAdapter, +})) function passThroughClientIp() { requestUtilsMockFns.mockGetClientIp.mockImplementation( diff --git a/apps/sim/lib/execution/isolated-vm.test.ts b/apps/sim/lib/execution/isolated-vm.test.ts index f19cebf61c7..43afb51f017 100644 --- a/apps/sim/lib/execution/isolated-vm.test.ts +++ b/apps/sim/lib/execution/isolated-vm.test.ts @@ -221,6 +221,7 @@ async function loadExecutionModule(options: { : null ) + // hygiene-suppress: isolated-vm initializes worker state at module scope — must re-evaluate per test scenario vi.resetModules() const mod = await import('@/lib/execution/isolated-vm') diff --git a/apps/sim/lib/mcp/utils.ts b/apps/sim/lib/mcp/utils.ts index 8684807adf5..1fc231c7c65 100644 --- a/apps/sim/lib/mcp/utils.ts +++ b/apps/sim/lib/mcp/utils.ts @@ -82,9 +82,11 @@ export function createMcpSuccessResponse(data: T, status = 200): NextResponse * Maps MCP orchestration error codes to safe HTTP statuses. */ export function mcpOrchestrationStatus(errorCode: string | undefined): number { + if (errorCode === 'validation') return 400 if (errorCode === 'forbidden') return 403 - if (errorCode === 'bad_gateway') return 502 if (errorCode === 'not_found') return 404 + if (errorCode === 'conflict') return 409 + if (errorCode === 'bad_gateway') return 502 return 500 } diff --git a/apps/sim/providers/attachments.ts b/apps/sim/providers/attachments.ts index 380b4d8c890..20e27dfa283 100644 --- a/apps/sim/providers/attachments.ts +++ b/apps/sim/providers/attachments.ts @@ -303,7 +303,10 @@ export function prepareProviderAttachments( ) } - if (Number.isFinite(file.size) && file.size > AGENT_ATTACHMENT_MAX_BYTES) { + // Base64 encoding inflates size by ~33%; pre-validate against 75% of the limit + // so files that pass here won't be silently stripped during hydration. + const rawSizeLimit = Math.floor(AGENT_ATTACHMENT_MAX_BYTES * 0.75) + if (Number.isFinite(file.size) && file.size > rawSizeLimit) { const sizeMB = (file.size / (1024 * 1024)).toFixed(2) const maxMB = (AGENT_ATTACHMENT_MAX_BYTES / (1024 * 1024)).toFixed(0) throw new Error( diff --git a/biome.json b/biome.json index 3346ec1a477..90392806824 100644 --- a/biome.json +++ b/biome.json @@ -90,7 +90,8 @@ "noControlCharactersInRegex": "off", "noThenProperty": "off", "noAssignInExpressions": "off", - "noDocumentCookie": "off" + "noDocumentCookie": "off", + "noConsole": "error" }, "correctness": { "useExhaustiveDependencies": "off", @@ -160,5 +161,22 @@ "enabled": true, "indentWidth": 2 } - } + }, + "overrides": [ + { + "includes": [ + "scripts/**", + "apps/*/scripts/**", + "**/vitest.setup.ts", + "apps/sim/app/_shell/hydration-error-handler.tsx" + ], + "linter": { + "rules": { + "suspicious": { + "noConsole": "off" + } + } + } + } + ] } diff --git a/package.json b/package.json index d587cebb54a..f7e13887c5e 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "check:realtime-prune": "bun run scripts/check-realtime-prune-graph.ts", "check:zustand-v5": "bun run scripts/check-zustand-v5-selectors.ts", "check:utils": "bun run scripts/check-utils-enforcement.ts", + "check:hygiene": "bun run scripts/check-hygiene.ts", "mship-contracts:generate": "bun run scripts/sync-mothership-stream-contract.ts", "mship-contracts:check": "bun run scripts/sync-mothership-stream-contract.ts --check", "mship-tools:generate": "bun run scripts/sync-tool-catalog.ts", diff --git a/scripts/check-hygiene.ts b/scripts/check-hygiene.ts new file mode 100644 index 00000000000..3003c31e468 --- /dev/null +++ b/scripts/check-hygiene.ts @@ -0,0 +1,219 @@ +#!/usr/bin/env bun +/** + * Enforces structural hygiene rules that Biome cannot cover statically. + * + * Checks: + * 1. @ts-ignore / @ts-expect-error without an explanation comment + * 2. Bare Next.js route handler exports (missing withRouteHandler wrapper) + * 3. Banned Vitest anti-patterns in test files + * + * Violations can be suppressed per-line with: + * // hygiene-suppress: + * placed on the line immediately before the flagged line. + */ +import { readdir, readFile } from 'node:fs/promises' +import path from 'node:path' + +const ROOT = path.resolve(import.meta.dir, '..') +const APPS_DIR = path.join(ROOT, 'apps') +const PACKAGES_DIR = path.join(ROOT, 'packages') + +const SKIP_DIRS = new Set([ + 'node_modules', + 'dist', + '.next', + '.turbo', + 'coverage', + 'bundles', + '.claude', +]) + +/** Generated directories where @ts-ignore is acceptable. */ +const TS_IGNORE_SKIP_PATHS = ['apps/docs/.next/', 'apps/docs/.source/', 'packages/ts-sdk/dist/'] + +/** Route files that legitimately don't need withRouteHandler. */ +const BARE_ROUTE_ALLOWLIST = new Set([ + // Ultra-lightweight health check — no logging, no tracing needed + 'apps/sim/app/api/health/route.ts', + // Delegates directly to copilot stream handler which has its own context + 'apps/sim/app/api/mothership/chat/stream/route.ts', +]) + +const SUPPRESSION_COMMENT = /\/\/\s*hygiene-suppress\s*:/ + +interface Violation { + file: string + line: number + description: string + snippet: string +} + +async function walk( + dir: string, + filter: (name: string) => boolean, + results: string[] = [] +): Promise { + let entries + try { + entries = await readdir(dir, { withFileTypes: true }) + } catch { + return results + } + for (const entry of entries) { + if (SKIP_DIRS.has(entry.name)) continue + const full = path.join(dir, entry.name) + if (entry.isDirectory()) { + await walk(full, filter, results) + } else if (filter(entry.name)) { + results.push(full) + } + } + return results +} + +function isSuppressed(lines: string[], lineIndex: number): boolean { + if (lineIndex > 0 && SUPPRESSION_COMMENT.test(lines[lineIndex - 1])) return true + return false +} + +// ─── Check 1: @ts-ignore / @ts-expect-error without explanation ────────────── + +const TS_SUPPRESS_PATTERN = /@ts-(?:ignore|expect-error)\s*$/ + +async function checkTsIgnore(violations: Violation[]) { + const allFiles: string[] = [] + for (const dir of [APPS_DIR, PACKAGES_DIR]) { + await walk(dir, (name) => /\.(ts|tsx|mts|cts)$/.test(name), allFiles) + } + + for (const file of allFiles) { + const rel = path.relative(ROOT, file) + if (TS_IGNORE_SKIP_PATHS.some((p) => rel.startsWith(p))) continue + + const content = await readFile(file, 'utf8') + const lines = content.split('\n') + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + if (TS_SUPPRESS_PATTERN.test(line.trimEnd())) { + if (isSuppressed(lines, i)) continue + violations.push({ + file: rel, + line: i + 1, + description: '@ts-ignore / @ts-expect-error without explanation', + snippet: line.trim(), + }) + } + } + } +} + +// ─── Check 2: Bare Next.js route exports ───────────────────────────────────── + +/** Matches `export async function GET/POST/PUT/DELETE/PATCH(` */ +const BARE_ROUTE_PATTERN = + /^export\s+(?:async\s+)?function\s+(?:GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s*\(/ + +async function checkBareRoutes(violations: Violation[]) { + const routeFiles: string[] = [] + await walk( + path.join(APPS_DIR, 'sim', 'app', 'api'), + (name) => name === 'route.ts' || name === 'route.tsx', + routeFiles + ) + + for (const file of routeFiles) { + const rel = path.relative(ROOT, file) + if (BARE_ROUTE_ALLOWLIST.has(rel)) continue + + const content = await readFile(file, 'utf8') + const lines = content.split('\n') + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + if (BARE_ROUTE_PATTERN.test(line)) { + if (isSuppressed(lines, i)) continue + violations.push({ + file: rel, + line: i + 1, + description: + 'Bare route export — wrap with withRouteHandler from @/lib/core/utils/with-route-handler', + snippet: line.trim(), + }) + } + } + } +} + +// ─── Check 3: Banned Vitest anti-patterns ──────────────────────────────────── + +const VITEST_ANTI_PATTERNS: Array<{ pattern: RegExp; description: string }> = [ + { + pattern: /\bvi\.doMock\s*\(/, + description: 'vi.doMock() — use vi.hoisted() + vi.mock() + static imports instead', + }, + { + pattern: /\bvi\.resetModules\s*\(/, + description: + 'vi.resetModules() — use vi.hoisted() + vi.mock() + static imports instead (exception: singleton modules that cache state)', + }, + { + pattern: /\bvi\.importActual\s*(?:<[^>]*>)?\s*\(/, + description: 'vi.importActual() — mock everything explicitly instead', + }, +] + +async function checkVitestAntiPatterns(violations: Violation[]) { + const testFiles: string[] = [] + for (const dir of [APPS_DIR, PACKAGES_DIR]) { + await walk(dir, (name) => /\.test\.(ts|tsx)$/.test(name), testFiles) + } + + for (const file of testFiles) { + const rel = path.relative(ROOT, file) + const content = await readFile(file, 'utf8') + const lines = content.split('\n') + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + for (const { pattern, description } of VITEST_ANTI_PATTERNS) { + if (pattern.test(line)) { + if (isSuppressed(lines, i)) continue + violations.push({ + file: rel, + line: i + 1, + description, + snippet: line.trim(), + }) + } + } + } + } +} + +// ─── Main ───────────────────────────────────────────────────────────────────── + +async function main() { + const violations: Violation[] = [] + + await Promise.all([ + checkTsIgnore(violations), + checkBareRoutes(violations), + checkVitestAntiPatterns(violations), + ]) + + if (violations.length === 0) { + console.log('✓ No hygiene violations found.') + process.exit(0) + } + + console.error(`\nFound ${violations.length} hygiene violation(s):\n`) + for (const v of violations) { + console.error(` ${v.file}:${v.line}`) + console.error(` ✗ ${v.description}`) + console.error(` ${v.snippet}\n`) + } + process.exit(1) +} + +main()