Skip to content

Commit 3193cac

Browse files
committed
chore(hygiene): add structural lint enforcement script and noConsole rule
1 parent 93f7be4 commit 3193cac

8 files changed

Lines changed: 250 additions & 13 deletions

File tree

apps/sim/app/api/copilot/chat/route.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,15 @@ import type { NextRequest } from 'next/server'
22
import { copilotChatGetContract } from '@/lib/api/contracts/copilot'
33
import { parseRequest } from '@/lib/api/server'
44
import { handleUnifiedChatPost, maxDuration } from '@/lib/copilot/chat/post'
5+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
56
import { GET as getChat } from '@/app/api/copilot/chat/queries'
67

78
export { maxDuration }
89

910
export const POST = handleUnifiedChatPost
1011

11-
export async function GET(request: NextRequest) {
12+
export const GET = withRouteHandler(async (request: NextRequest) => {
1213
const parsed = await parseRequest(copilotChatGetContract, request, {})
1314
if (!parsed.success) return parsed.response
1415
return getChat(request)
15-
}
16+
})

apps/sim/lib/copilot/request/go/stream.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
MothershipStreamV1ToolPhase,
1212
} from '@/lib/copilot/generated/mothership-stream-v1'
1313

14+
// hygiene-suppress: session re-exports many real domain functions (eventToStreamEvent, parsePersistedStreamEventEnvelope) used by stream.ts under test — must spread real implementations and override only hasAbortMarker
1415
vi.mock('@/lib/copilot/request/session', async () => {
1516
const actual = await vi.importActual<typeof import('@/lib/copilot/request/session')>(
1617
'@/lib/copilot/request/session'

apps/sim/lib/core/config/redis.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,9 @@ describe('redis config', () => {
181181
})
182182

183183
it('returns true as a no-op when Redis is unavailable', async () => {
184+
// hygiene-suppress: redis module caches a singleton client at import time — must re-evaluate to test the no-Redis path
184185
vi.resetModules()
186+
// hygiene-suppress: redis module caches a singleton client at import time — must re-evaluate to test the no-Redis path
185187
vi.doMock('@/lib/core/config/env', () =>
186188
createEnvMock({ REDIS_URL: undefined as unknown as string })
187189
)

apps/sim/lib/core/rate-limiter/route-helpers.test.ts

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,9 @@ const { mockAdapter } = vi.hoisted(() => ({
1212
},
1313
}))
1414

15-
vi.mock('@/lib/core/rate-limiter/storage', async () => {
16-
const actual = await vi.importActual<typeof import('@/lib/core/rate-limiter/storage')>(
17-
'@/lib/core/rate-limiter/storage'
18-
)
19-
return {
20-
...actual,
21-
createStorageAdapter: () => mockAdapter,
22-
}
23-
})
15+
vi.mock('@/lib/core/rate-limiter/storage', () => ({
16+
createStorageAdapter: () => mockAdapter,
17+
}))
2418

2519
function passThroughClientIp() {
2620
requestUtilsMockFns.mockGetClientIp.mockImplementation(

apps/sim/lib/execution/isolated-vm.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,7 @@ async function loadExecutionModule(options: {
221221
: null
222222
)
223223

224+
// hygiene-suppress: isolated-vm initializes worker state at module scope — must re-evaluate per test scenario
224225
vi.resetModules()
225226

226227
const mod = await import('@/lib/execution/isolated-vm')

biome.json

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,8 @@
9090
"noControlCharactersInRegex": "off",
9191
"noThenProperty": "off",
9292
"noAssignInExpressions": "off",
93-
"noDocumentCookie": "off"
93+
"noDocumentCookie": "off",
94+
"noConsole": "error"
9495
},
9596
"correctness": {
9697
"useExhaustiveDependencies": "off",
@@ -160,5 +161,22 @@
160161
"enabled": true,
161162
"indentWidth": 2
162163
}
163-
}
164+
},
165+
"overrides": [
166+
{
167+
"includes": [
168+
"scripts/**",
169+
"apps/*/scripts/**",
170+
"**/vitest.setup.ts",
171+
"apps/sim/app/_shell/hydration-error-handler.tsx"
172+
],
173+
"linter": {
174+
"rules": {
175+
"suspicious": {
176+
"noConsole": "off"
177+
}
178+
}
179+
}
180+
}
181+
]
164182
}

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"check:realtime-prune": "bun run scripts/check-realtime-prune-graph.ts",
2828
"check:zustand-v5": "bun run scripts/check-zustand-v5-selectors.ts",
2929
"check:utils": "bun run scripts/check-utils-enforcement.ts",
30+
"check:hygiene": "bun run scripts/check-hygiene.ts",
3031
"mship-contracts:generate": "bun run scripts/sync-mothership-stream-contract.ts",
3132
"mship-contracts:check": "bun run scripts/sync-mothership-stream-contract.ts --check",
3233
"mship-tools:generate": "bun run scripts/sync-tool-catalog.ts",

scripts/check-hygiene.ts

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
#!/usr/bin/env bun
2+
/**
3+
* Enforces structural hygiene rules that Biome cannot cover statically.
4+
*
5+
* Checks:
6+
* 1. @ts-ignore / @ts-expect-error without an explanation comment
7+
* 2. Bare Next.js route handler exports (missing withRouteHandler wrapper)
8+
* 3. Banned Vitest anti-patterns in test files
9+
*
10+
* Violations can be suppressed per-line with:
11+
* // hygiene-suppress: <reason>
12+
* placed on the line immediately before the flagged line.
13+
*/
14+
import { readdir, readFile } from 'node:fs/promises'
15+
import path from 'node:path'
16+
17+
const ROOT = path.resolve(import.meta.dir, '..')
18+
const APPS_DIR = path.join(ROOT, 'apps')
19+
const PACKAGES_DIR = path.join(ROOT, 'packages')
20+
21+
const SKIP_DIRS = new Set([
22+
'node_modules',
23+
'dist',
24+
'.next',
25+
'.turbo',
26+
'coverage',
27+
'bundles',
28+
'.claude',
29+
])
30+
31+
/** Generated directories where @ts-ignore is acceptable. */
32+
const TS_IGNORE_SKIP_PATHS = ['apps/docs/.next/', 'apps/docs/.source/', 'packages/ts-sdk/dist/']
33+
34+
/** Route files that legitimately don't need withRouteHandler. */
35+
const BARE_ROUTE_ALLOWLIST = new Set([
36+
// Ultra-lightweight health check — no logging, no tracing needed
37+
'apps/sim/app/api/health/route.ts',
38+
// Delegates directly to copilot stream handler which has its own context
39+
'apps/sim/app/api/mothership/chat/stream/route.ts',
40+
])
41+
42+
const SUPPRESSION_COMMENT = /\/\/\s*hygiene-suppress\s*:/
43+
44+
interface Violation {
45+
file: string
46+
line: number
47+
description: string
48+
snippet: string
49+
}
50+
51+
async function walk(
52+
dir: string,
53+
filter: (name: string) => boolean,
54+
results: string[] = []
55+
): Promise<string[]> {
56+
let entries
57+
try {
58+
entries = await readdir(dir, { withFileTypes: true })
59+
} catch {
60+
return results
61+
}
62+
for (const entry of entries) {
63+
if (SKIP_DIRS.has(entry.name)) continue
64+
const full = path.join(dir, entry.name)
65+
if (entry.isDirectory()) {
66+
await walk(full, filter, results)
67+
} else if (filter(entry.name)) {
68+
results.push(full)
69+
}
70+
}
71+
return results
72+
}
73+
74+
function isSuppressed(lines: string[], lineIndex: number): boolean {
75+
if (lineIndex > 0 && SUPPRESSION_COMMENT.test(lines[lineIndex - 1])) return true
76+
return false
77+
}
78+
79+
// ─── Check 1: @ts-ignore / @ts-expect-error without explanation ──────────────
80+
81+
const TS_SUPPRESS_PATTERN = /@ts-(?:ignore|expect-error)\s*$/
82+
83+
async function checkTsIgnore(violations: Violation[]) {
84+
const allFiles: string[] = []
85+
for (const dir of [APPS_DIR, PACKAGES_DIR]) {
86+
await walk(dir, (name) => /\.(ts|tsx|mts|cts)$/.test(name), allFiles)
87+
}
88+
89+
for (const file of allFiles) {
90+
const rel = path.relative(ROOT, file)
91+
if (TS_IGNORE_SKIP_PATHS.some((p) => rel.startsWith(p))) continue
92+
93+
const content = await readFile(file, 'utf8')
94+
const lines = content.split('\n')
95+
96+
for (let i = 0; i < lines.length; i++) {
97+
const line = lines[i]
98+
if (TS_SUPPRESS_PATTERN.test(line.trimEnd())) {
99+
if (isSuppressed(lines, i)) continue
100+
violations.push({
101+
file: rel,
102+
line: i + 1,
103+
description: '@ts-ignore / @ts-expect-error without explanation',
104+
snippet: line.trim(),
105+
})
106+
}
107+
}
108+
}
109+
}
110+
111+
// ─── Check 2: Bare Next.js route exports ─────────────────────────────────────
112+
113+
/** Matches `export async function GET/POST/PUT/DELETE/PATCH(` */
114+
const BARE_ROUTE_PATTERN =
115+
/^export\s+(?:async\s+)?function\s+(?:GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s*\(/
116+
117+
async function checkBareRoutes(violations: Violation[]) {
118+
const routeFiles: string[] = []
119+
await walk(
120+
path.join(APPS_DIR, 'sim', 'app', 'api'),
121+
(name) => name === 'route.ts' || name === 'route.tsx',
122+
routeFiles
123+
)
124+
125+
for (const file of routeFiles) {
126+
const rel = path.relative(ROOT, file)
127+
if (BARE_ROUTE_ALLOWLIST.has(rel)) continue
128+
129+
const content = await readFile(file, 'utf8')
130+
const lines = content.split('\n')
131+
132+
for (let i = 0; i < lines.length; i++) {
133+
const line = lines[i]
134+
if (BARE_ROUTE_PATTERN.test(line)) {
135+
if (isSuppressed(lines, i)) continue
136+
violations.push({
137+
file: rel,
138+
line: i + 1,
139+
description:
140+
'Bare route export — wrap with withRouteHandler from @/lib/core/utils/with-route-handler',
141+
snippet: line.trim(),
142+
})
143+
}
144+
}
145+
}
146+
}
147+
148+
// ─── Check 3: Banned Vitest anti-patterns ────────────────────────────────────
149+
150+
const VITEST_ANTI_PATTERNS: Array<{ pattern: RegExp; description: string }> = [
151+
{
152+
pattern: /\bvi\.doMock\s*\(/,
153+
description: 'vi.doMock() — use vi.hoisted() + vi.mock() + static imports instead',
154+
},
155+
{
156+
pattern: /\bvi\.resetModules\s*\(/,
157+
description:
158+
'vi.resetModules() — use vi.hoisted() + vi.mock() + static imports instead (exception: singleton modules that cache state)',
159+
},
160+
{
161+
pattern: /\bvi\.importActual\s*\(/,
162+
description: 'vi.importActual() — mock everything explicitly instead',
163+
},
164+
]
165+
166+
async function checkVitestAntiPatterns(violations: Violation[]) {
167+
const testFiles: string[] = []
168+
for (const dir of [APPS_DIR, PACKAGES_DIR]) {
169+
await walk(dir, (name) => /\.test\.(ts|tsx)$/.test(name), testFiles)
170+
}
171+
172+
for (const file of testFiles) {
173+
const rel = path.relative(ROOT, file)
174+
const content = await readFile(file, 'utf8')
175+
const lines = content.split('\n')
176+
177+
for (let i = 0; i < lines.length; i++) {
178+
const line = lines[i]
179+
for (const { pattern, description } of VITEST_ANTI_PATTERNS) {
180+
if (pattern.test(line)) {
181+
if (isSuppressed(lines, i)) continue
182+
violations.push({
183+
file: rel,
184+
line: i + 1,
185+
description,
186+
snippet: line.trim(),
187+
})
188+
}
189+
}
190+
}
191+
}
192+
}
193+
194+
// ─── Main ─────────────────────────────────────────────────────────────────────
195+
196+
async function main() {
197+
const violations: Violation[] = []
198+
199+
await Promise.all([
200+
checkTsIgnore(violations),
201+
checkBareRoutes(violations),
202+
checkVitestAntiPatterns(violations),
203+
])
204+
205+
if (violations.length === 0) {
206+
console.log('✓ No hygiene violations found.')
207+
process.exit(0)
208+
}
209+
210+
console.error(`\nFound ${violations.length} hygiene violation(s):\n`)
211+
for (const v of violations) {
212+
console.error(` ${v.file}:${v.line}`)
213+
console.error(` ✗ ${v.description}`)
214+
console.error(` ${v.snippet}\n`)
215+
}
216+
process.exit(1)
217+
}
218+
219+
main()

0 commit comments

Comments
 (0)