Skip to content
Merged
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
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@
"dev:sockets": "cd apps/realtime && bun run dev",
"dev:full": "bunx concurrently -n \"App,Realtime\" -c \"cyan,magenta\" \"cd apps/sim && bun run dev\" \"cd apps/realtime && bun run dev\"",
"dev:full:capped": "bunx concurrently -n \"App,Realtime\" -c \"cyan,magenta\" \"cd apps/sim && bun run dev:capped\" \"cd apps/realtime && bun run dev\"",
"test": "bun run test:setup && bun run test:npm-package-versions && bun run test:icon-path-precision && bun run test:tool-registry-boundary && bun run test:tool-request-boundary && bun run test:actorless-executor-operations && bun run test:permission-group-enforcement && bun run test:capability-subject && bun run test:application-graph && bun run test:migrations-safety && bun run test:generators && turbo run test",
"test": "bun run test:setup && bun run test:npm-package-versions && bun run test:audit-candidates && bun run test:icon-path-precision && bun run test:tool-registry-boundary && bun run test:tool-request-boundary && bun run test:actorless-executor-operations && bun run test:permission-group-enforcement && bun run test:capability-subject && bun run test:application-graph && bun run test:migrations-safety && bun run test:generators && turbo run test",
"test:setup": "bun run --cwd packages/sim-setup test",
"test:npm-package-versions": "bunx vitest run scripts/bump-npm-package-versions.test.ts",
"test:audit-candidates": "bunx vitest run scripts/check-db-audit-candidates.test.ts scripts/check-egress-boundary.test.ts",
"test:icon-path-precision": "bunx vitest run scripts/check-icon-path-precision.test.ts",
"test:tool-registry-boundary": "bunx vitest run scripts/check-tool-registry-boundary.test.ts",
"test:tool-request-boundary": "bunx vitest run scripts/check-tool-request-boundary.test.ts",
Expand Down
29 changes: 24 additions & 5 deletions scripts/check-api-contract-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ interface DeclaredContract {
module: string
}

const fileSourceCache = new Map<string, Promise<string | null>>()
const routeSourceCache = new Map<string, Promise<string | null>>()

