Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions apps/sim/app/api/copilot/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
1 change: 1 addition & 0 deletions apps/sim/lib/copilot/request/go/stream.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof import('@/lib/copilot/request/session')>(
'@/lib/copilot/request/session'
)
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/lib/core/config/redis.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
)
Expand Down
12 changes: 3 additions & 9 deletions apps/sim/lib/core/rate-limiter/route-helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,9 @@ const { mockAdapter } = vi.hoisted(() => ({
},
}))

vi.mock('@/lib/core/rate-limiter/storage', async () => {
const actual = await vi.importActual<typeof import('@/lib/core/rate-limiter/storage')>(
'@/lib/core/rate-limiter/storage'
)
return {
...actual,
createStorageAdapter: () => mockAdapter,
}
})
vi.mock('@/lib/core/rate-limiter/storage', () => ({
createStorageAdapter: () => mockAdapter,
}))

function passThroughClientIp() {
requestUtilsMockFns.mockGetClientIp.mockImplementation(
Expand Down
1 change: 1 addition & 0 deletions apps/sim/lib/execution/isolated-vm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
4 changes: 3 additions & 1 deletion apps/sim/lib/mcp/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,11 @@ export function createMcpSuccessResponse<T>(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
}

Expand Down
5 changes: 4 additions & 1 deletion apps/sim/providers/attachments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
22 changes: 20 additions & 2 deletions biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,8 @@
"noControlCharactersInRegex": "off",
"noThenProperty": "off",
"noAssignInExpressions": "off",
"noDocumentCookie": "off"
"noDocumentCookie": "off",
"noConsole": "error"
},
"correctness": {
"useExhaustiveDependencies": "off",
Expand Down Expand Up @@ -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"
}
}
}
}
]
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
219 changes: 219 additions & 0 deletions scripts/check-hygiene.ts
Original file line number Diff line number Diff line change
@@ -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: <reason>
* 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<string[]> {
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()
Loading