async function listContractModules(dir: string, results: string[] = []): Promise<string[]> {
for (const entry of await readdir(dir, { withFileTypes: true })) {
if (SKIP_DIRS.has(entry.name)) continue
Expand All @@ -68,12 +71,19 @@ function isRouteContract(value: unknown): value is { method: HttpMethod; path: s
}

async function readIfFile(candidate: string): Promise<string | null> {
try {
if (!(await stat(candidate)).isFile()) return null
return await readFile(candidate, 'utf8')
} catch {
return null
let pending = fileSourceCache.get(candidate)
if (!pending) {
pending = (async () => {
try {
if (!(await stat(candidate)).isFile()) return null
return await readFile(candidate, 'utf8')
} catch {
return null
}
})()
fileSourceCache.set(candidate, pending)
}
return pending
}

/**
Expand All @@ -84,6 +94,15 @@ async function readIfFile(candidate: string): Promise<string | null> {
* own file — would look routeless and be silently exempted from the check.
*/
async function readRouteFile(routePath: string): Promise<string | null> {
let pending = routeSourceCache.get(routePath)
if (!pending) {
pending = resolveRouteFile(routePath)
routeSourceCache.set(routePath, pending)
}
return pending
}

async function resolveRouteFile(routePath: string): Promise<string | null> {
if (!routePath.startsWith('/api/')) return null
const segments = routePath.slice('/api/'.length).split('/').filter(Boolean)

Expand Down
36 changes: 26 additions & 10 deletions scripts/check-api-validation-contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,16 @@ const SOURCE_SKIP_DIRS = new Set([

type AnnotationKind = 'raw-fetch' | 'double-cast' | 'raw-json' | 'untyped-response'

const sourceCache = new Map<string, string>()

async function readSource(filePath: string): Promise<string> {
const cached = sourceCache.get(filePath)
if (cached !== undefined) return cached
const content = await readFile(filePath, 'utf8')
sourceCache.set(filePath, content)
return content
}

interface AnnotationResult {
allowed: boolean
missingReason: boolean
Expand Down Expand Up @@ -1257,7 +1267,7 @@ async function auditQueryHooks(): Promise<QueryHookAudit[]> {
const audits: QueryHookAudit[] = []

for (const filePath of queryHookFiles) {
const content = await readFile(filePath, 'utf8')
const content = await readSource(filePath)
audits.push(auditQueryHook(filePath, content))
}

Expand All @@ -1274,7 +1284,7 @@ async function main() {
let rawJsonExemptions = 0

for (const filePath of routeFiles) {
const content = await readFile(filePath, 'utf8')
const content = await readSource(filePath)
audits.push(auditRoute(filePath, content))

const rawJson = findRawJsonFindings(filePath, content)
Expand All @@ -1293,12 +1303,14 @@ async function main() {
let doubleCastExemptions = 0

const appsSimRoot = path.join(ROOT, 'apps/sim')
const contractsRoot = path.join(CONTRACTS_DIR, path.sep)

for (const filePath of sourceFiles) {
const content = await readFile(filePath, 'utf8')
const content = sourceCache.get(filePath) ?? (await readFile(filePath, 'utf8'))
if (filePath.startsWith(contractsRoot)) sourceCache.set(filePath, content)
const normalized = filePath.replace(/\\/g, '/')

if (isClientHookFile(filePath)) {
if (isClientHookFile(filePath) && content.includes('fetch')) {
const rawFetch = findRawFetchFindings(filePath, content)
rawFetchFindings.push(...rawFetch.findings)
rawFetchExemptions += rawFetch.exemptions
Expand All @@ -1308,26 +1320,30 @@ async function main() {
if (
normalized.startsWith(`${appsSimRoot}/`) &&
!isApiRouteHandler(filePath) &&
filePath !== path.join(ROOT, 'scripts', 'check-api-validation-contracts.ts')
filePath !== path.join(ROOT, 'scripts', 'check-api-validation-contracts.ts') &&
content.includes('fetch') &&
content.includes('/api/')
) {
const sameOrigin = findSameOriginApiFetchFindings(filePath, content)
sameOriginApiFetchFindings.push(...sameOrigin.findings)
sameOriginApiFetchExemptions += sameOrigin.exemptions
annotationsMissingReason.push(...sameOrigin.missingReasons)
}

const doubleCast = findDoubleCastFindings(filePath, content)
doubleCastFindings.push(...doubleCast.findings)
doubleCastExemptions += doubleCast.exemptions
annotationsMissingReason.push(...doubleCast.missingReasons)
if (content.includes('as unknown as')) {
const doubleCast = findDoubleCastFindings(filePath, content)
doubleCastFindings.push(...doubleCast.findings)
doubleCastExemptions += doubleCast.exemptions
annotationsMissingReason.push(...doubleCast.missingReasons)
}
}

const contractFiles = await walk(CONTRACTS_DIR, (fileName) => /\.ts$/.test(fileName))
const untypedResponseFindings: UntypedResponseFinding[] = []
let untypedResponseExemptions = 0

for (const filePath of contractFiles) {
const content = await readFile(filePath, 'utf8')
const content = await readSource(filePath)
const untyped = findUntypedResponseFindings(filePath, content)
untypedResponseFindings.push(...untyped.findings)
untypedResponseExemptions += untyped.exemptions
Expand Down
51 changes: 32 additions & 19 deletions scripts/check-client-boundary-imports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,15 @@ function isServerSurface(rel: string): boolean {

const SOURCE_EXTENSIONS = ['.ts', '.tsx']
const ALLOW_DIRECTIVE = 'client-boundary-allow'
const sourceCache = new Map<string, string>()

async function readSource(file: string): Promise<string> {
const cached = sourceCache.get(file)
if (cached !== undefined) return cached
const source = await readFile(file, 'utf8')
sourceCache.set(file, source)
return source
}

async function listFiles(dir: string): Promise<string[]> {
const out: string[] = []
Expand Down Expand Up @@ -125,7 +134,7 @@ async function isUseClientModule(absFile: string): Promise<boolean> {
if (cached !== undefined) return cached
let isClient = false
try {
isClient = leadingDirective(await readFile(absFile, 'utf8')) === 'use client'
isClient = leadingDirective(await readSource(absFile)) === 'use client'
} catch {}
useClientCache.set(absFile, isClient)
return isClient
Expand All @@ -135,24 +144,26 @@ async function isUseClientModule(absFile: string): Promise<boolean> {
* Locations declaring `'use server'` — module prologue or inline in a function
* body. Either form registers Server Actions app-wide.
*/
async function findUseServerDirectives(): Promise<string[]> {
async function findUseServerDirectives(files: readonly string[]): Promise<string[]> {
const found: string[] = []
for (const dir of DIRECTIVE_SCAN_DIRS) {
for (const absFile of await listFiles(dir)) {
const lines = (await readFile(absFile, 'utf8')).split('\n')
for (let i = 0; i < lines.length; i++) {
const match = DIRECTIVE_STATEMENT.exec(stripTrailingComment(lines[i].trim()))
if (match?.[2] === 'use server') {
found.push(`${path.relative(ROOT, absFile)}:${i + 1}`)
}
for (const absFile of files) {
const lines = (await readSource(absFile)).split('\n')
for (let i = 0; i < lines.length; i++) {
const match = DIRECTIVE_STATEMENT.exec(stripTrailingComment(lines[i].trim()))
if (match?.[2] === 'use server') {
found.push(`${path.relative(ROOT, absFile)}:${i + 1}`)
}
}
}
return found
}

/** Resolve an import specifier to an absolute source file, or null if external/unresolved. */
async function resolveSpecifier(spec: string, fromFile: string): Promise<string | null> {
function resolveSpecifier(
spec: string,
fromFile: string,
sourceFiles: ReadonlySet<string>
): string | null {
let base: string
if (spec.startsWith('@/')) {
base = path.join(APP_DIR, spec.slice(2))
Expand All @@ -168,10 +179,7 @@ async function resolveSpecifier(spec: string, fromFile: string): Promise<string
]
for (const candidate of candidates) {
if (!SOURCE_EXTENSIONS.includes(path.extname(candidate))) continue
try {
await readFile(candidate, 'utf8')
return candidate
} catch {}
if (sourceFiles.has(candidate)) return candidate
}
return null
}
Expand Down Expand Up @@ -246,7 +254,12 @@ async function main() {
const checkMode = process.argv.includes('--check')
let failed = false

const serverDirectives = await findUseServerDirectives()
const allFiles: string[] = []
for (const dir of DIRECTIVE_SCAN_DIRS) {
allFiles.push(...(await listFiles(dir)))
}
const sourceFiles = new Set(allFiles)
const serverDirectives = await findUseServerDirectives(allFiles)
if (serverDirectives.length === 0) {
console.log("✓ No 'use server' directives (Server Actions stay disabled).")
} else {
Expand All @@ -260,19 +273,19 @@ async function main() {
for (const location of serverDirectives) console.error(` ${location}`)
}

const allFiles = await listFiles(APP_DIR)
const violations: Violation[] = []

for (const absFile of allFiles) {
if (!absFile.startsWith(`${APP_DIR}${path.sep}`)) continue
const rel = path.relative(APP_DIR, absFile)
if (!isServerSurface(rel)) continue
// A server file that is itself `'use client'` is a client component — out of scope.
if (await isUseClientModule(absFile)) continue

const content = await readFile(absFile, 'utf8')
const content = await readSource(absFile)
for (const imp of parseImports(content)) {
if (!importsAValue(imp.clause)) continue
const resolved = await resolveSpecifier(imp.specifier, absFile)
const resolved = resolveSpecifier(imp.specifier, absFile, sourceFiles)
if (!resolved) continue
if (!(await isUseClientModule(resolved))) continue
if (hasAllowDirective(content, imp.line)) continue
Expand Down
45 changes: 45 additions & 0 deletions scripts/check-db-audit-candidates.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import { mayReferencePendingTable } from './check-pending-drop-tables'
import { mayBindDrizzleSql } from './check-sql-date-binding'

describe('database audit candidate scans', () => {
it('finds renamed pending-table imports', () => {
expect(
mayReferencePendingTable(
"import { organization as org } from '@sim/db/schema'",
new Set(['organization'])
)
).toBe(true)
})

it('decodes escaped schema module literals', () => {
expect(
mayReferencePendingTable(
String.raw`const organization = require('@sim/db/sch\u0065ma')`,
new Set(['organization'])
)
).toBe(true)
})

it('rejects unrelated uses of common table names', () => {
expect(
mayReferencePendingTable('const organization = getOrganization()', new Set(['organization']))
).toBe(false)
})

it('finds aliased drizzle sql imports', () => {
expect(mayBindDrizzleSql("import { sql as query } from 'drizzle-orm'")).toBe(true)
})

it('finds drizzle sql subpath imports', () => {
expect(mayBindDrizzleSql("import { sql } from 'drizzle-orm/sql'")).toBe(true)
})

it('decodes escaped drizzle module literals', () => {
expect(mayBindDrizzleSql(String.raw`const { sql } = require('drizzle\x2dorm')`)).toBe(true)
})

it('skips drizzle consumers that cannot bind sql', () => {
expect(mayBindDrizzleSql("import { eq } from 'drizzle-orm'")).toBe(false)
})
})
16 changes: 16 additions & 0 deletions scripts/check-egress-boundary.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest'
import { mayLoadTransport } from './check-egress-boundary'

describe('egress transport candidate scan', () => {
it('finds literal transport modules', () => {
expect(mayLoadTransport("import { request } from 'node:https'")).toBe(true)
})

it('decodes escaped transport module literals', () => {
expect(mayLoadTransport(String.raw`const http = require('node:\x68ttp')`)).toBe(true)
})

it('ignores transport names outside string tokens', () => {
expect(mayLoadTransport('const https = createClient()')).toBe(false)
})
})
30 changes: 26 additions & 4 deletions scripts/check-egress-boundary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,10 @@
import type { Dirent } from 'node:fs'
import { readdirSync, readFileSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import ts from '@typescript/typescript6'

const ROOT = path.resolve(import.meta.dir, '..')
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')

const SCAN_DIRS = [
'apps/sim/app',
Expand Down Expand Up @@ -141,7 +142,7 @@ function isElidedExport(node: ts.ExportDeclaration): boolean {
* skipped: it has no runtime presence and cannot open anything.
*/
function findTransportLoads(file: string, source: string): Array<Omit<Violation, 'file'>> {
const sourceFile = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true)
const sourceFile = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, false)
const found: Array<Omit<Violation, 'file'>> = []

const record = (node: ts.Node, specifier: string, kind: string) => {
Expand Down Expand Up @@ -177,6 +178,25 @@ function findTransportLoads(file: string, source: string): Array<Omit<Violation,
return found
}

export function mayLoadTransport(source: string): boolean {
const scanner = ts.createScanner(
ts.ScriptTarget.Latest,
true,
ts.LanguageVariant.Standard,
source
)
for (let token = scanner.scan(); token !== ts.SyntaxKind.EndOfFileToken; token = scanner.scan()) {
if (
(token === ts.SyntaxKind.StringLiteral ||
token === ts.SyntaxKind.NoSubstitutionTemplateLiteral) &&
TRANSPORTS.has(scanner.getTokenValue())
) {
return true
}
}
return false
}

function main() {
const violations: Violation[] = []
let scanned = 0
Expand All @@ -186,7 +206,9 @@ function main() {
const rel = path.relative(ROOT, file).split(path.sep).join('/')
if (ALLOWED.has(rel)) continue
scanned++
for (const load of findTransportLoads(rel, readFileSync(file, 'utf8'))) {
const source = readFileSync(file, 'utf8')
if (!mayLoadTransport(source)) continue
for (const load of findTransportLoads(rel, source)) {
violations.push({ file: rel, ...load })
}
}
Expand All @@ -212,4 +234,4 @@ function main() {
process.exit(1)
}

main()
if (import.meta.main) main()
Loading
Loading