From 013261467fa47bd81cc0e2fd3f594f5b3595832f Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 1 Sep 2026 11:41:34 -0700 Subject: [PATCH 1/3] improvement(audits): eliminate repeated parsing and scans --- package.json | 3 +- scripts/check-api-contract-routes.ts | 29 +++- scripts/check-api-validation-contracts.ts | 36 +++-- scripts/check-client-boundary-imports.ts | 51 ++++--- scripts/check-db-audit-candidates.test.ts | 41 ++++++ scripts/check-egress-boundary.test.ts | 16 +++ scripts/check-egress-boundary.ts | 30 +++- scripts/check-icon-path-precision.ts | 17 ++- scripts/check-import-specifiers.ts | 26 +++- scripts/check-openapi-specs.ts | 26 +++- scripts/check-openapi.ts | 11 +- scripts/check-pending-drop-tables.ts | 29 +++- scripts/check-sql-date-binding.ts | 31 ++++- scripts/check-tool-request-boundary.test.ts | 16 ++- scripts/check-tool-request-boundary.ts | 74 +++++++--- scripts/check-utils-enforcement.ts | 32 +++-- scripts/format-generated-source.ts | 3 +- scripts/generate-docs.ts | 144 +++++++++++++------- scripts/openapi/documents.test.ts | 37 +++-- scripts/openapi/generator.test.ts | 26 +++- scripts/openapi/generator.ts | 96 ++++++++----- 21 files changed, 565 insertions(+), 209 deletions(-) create mode 100644 scripts/check-db-audit-candidates.test.ts create mode 100644 scripts/check-egress-boundary.test.ts diff --git a/package.json b/package.json index f5e952fb383..84d1a3930c5 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/check-api-contract-routes.ts b/scripts/check-api-contract-routes.ts index 6ebfc193030..7095fc99924 100644 --- a/scripts/check-api-contract-routes.ts +++ b/scripts/check-api-contract-routes.ts @@ -45,6 +45,9 @@ interface DeclaredContract { module: string } +const fileSourceCache = new Map>() +const routeSourceCache = new Map>() + async function listContractModules(dir: string, results: string[] = []): Promise { for (const entry of await readdir(dir, { withFileTypes: true })) { if (SKIP_DIRS.has(entry.name)) continue @@ -68,12 +71,19 @@ function isRouteContract(value: unknown): value is { method: HttpMethod; path: s } async function readIfFile(candidate: string): Promise { - 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 } /** @@ -84,6 +94,15 @@ async function readIfFile(candidate: string): Promise { * own file — would look routeless and be silently exempted from the check. */ async function readRouteFile(routePath: string): Promise { + let pending = routeSourceCache.get(routePath) + if (!pending) { + pending = resolveRouteFile(routePath) + routeSourceCache.set(routePath, pending) + } + return pending +} + +async function resolveRouteFile(routePath: string): Promise { if (!routePath.startsWith('/api/')) return null const segments = routePath.slice('/api/'.length).split('/').filter(Boolean) diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index f3c84aaf648..4943ad83f95 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -262,6 +262,16 @@ const SOURCE_SKIP_DIRS = new Set([ type AnnotationKind = 'raw-fetch' | 'double-cast' | 'raw-json' | 'untyped-response' +const sourceCache = new Map() + +async function readSource(filePath: string): Promise { + 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 @@ -1257,7 +1267,7 @@ async function auditQueryHooks(): Promise { const audits: QueryHookAudit[] = [] for (const filePath of queryHookFiles) { - const content = await readFile(filePath, 'utf8') + const content = await readSource(filePath) audits.push(auditQueryHook(filePath, content)) } @@ -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) @@ -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 @@ -1308,7 +1320,9 @@ 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) @@ -1316,10 +1330,12 @@ async function main() { 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)) @@ -1327,7 +1343,7 @@ async function main() { 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 diff --git a/scripts/check-client-boundary-imports.ts b/scripts/check-client-boundary-imports.ts index d9ad1ea3364..ba7a332e3ec 100644 --- a/scripts/check-client-boundary-imports.ts +++ b/scripts/check-client-boundary-imports.ts @@ -69,6 +69,15 @@ function isServerSurface(rel: string): boolean { const SOURCE_EXTENSIONS = ['.ts', '.tsx'] const ALLOW_DIRECTIVE = 'client-boundary-allow' +const sourceCache = new Map() + +async function readSource(file: string): Promise { + 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 { const out: string[] = [] @@ -125,7 +134,7 @@ async function isUseClientModule(absFile: string): Promise { 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 @@ -135,16 +144,14 @@ async function isUseClientModule(absFile: string): Promise { * Locations declaring `'use server'` — module prologue or inline in a function * body. Either form registers Server Actions app-wide. */ -async function findUseServerDirectives(): Promise { +async function findUseServerDirectives(files: readonly string[]): Promise { 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}`) } } } @@ -152,7 +159,11 @@ async function findUseServerDirectives(): Promise { } /** Resolve an import specifier to an absolute source file, or null if external/unresolved. */ -async function resolveSpecifier(spec: string, fromFile: string): Promise { +function resolveSpecifier( + spec: string, + fromFile: string, + sourceFiles: ReadonlySet +): string | null { let base: string if (spec.startsWith('@/')) { base = path.join(APP_DIR, spec.slice(2)) @@ -168,10 +179,7 @@ async function resolveSpecifier(spec: string, fromFile: string): Promise { + 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('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) + }) +}) diff --git a/scripts/check-egress-boundary.test.ts b/scripts/check-egress-boundary.test.ts new file mode 100644 index 00000000000..bb3109357b3 --- /dev/null +++ b/scripts/check-egress-boundary.test.ts @@ -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) + }) +}) diff --git a/scripts/check-egress-boundary.ts b/scripts/check-egress-boundary.ts index f48d59cb763..b2210258bdf 100644 --- a/scripts/check-egress-boundary.ts +++ b/scripts/check-egress-boundary.ts @@ -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', @@ -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> { - const sourceFile = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true) + const sourceFile = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, false) const found: Array> = [] const record = (node: ts.Node, specifier: string, kind: string) => { @@ -177,6 +178,25 @@ function findTransportLoads(file: string, source: string): Array ({ @@ -322,8 +325,16 @@ async function currentIconFiles(): Promise { async function scanCurrentFiles(files: string[]): Promise { const candidates: PrecisionCandidate[] = [] const invalidExceptions: InvalidPrecisionException[] = [] - for (const file of files) { - const analysis = analyzeIconSource(await readFile(file, 'utf8'), file) + const sources = await Promise.all(files.map((file) => readFile(file, 'utf8'))) + const parsedBySource = new Map() + for (const [index, file] of files.entries()) { + const source = sources[index] + let extracted = parsedBySource.get(source) + if (!extracted) { + extracted = extractLiteralPaths(source, file) + parsedBySource.set(source, extracted) + } + const analysis = analyzeExtractedPaths(extracted, file) candidates.push(...analysis.candidates) invalidExceptions.push(...analysis.invalidExceptions) } diff --git a/scripts/check-import-specifiers.ts b/scripts/check-import-specifiers.ts index b8ad37a3b51..696a3033ab9 100644 --- a/scripts/check-import-specifiers.ts +++ b/scripts/check-import-specifiers.ts @@ -45,6 +45,7 @@ const REQUIRE_RE = /\brequire\s*\(\s*['"]([^'"]+)['"]\s*\)/g * design, so flagging them would bury the one rule that matters. */ const SUBPATH_REQUIRED = new Set(['@sim/utils']) +const repositoryFiles = new Set() /** Repo-relative path, always `/`-separated — `relative()` yields `\` on Windows. */ function repoPath(absolute: string): string { @@ -70,7 +71,10 @@ function walk(dir: string, acc: string[] = []): string[] { if (e.name.startsWith('.') || SKIP_DIRS.has(e.name)) continue const full = join(dir, e.name) if (e.isDirectory()) walk(full, acc) - else if (isCompiledSource(full, e.name)) acc.push(full) + else { + repositoryFiles.add(full) + if (isCompiledSource(full, e.name)) acc.push(full) + } } return acc } @@ -98,13 +102,13 @@ function isFile(p: string): boolean { /** ``, ``, or `/index`. */ function probe(base: string): string | null { - if (isFile(base)) return base + if (repositoryFiles.has(base)) return base for (const ext of EXTENSIONS) { - if (isFile(base + ext)) return base + ext + if (repositoryFiles.has(base + ext)) return base + ext } for (const ext of EXTENSIONS) { const idx = join(base, `index${ext}`) - if (isFile(idx)) return idx + if (repositoryFiles.has(idx)) return idx } return null } @@ -279,6 +283,18 @@ function resolveSpecifier(spec: string, importer: string): Outcome | null { return null // bare npm specifier — not ours to verify } +const resolutionCache = new Map() + +function resolveSpecifierCached(spec: string, importer: string): Outcome | null { + const workspace = workspaceFor(importer) + const scope = spec.startsWith('.') ? dirname(importer) : (workspace?.dir ?? ROOT) + const key = `${scope}\0${spec}` + if (resolutionCache.has(key)) return resolutionCache.get(key) ?? null + const outcome = resolveSpecifier(spec, importer) + resolutionCache.set(key, outcome) + return outcome +} + interface Violation { file: string line: number @@ -328,7 +344,7 @@ for (const file of files) { const spec = m[1] // `m.index` is the newline ending the previous line; the specifier's offset is exact. const at = m.index + m[0].lastIndexOf(spec) - const outcome = resolveSpecifier(spec, file) + const outcome = resolveSpecifierCached(spec, file) if (outcome) { checked++ if (!outcome.ok) { diff --git a/scripts/check-openapi-specs.ts b/scripts/check-openapi-specs.ts index 97274d5fb26..9e5126a982c 100644 --- a/scripts/check-openapi-specs.ts +++ b/scripts/check-openapi-specs.ts @@ -250,13 +250,23 @@ function docPropertyNames(schema: unknown, spec: Json): Set | null { return null } -function toJsonSchema(schema: z.ZodType, io: 'input' | 'output'): Json { - return z.toJSONSchema(schema, { +type SchemaIo = 'input' | 'output' + +const jsonSchemaCache = new WeakMap>() + +function toJsonSchema(schema: z.ZodType, io: SchemaIo): Json { + const cached = jsonSchemaCache.get(schema)?.get(io) + if (cached) return cached + const converted = z.toJSONSchema(schema, { io, target: 'draft-2020-12', unrepresentable: 'any', cycles: 'ref', }) as Json + const byIo = jsonSchemaCache.get(schema) ?? new Map() + byIo.set(io, converted) + jsonSchemaCache.set(schema, byIo) + return converted } const outputExampleValidator = new Ajv2020({ @@ -264,6 +274,10 @@ const outputExampleValidator = new Ajv2020({ allErrors: true, validateFormats: false, }) +const outputValidatorCache = new WeakMap< + z.ZodType, + ReturnType +>() function stripLegacySchemaIds(value: unknown): unknown { if (Array.isArray(value)) return value.map(stripLegacySchemaIds) @@ -276,9 +290,11 @@ function stripLegacySchemaIds(value: unknown): unknown { } function outputExampleError(schema: z.ZodType, value: unknown): string | null { - const validate = outputExampleValidator.compile( - stripLegacySchemaIds(toJsonSchema(schema, 'output')) - ) + let validate = outputValidatorCache.get(schema) + if (!validate) { + validate = outputExampleValidator.compile(stripLegacySchemaIds(toJsonSchema(schema, 'output'))) + outputValidatorCache.set(schema, validate) + } if (validate(value)) return null return outputExampleValidator.errorsText(validate.errors) } diff --git a/scripts/check-openapi.ts b/scripts/check-openapi.ts index cc7840e6f85..94d1958a46a 100644 --- a/scripts/check-openapi.ts +++ b/scripts/check-openapi.ts @@ -1,16 +1,17 @@ #!/usr/bin/env bun import { spawnSync } from 'node:child_process' import path from 'node:path' +import { localBin } from './local-bin' const ROOT = path.resolve(import.meta.dir, '..') const CHECKS = [ - ['run', 'scripts/generate-openapi.ts', '--check'], - ['x', 'vitest', 'run', '--config', 'scripts/openapi/vitest.config.ts'], - ['run', 'scripts/check-openapi-specs.ts'], + [process.execPath, 'run', 'scripts/generate-openapi.ts', '--check'], + [localBin('vitest'), 'run', '--config', 'scripts/openapi/vitest.config.ts'], + [process.execPath, 'run', 'scripts/check-openapi-specs.ts'], ] as const -for (const args of CHECKS) { - const result = spawnSync(process.execPath, args, { +for (const [command, ...args] of CHECKS) { + const result = spawnSync(command, args, { cwd: ROOT, stdio: 'inherit', }) diff --git a/scripts/check-pending-drop-tables.ts b/scripts/check-pending-drop-tables.ts index 4c842dbc061..5a4e60f1b41 100644 --- a/scripts/check-pending-drop-tables.ts +++ b/scripts/check-pending-drop-tables.ts @@ -28,6 +28,7 @@ import { readdirSync, readFileSync } from 'node:fs' import { dirname, extname, join, relative, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { parse } from '@babel/parser' +import ts from '@typescript/typescript6' const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)) const ROOT = resolve(SCRIPT_DIR, '..') @@ -528,6 +529,28 @@ function collectSources(dir: string, found: string[] = []): string[] { return found } +export function mayReferencePendingTable(source: string, tableNames: ReadonlySet): boolean { + const scanner = ts.createScanner(ts.ScriptTarget.Latest, true, ts.LanguageVariant.JSX, source) + let hasSchemaModule = false + let hasTableName = false + + for (let token = scanner.scan(); token !== ts.SyntaxKind.EndOfFileToken; token = scanner.scan()) { + const value = scanner.getTokenValue() + if ( + (token === ts.SyntaxKind.StringLiteral || + token === ts.SyntaxKind.NoSubstitutionTemplateLiteral) && + (/@sim\/db(\/|$)/.test(value) || /(^|\/)schema(\.ts)?$/.test(value)) + ) { + hasSchemaModule = true + } + if (token === ts.SyntaxKind.Identifier && tableNames.has(value)) { + hasTableName = true + } + if (hasSchemaModule && hasTableName) return true + } + return false +} + function main(): void { const pendingTables = readPendingTables() if (pendingTables.size === 0) { @@ -538,12 +561,12 @@ function main(): void { // schema.ts is deliberately NOT skipped: its own `Columns` helpers // must keep naming every doomed column away, including ones deprecated later. const skipFiles = new Set([fileURLToPath(import.meta.url)]) - const namePattern = new RegExp(`\\b(${[...pendingTables.keys()].join('|')}|alias)\\b`) + const pendingTableNames = new Set(pendingTables.keys()) const violations: Violation[] = [] for (const file of SCAN_DIRS.flatMap((dir) => collectSources(dir))) { if (skipFiles.has(file) || /\.test\.(ts|tsx|mts|cts)$/.test(file)) continue const source = readFileSync(file, 'utf8') - if (!namePattern.test(source)) continue + if (file !== SCHEMA_PATH && !mayReferencePendingTable(source, pendingTableNames)) continue violations.push(...auditFile(file, source, pendingTables)) } @@ -568,4 +591,4 @@ function main(): void { process.exit(1) } -main() +if (import.meta.main) main() diff --git a/scripts/check-sql-date-binding.ts b/scripts/check-sql-date-binding.ts index 514e8884fd4..131155dacae 100644 --- a/scripts/check-sql-date-binding.ts +++ b/scripts/check-sql-date-binding.ts @@ -17,6 +17,7 @@ import { readdirSync, readFileSync } from 'node:fs' import { dirname, extname, join, relative, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { parse } from '@babel/parser' +import ts from '@typescript/typescript6' const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)) const ROOT = resolve(SCRIPT_DIR, '..') @@ -548,12 +549,32 @@ function analyzeSource(source: string, file = 'source.ts'): FileAnalysis { * Skips the parse for files that cannot bind the tag. * * `collectSqlBindings` and `isDrizzleImportCall` both match the specifier as a string literal, - * so a source that never names the module yields no bindings and no violations. That is all but - * ~590 of the ~13,900 scanned files, and not parsing them takes the audit from ~4.5s to ~0.8s. - * An escaped specifier (`'drizzle\x2dorm'`) would evade the substring; the repo contains none. + * so a source that never names the module yields no bindings and no violations. The scanner + * decodes escaped string tokens before comparing them and also requires a possible `sql` binding. */ -function mayBindDrizzleSql(source: string): boolean { - return source.includes(DRIZZLE_MODULE) +export function mayBindDrizzleSql(source: string): boolean { + const scanner = ts.createScanner(ts.ScriptTarget.Latest, true, ts.LanguageVariant.JSX, source) + let hasDrizzleModule = false + let hasSqlToken = false + + for (let token = scanner.scan(); token !== ts.SyntaxKind.EndOfFileToken; token = scanner.scan()) { + const value = scanner.getTokenValue() + if ( + (token === ts.SyntaxKind.StringLiteral || + token === ts.SyntaxKind.NoSubstitutionTemplateLiteral) && + value === DRIZZLE_MODULE + ) { + hasDrizzleModule = true + } + if ( + (token === ts.SyntaxKind.Identifier || token === ts.SyntaxKind.StringLiteral) && + value === 'sql' + ) { + hasSqlToken = true + } + if (hasDrizzleModule && hasSqlToken) return true + } + return false } function collectSources(dir: string, found: string[] = []): string[] { diff --git a/scripts/check-tool-request-boundary.test.ts b/scripts/check-tool-request-boundary.test.ts index 5291d3154f7..39657d39d33 100644 --- a/scripts/check-tool-request-boundary.test.ts +++ b/scripts/check-tool-request-boundary.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { auditToolSelfHops } from './check-tool-request-boundary' +import { auditToolSelfHops, mayAccessToolRequest } from './check-tool-request-boundary' const ENCODED_ID_TEMPLATE = '$' + '{encodeURIComponent(params.id)}' const GET_BASE_URL_TEMPLATE = '$' + '{getBaseUrl()}' @@ -1085,3 +1085,17 @@ describe('tool self-hop audit', () => { ]) }) }) + +describe('tool request access candidate scan', () => { + it('finds direct request member access', () => { + expect(mayAccessToolRequest('const url = tool.request.url')).toBe(true) + }) + + it('decodes escaped identifiers and property strings', () => { + expect(mayAccessToolRequest(String.raw`const url = tool.req\u0075est['\u0075rl']`)).toBe(true) + }) + + it('ignores request objects that are never executed directly', () => { + expect(mayAccessToolRequest("const request = { endpoint: '/v1/items' }")).toBe(false) + }) +}) diff --git a/scripts/check-tool-request-boundary.ts b/scripts/check-tool-request-boundary.ts index dc2f7d2ce1f..12da20a5eb9 100644 --- a/scripts/check-tool-request-boundary.ts +++ b/scripts/check-tool-request-boundary.ts @@ -10,12 +10,14 @@ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs' import { dirname, extname, join, relative, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { parse } from '@babel/parser' +import ts from '@typescript/typescript6' const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)) const ROOT = resolve(SCRIPT_DIR, '..') const APP = join(ROOT, 'apps/sim') const CANONICAL_TRANSPORT = join(APP, 'tools/request-transport.ts') const REQUEST_MEMBERS = new Set(['url', 'method', 'headers', 'body']) +const REQUEST_CANDIDATE_TOKENS = new Set(['request', ...REQUEST_MEMBERS]) const SIM_URLS_MODULE = '@/lib/core/utils/urls' const SIM_ORIGIN_EXPORTS = new Set(['getBaseUrl', 'getInternalApiBaseUrl']) const SIM_URL_BUILDER_EXPORTS = new Set(['ensureAbsoluteUrl']) @@ -1596,8 +1598,7 @@ function getResolvedObjectProperties( } /** Rejects tool definitions that route execution back through this Sim app. */ -export function auditToolSelfHops(source: string, file = 'source.ts'): ToolSelfHopAudit { - const program = parseProgram(source, file) +function auditToolSelfHopProgram(program: SyntaxNode, file: string): ToolSelfHopAudit { const violations: ToolSelfHopViolation[] = [] let detectedSelfHops = 0 let legacyInternalPolicies = 0 @@ -1753,6 +1754,10 @@ export function auditToolSelfHops(source: string, file = 'source.ts'): ToolSelfH return { violations, detectedSelfHops, legacyInternalPolicies } } +export function auditToolSelfHops(source: string, file = 'source.ts'): ToolSelfHopAudit { + return auditToolSelfHopProgram(parseProgram(source, file), file) +} + function getStaticMemberAccess( expression: SyntaxNode ): { target: SyntaxNode; member: string } | undefined { @@ -1809,17 +1814,11 @@ function isLikelyToolIdentifier(expression: SyntaxNode): boolean { ) } -function findToolRequestBoundaryViolations(source: string, file = 'source.ts'): Violation[] { - const extension = extname(file) - const syntaxTree = parse(source, { - sourceFilename: file, - sourceType: 'unambiguous', - errorRecovery: true, - plugins: [ - ...(extension === '.jsx' || extension === '.tsx' ? (['jsx'] as const) : []), - ...(!['.js', '.jsx', '.mjs', '.cjs'].includes(extension) ? (['typescript'] as const) : []), - ], - }) +function findToolRequestBoundaryViolations( + program: SyntaxNode, + source: string, + file: string +): Violation[] { const requestAliases = new Set() const violations: Violation[] = [] const seen = new Set() @@ -1850,7 +1849,7 @@ function findToolRequestBoundaryViolations(source: string, file = 'source.ts'): } for (const child of getChildNodes(node)) collectAliases(child) } - collectAliases(syntaxTree.program) + collectAliases(program) const visit = (node: SyntaxNode) => { if ( @@ -1905,19 +1904,52 @@ function findToolRequestBoundaryViolations(source: string, file = 'source.ts'): } for (const child of getChildNodes(node)) visit(child) } - visit(syntaxTree.program) + visit(program) return violations } +export function mayAccessToolRequest(source: string): boolean { + const scanner = ts.createScanner(ts.ScriptTarget.Latest, true, ts.LanguageVariant.JSX, source) + let hasRequest = false + let hasRequestMember = false + + for (let token = scanner.scan(); token !== ts.SyntaxKind.EndOfFileToken; token = scanner.scan()) { + if ( + token !== ts.SyntaxKind.Identifier && + token !== ts.SyntaxKind.StringLiteral && + token !== ts.SyntaxKind.NoSubstitutionTemplateLiteral + ) { + continue + } + const value = scanner.getTokenValue() + if (!REQUEST_CANDIDATE_TOKENS.has(value)) continue + if (value === 'request') hasRequest = true + else hasRequestMember = true + if (hasRequest && hasRequestMember) return true + } + return false +} + function main(): void { const productionSources = collectProductionSources(APP) - const violations = productionSources - .filter((file) => file !== CANONICAL_TRANSPORT) - .flatMap((file) => findToolRequestBoundaryViolations(readFileSync(file, 'utf8'), file)) - const selfHopAudits = productionSources - .filter((file) => file.startsWith(join(APP, 'tools'))) - .map((file) => auditToolSelfHops(readFileSync(file, 'utf8'), file)) + const violations: Violation[] = [] + const selfHopAudits: ToolSelfHopAudit[] = [] + + for (const file of productionSources) { + const isToolSource = file.startsWith(join(APP, 'tools')) + const source = readFileSync(file, 'utf8') + const auditsDirectAccess = file !== CANONICAL_TRANSPORT && mayAccessToolRequest(source) + if (!isToolSource && !auditsDirectAccess) continue + + const program = parseProgram(source, file) + if (auditsDirectAccess) { + violations.push(...findToolRequestBoundaryViolations(program, source, file)) + } + if (isToolSource) { + selfHopAudits.push(auditToolSelfHopProgram(program, file)) + } + } const selfHopViolations = selfHopAudits.flatMap((audit) => audit.violations) if (violations.length > 0) { diff --git a/scripts/check-utils-enforcement.ts b/scripts/check-utils-enforcement.ts index 3239ef8aa9c..a44162f89e1 100644 --- a/scripts/check-utils-enforcement.ts +++ b/scripts/check-utils-enforcement.ts @@ -171,23 +171,33 @@ async function main() { if (ALLOWLISTED_FILES.has(rel)) continue const content = await readFile(file, 'utf8') - const lines = content.split('\n') - const lineStarts = buildLineStarts(content) + const matches: Array<{ + index: number + description: string + suggestion: string + }> = [] for (const { pattern, description, suggestion } of BANNED_PATTERNS) { pattern.lastIndex = 0 for (let match = pattern.exec(content); match !== null; match = pattern.exec(content)) { - const line = lineAt(lineStarts, match.index) - if (hasAllow(lines, line)) continue - violations.push({ - file: rel, - line, - description, - suggestion, - snippet: (lines[line - 1] ?? '').trim(), - }) + matches.push({ index: match.index, description, suggestion }) } } + if (matches.length === 0) continue + + const lines = content.split('\n') + const lineStarts = buildLineStarts(content) + for (const match of matches) { + const line = lineAt(lineStarts, match.index) + if (hasAllow(lines, line)) continue + violations.push({ + file: rel, + line, + description: match.description, + suggestion: match.suggestion, + snippet: (lines[line - 1] ?? '').trim(), + }) + } } if (violations.length === 0) { diff --git a/scripts/format-generated-source.ts b/scripts/format-generated-source.ts index 538d3643489..63de2c80dde 100644 --- a/scripts/format-generated-source.ts +++ b/scripts/format-generated-source.ts @@ -1,7 +1,8 @@ import { spawnSync } from 'node:child_process' +import { localBin } from './local-bin' export function formatGeneratedSource(source: string, stdinFilePath: string, cwd: string): string { - const result = spawnSync('bunx', ['biome', 'format', '--stdin-file-path', stdinFilePath], { + const result = spawnSync(localBin('biome'), ['format', '--stdin-file-path', stdinFilePath], { cwd, encoding: 'utf8', input: source, diff --git a/scripts/generate-docs.ts b/scripts/generate-docs.ts index 6e47f07393a..44df9dd0131 100755 --- a/scripts/generate-docs.ts +++ b/scripts/generate-docs.ts @@ -42,6 +42,34 @@ const LANDING_INTEGRATIONS_DATA_PATH = path.join( 'apps/sim/app/(landing)/integrations/data' ) const TRIGGERS_PATH = path.join(rootDir, 'apps/sim/triggers') +const sourceFileCache = new Map() +const sourceGlobCache = new Map>() +const blockConfigCache = new Map>() + +function readSourceFile(filePath: string): string { + const cached = sourceFileCache.get(filePath) + if (cached !== undefined) return cached + const source = fs.readFileSync(filePath, 'utf-8') + sourceFileCache.set(filePath, source) + return source +} + +async function sourceGlob(pattern: string): Promise { + let pending = sourceGlobCache.get(pattern) + if (!pending) { + pending = glob(pattern) + sourceGlobCache.set(pattern, pending) + } + return [...(await pending)] +} + +function blockConfigsForFile(filePath: string): ReturnType { + const cached = blockConfigCache.get(filePath) + if (cached) return cached + const configs = extractAllBlockConfigs(readSourceFile(filePath)) + blockConfigCache.set(filePath, configs) + return configs +} // Integration triggers are merged into the same per-service page as the service's // actions (one block per integration: actions + an optional Trigger). const TRIGGER_DOCS_OUTPUT_PATH = DOCS_OUTPUT_PATH @@ -473,7 +501,7 @@ function copyIconsFile(): void { return } - const iconsContent = fs.readFileSync(ICONS_PATH, 'utf-8') + const iconsContent = readSourceFile(ICONS_PATH) emitGeneratedFile(DOCS_ICONS_PATH, iconsContent) if (!CHECK_ONLY) console.log('✓ Icons successfully copied to docs app') @@ -489,12 +517,16 @@ function copyIconsFile(): void { * instead of the two-letter fallback. Never overwrites a block-derived entry — * the block is the canonical icon source when one exists. */ -async function addTriggerProviderIcons(iconMapping: Record): Promise { - const triggerFiles = (await glob(`${TRIGGERS_PATH}/**/*.ts`)).filter((f) => !f.includes('.test.')) +async function addTriggerProviderIcons( + iconMappings: readonly Record[] +): Promise { + const triggerFiles = (await sourceGlob(`${TRIGGERS_PATH}/**/*.ts`)).filter( + (f) => !f.includes('.test.') + ) const previewOnly = await collectPreviewOnlyTriggerIds() for (const file of triggerFiles) { - const fileContent = fs.readFileSync(file, 'utf-8') + const fileContent = readSourceFile(file) const source = stripSourceComments(fileContent) // Pair each trigger's `id` with the `provider` that follows it in the same @@ -505,7 +537,7 @@ async function addTriggerProviderIcons(iconMapping: Record): Pr for (const match of source.matchAll(configRegex)) { const [, triggerId, provider] = match - if (iconMapping[provider]) continue + if (iconMappings.every((iconMapping) => iconMapping[provider])) continue // Preview-only triggers get no page, so they need no provider icon. if (previewOnly.has(triggerId)) continue @@ -513,7 +545,10 @@ async function addTriggerProviderIcons(iconMapping: Record): Pr const iconName = extractIconNameFromContent(source.slice(match.index)) if (!iconName) continue - iconMapping[provider] = { name: iconName, source: resolveIconSource(fileContent, iconName) } + const iconRef = { name: iconName, source: resolveIconSource(fileContent, iconName) } + for (const iconMapping of iconMappings) { + if (!iconMapping[provider]) iconMapping[provider] = iconRef + } } } } @@ -523,17 +558,19 @@ async function addTriggerProviderIcons(iconMapping: Record): Pr * Docs need hidden historical version keys so old BlockInfoCard references and * versioned docs links still render icons, while landing only needs visible blocks. */ -async function generateIconMapping(options: { - includeHidden: boolean -}): Promise> { +async function generateIconMappings(): Promise<{ + docs: Record + visible: Record +}> { try { console.log('Generating icon mapping from block definitions...') - const iconMapping: Record = {} - const blockFiles = (await glob(`${BLOCKS_PATH}/*.ts`)).sort() + const docs: Record = {} + const visible: Record = {} + const blockFiles = (await sourceGlob(`${BLOCKS_PATH}/*.ts`)).sort() for (const blockFile of blockFiles) { - const fileContent = fs.readFileSync(blockFile, 'utf-8') + const fileContent = readSourceFile(blockFile) // For icon mapping, we need ALL blocks including hidden ones // because V2 blocks inherit icons from legacy blocks via spread @@ -603,26 +640,30 @@ async function generateIconMapping(options: { * hidden versioned block. Without this it renders as a text tile. */ const isSunsetBlockType = /sunset\s*:\s*\{/.test(stripSourceComments(blockContent)) - if ( - !hideFromToolbar || - (options.includeHidden && (isVersionedBlockType || isSunsetBlockType)) - ) { - iconMapping[blockType] = { - name: iconName, - source: resolveIconSource(fileContent, iconName), - } + const iconRef = { + name: iconName, + source: resolveIconSource(fileContent, iconName), + } + if (!hideFromToolbar) { + docs[blockType] = iconRef + visible[blockType] = iconRef + } else if (isVersionedBlockType || isSunsetBlockType) { + docs[blockType] = iconRef } } } } - await addTriggerProviderIcons(iconMapping) + await addTriggerProviderIcons([docs, visible]) - console.log(`✓ Generated icon mapping for ${Object.keys(iconMapping).length} blocks`) - return iconMapping + console.log( + `✓ Generated icon mappings for ${Object.keys(docs).length} docs blocks and ` + + `${Object.keys(visible).length} visible blocks` + ) + return { docs, visible } } catch (error) { console.error('Error generating icon mapping:', error) - return {} + return { docs: {}, visible: {} } } } @@ -1227,11 +1268,11 @@ async function buildToolDescriptionMap(): Promise { const desc = new Map() const name = new Map() try { - const toolFiles = await glob(`${toolsDir}/**/*.ts`) + const toolFiles = await sourceGlob(`${toolsDir}/**/*.ts`) for (const file of toolFiles) { const basename = path.basename(file) if (basename === 'index.ts' || basename === 'types.ts') continue - const content = fs.readFileSync(file, 'utf-8') + const content = readSourceFile(file) // Find every `id: 'tool_id'` occurrence in the file. For each, search // the next ~600 characters for `name:` and `description:` fields, cutting @@ -1690,13 +1731,13 @@ async function buildTriggerRegistry(): Promise> { const registry = new Map() const SKIP = new Set(['index.ts', 'registry.ts', 'types.ts', 'constants.ts', 'utils.ts']) - const triggerFiles = (await glob(`${TRIGGERS_PATH}/**/*.ts`)).filter( + const triggerFiles = (await sourceGlob(`${TRIGGERS_PATH}/**/*.ts`)).filter( (f) => !SKIP.has(path.basename(f)) && !f.includes('.test.') ) for (const file of triggerFiles) { try { - const content = fs.readFileSync(file, 'utf-8') + const content = readSourceFile(file) // A file may export multiple TriggerConfig objects (e.g. v1 + v2 in // the same file). Extract all exported configs by splitting on the @@ -1810,12 +1851,12 @@ async function writeIntegrationsJson(iconMapping: Record): Prom const integrations: IntegrationEntry[] = [] const seenBaseTypes = new Set() - const blockFiles = (await glob(`${BLOCKS_PATH}/*.ts`)).sort() + const blockFiles = (await sourceGlob(`${BLOCKS_PATH}/*.ts`)).sort() for (const blockFile of blockFiles) { - const fileContent = fs.readFileSync(blockFile, 'utf-8') + const fileContent = readSourceFile(blockFile) const switchCaseMap = extractSwitchCaseToolMapping(fileContent) - const configs = extractAllBlockConfigs(fileContent) + const configs = blockConfigsForFile(blockFile) for (const config of configs) { const blockType = config.type @@ -2545,7 +2586,7 @@ function resolveConstReference( return null } - const typesContent = fs.readFileSync(typesFilePath, 'utf-8') + const typesContent = readSourceFile(typesFilePath) // Find the const definition // Pattern: export const CONST_NAME = { ... } as const @@ -2936,7 +2977,7 @@ function resolveFactorySource(fileContent: string, toolFilePath: string, rootDir : path.resolve(path.dirname(toolFilePath), specifier) for (const candidate of [`${resolved}.ts`, path.join(resolved, 'index.ts')]) { - if (fs.existsSync(candidate)) return fs.readFileSync(candidate, 'utf-8') + if (fs.existsSync(candidate)) return readSourceFile(candidate) } return '' } @@ -2966,7 +3007,7 @@ function readImportedModuleSource( if (!resolved) return '' for (const candidate of [`${resolved}.ts`, path.join(resolved, 'index.ts')]) { - if (fs.existsSync(candidate)) return fs.readFileSync(candidate, 'utf-8') + if (fs.existsSync(candidate)) return readSourceFile(candidate) } return '' } @@ -3875,7 +3916,7 @@ export async function getToolInfo( for (const location of possibleLocations) { if (fs.existsSync(location.path)) { - const content = fs.readFileSync(location.path, 'utf-8') + const content = readSourceFile(location.path) const toolIdRegex = new RegExp(`id:\\s*['"]${toolName}['"]`) if (toolIdRegex.test(content)) { @@ -3900,11 +3941,11 @@ export async function getToolInfo( if (!foundExactId) { const prefixDir = path.join(rootDir, `apps/sim/tools/${toolPrefix}`) if (fs.existsSync(prefixDir)) { - const dirFiles = await glob(`${prefixDir}/**/*.ts`) + const dirFiles = await sourceGlob(`${prefixDir}/**/*.ts`) const toolIdRegex = new RegExp(`id:\\s*['"]${toolName}['"]`) for (const dirFile of dirFiles) { if (dirFile.endsWith('.test.ts')) continue - const content = fs.readFileSync(dirFile, 'utf-8') + const content = readSourceFile(dirFile) if (toolIdRegex.test(content)) { toolFileContent = content foundFile = dirFile @@ -3919,7 +3960,7 @@ export async function getToolInfo( if (!toolFileContent) { for (const location of possibleLocations) { if (fs.existsSync(location.path)) { - toolFileContent = fs.readFileSync(location.path, 'utf-8') + toolFileContent = readSourceFile(location.path) foundFile = location.path break } @@ -4046,10 +4087,10 @@ async function generateBlockDoc(blockPath: string) { return } - const fileContent = fs.readFileSync(blockPath, 'utf-8') + const fileContent = readSourceFile(blockPath) // Extract ALL block configs from the file (already filters out hideFromToolbar: true) - const blockConfigs = extractAllBlockConfigs(fileContent) + const blockConfigs = blockConfigsForFile(blockPath) if (blockConfigs.length === 0) { console.warn(`Skipping ${blockFileName} - no valid block configs found`) @@ -4287,11 +4328,10 @@ ${toolsSection} */ async function getCanonicalToolDocNames(): Promise> { const validToolDocs = new Set() - const blockFiles = (await glob(`${BLOCKS_PATH}/*.ts`)).sort() + const blockFiles = (await sourceGlob(`${BLOCKS_PATH}/*.ts`)).sort() for (const blockFile of blockFiles) { - const fileContent = fs.readFileSync(blockFile, 'utf-8') - const configs = extractAllBlockConfigs(fileContent) + const configs = blockConfigsForFile(blockFile) for (const config of configs) { // Match the writer filter: integration blocks, the documented @@ -4571,14 +4611,14 @@ async function buildFullTriggerRegistry(): Promise> const registry = new Map() const SKIP = new Set(['index.ts', 'registry.ts', 'types.ts', 'constants.ts', 'utils.ts']) - const triggerFiles = (await glob(`${TRIGGERS_PATH}/**/*.ts`)).filter( + const triggerFiles = (await sourceGlob(`${TRIGGERS_PATH}/**/*.ts`)).filter( (f) => !SKIP.has(path.basename(f)) && !f.includes('.test.') ) const registryTriggers = await loadTriggerRegistry() for (const file of triggerFiles) { try { - const content = fs.readFileSync(file, 'utf-8') + const content = readSourceFile(file) const exportRegex = /export\s+const\s+\w+\s*:\s*TriggerConfig\s*=\s*\{/g let exportMatch: RegExpExecArray | null @@ -4780,11 +4820,10 @@ ${buildTriggersSection(triggers)}` */ async function buildProviderColorMap(): Promise> { const colorMap = new Map() - const blockFiles = (await glob(`${BLOCKS_PATH}/*.ts`)).sort() + const blockFiles = (await sourceGlob(`${BLOCKS_PATH}/*.ts`)).sort() for (const blockFile of blockFiles) { - const fileContent = fs.readFileSync(blockFile, 'utf-8') - const configs = extractAllBlockConfigs(fileContent) + const configs = blockConfigsForFile(blockFile) for (const config of configs) { if (config.bgColor && config.type) { const baseType = stripVersionSuffix(config.type) @@ -4812,9 +4851,9 @@ async function collectPreviewOnlyTriggerIds(): Promise> { const listedByReleased = new Set() const listedByPreview = new Set() - const blockFiles = (await glob(`${BLOCKS_PATH}/*.ts`)).sort() + const blockFiles = (await sourceGlob(`${BLOCKS_PATH}/*.ts`)).sort() for (const blockFile of blockFiles) { - const fileContent = fs.readFileSync(blockFile, 'utf-8') + const fileContent = readSourceFile(blockFile) const exportRegex = /export\s+const\s+(\w+)Block\s*:\s*BlockConfig[^=]*=\s*\{/g let match: RegExpExecArray | null @@ -4921,12 +4960,11 @@ async function generateAllTriggerDocs(): Promise { async function generateAllBlockDocs() { try { - const blockFiles = (await glob(`${BLOCKS_PATH}/*.ts`)).sort() + const blockFiles = (await sourceGlob(`${BLOCKS_PATH}/*.ts`)).sort() copyIconsFile() - const docsIconMapping = await generateIconMapping({ includeHidden: true }) - const visibleIconMapping = await generateIconMapping({ includeHidden: false }) + const { docs: docsIconMapping, visible: visibleIconMapping } = await generateIconMappings() writeIconMapping(docsIconMapping) await writeIntegrationsJson(visibleIconMapping) diff --git a/scripts/openapi/documents.test.ts b/scripts/openapi/documents.test.ts index 69930110e71..163b983369f 100644 --- a/scripts/openapi/documents.test.ts +++ b/scripts/openapi/documents.test.ts @@ -43,6 +43,17 @@ const EXPECTED_OPERATION_COUNTS = new Map([ ['apps/docs/openapi-v2-resources.json', 45], ]) +const generatedDocuments = new Map<(typeof DOCUMENTS)[number], JsonObject>() + +function generatedDocument(document: (typeof DOCUMENTS)[number]): JsonObject { + const cached = generatedDocuments.get(document) + if (cached) return cached + + const generated = generateOpenApiDocument(document) + generatedDocuments.set(document, generated) + return generated +} + function getOperation(spec: JsonObject, path: string, method: string): JsonObject { const paths = spec.paths as JsonObject return (paths[path] as JsonObject)[method] as JsonObject @@ -147,7 +158,7 @@ describe('generated OpenAPI documents', () => { let totalOperations = 0 for (const document of DOCUMENTS) { - const spec = generateOpenApiDocument(document) + const spec = generatedDocument(document) const documentOperations = operations(spec) const expectedCount = EXPECTED_OPERATION_COUNTS.get(document.output) @@ -176,7 +187,7 @@ describe('generated OpenAPI documents', () => { }) it('documents mixed workflow execution and resume responses', () => { - const spec = generateOpenApiDocument(workflowsOpenApiDocument) + const spec = generatedDocument(workflowsOpenApiDocument) const execute = getOperation(spec, '/api/v2/workflows/{workflowId}/execute', 'post') const executeResponses = execute.responses as JsonObject const executeOk = executeResponses['200'] as JsonObject @@ -220,7 +231,7 @@ describe('generated OpenAPI documents', () => { }) it('documents multipart uploads, dual-status secret sets, and nullable file shares', () => { - const knowledgeSpec = generateOpenApiDocument(knowledgeOpenApiDocument) + const knowledgeSpec = generatedDocument(knowledgeOpenApiDocument) const upload = getOperation( knowledgeSpec, '/api/v2/knowledge/{knowledgeBaseId}/documents', @@ -238,13 +249,13 @@ describe('generated OpenAPI documents', () => { expect(Object.keys(uploadContent)).toEqual(['multipart/form-data']) expect(uploadProperties.file).toMatchObject({ type: 'string', format: 'binary' }) - const resourcesSpec = generateOpenApiDocument(resourcesOpenApiDocument) + const resourcesSpec = generatedDocument(resourcesOpenApiDocument) const setSecret = getOperation(resourcesSpec, '/api/v2/secrets/{name}', 'put') expect( Object.keys(setSecret.responses as JsonObject).filter((status) => status.startsWith('2')) ).toEqual(['200', '201']) - const filesSpec = generateOpenApiDocument(filesAuditOpenApiDocument) + const filesSpec = generatedDocument(filesAuditOpenApiDocument) const fileSchemas = (filesSpec.components as JsonObject).schemas as JsonObject const fileMetadata = fileSchemas.V2FileMetadata as JsonObject const fileMetadataProperties = fileMetadata.properties as JsonObject @@ -254,12 +265,12 @@ describe('generated OpenAPI documents', () => { }) it('documents public resource owner email addresses', () => { - const knowledgeSpec = generateOpenApiDocument(knowledgeOpenApiDocument) + const knowledgeSpec = generatedDocument(knowledgeOpenApiDocument) const knowledgeSchemas = (knowledgeSpec.components as JsonObject).schemas as JsonObject const knowledgeBase = knowledgeSchemas.V2KnowledgeBase as JsonObject const knowledgeBaseProperties = knowledgeBase.properties as JsonObject - const tablesSpec = generateOpenApiDocument(tablesOpenApiDocument) + const tablesSpec = generatedDocument(tablesOpenApiDocument) const tableSchemas = (tablesSpec.components as JsonObject).schemas as JsonObject const table = tableSchemas.V2ApiTable as JsonObject const tableProperties = table.properties as JsonObject @@ -269,14 +280,14 @@ describe('generated OpenAPI documents', () => { }) it('keeps billing as its own API reference group', () => { - const spec = generateOpenApiDocument(billingOpenApiDocument) + const spec = generatedDocument(billingOpenApiDocument) expect((spec.tags as JsonObject[]).map((tag) => tag.name)).toEqual(['Billing']) expect(getOperation(spec, '/api/v2/billing/status', 'get').tags).toEqual(['Billing']) expect(getOperation(spec, '/api/v2/billing/logs', 'get').tags).toEqual(['Billing']) }) it('documents workspace details as a named schema without internal mode', () => { - const resourcesSpec = generateOpenApiDocument(resourcesOpenApiDocument) + const resourcesSpec = generatedDocument(resourcesOpenApiDocument) const schemas = (resourcesSpec.components as JsonObject).schemas as JsonObject const response = schemas.GetWorkspaceResponse as JsonObject const responseProperties = response.properties as JsonObject @@ -297,7 +308,7 @@ describe('generated OpenAPI documents', () => { }) it('publishes Agent tools as named integration, custom, and MCP schemas', () => { - const workflowsSpec = generateOpenApiDocument(workflowsOpenApiDocument) + const workflowsSpec = generatedDocument(workflowsOpenApiDocument) const schemas = (workflowsSpec.components as JsonObject).schemas as JsonObject const agentToolInput = schemas.AgentToolInput as JsonObject const agentTool = schemas.AgentTool as JsonObject @@ -354,7 +365,7 @@ describe('generated OpenAPI documents', () => { it('uses named schemas for top-level response objects and list items', () => { for (const document of DOCUMENTS) { - expect(anonymousTopLevelResponseObjects(generateOpenApiDocument(document))).toEqual([]) + expect(anonymousTopLevelResponseObjects(generatedDocument(document))).toEqual([]) } }) @@ -438,7 +449,7 @@ describe('documented error sets', () => { * selection is an empty page. `getAuditLog` does 404 and keeps it. */ it('does not publish a 404 the audit-log list cannot emit', () => { - const spec = generateOpenApiDocument(filesAuditOpenApiDocument) + const spec = generatedDocument(filesAuditOpenApiDocument) expect( Object.keys(getOperation(spec, '/api/v2/audit-logs', 'get').responses as JsonObject) ).not.toContain('404') @@ -483,7 +494,7 @@ describe('shared parameter descriptions do not fork', () => { const descriptionsByParameter = new Map>() for (const document of DOCUMENTS) { - const spec = generateOpenApiDocument(document) + const spec = generatedDocument(document) for (const operation of operations(spec)) { for (const parameter of (operation.parameters ?? []) as JsonObject[]) { const name = parameter.name as string diff --git a/scripts/openapi/generator.test.ts b/scripts/openapi/generator.test.ts index c44ee3d0451..b6b9b453fb9 100644 --- a/scripts/openapi/generator.test.ts +++ b/scripts/openapi/generator.test.ts @@ -21,6 +21,18 @@ import { } from './generator' type JsonObject = Record +type OpenApiDocument = Parameters[0] + +const generatedDocuments = new Map() + +function generatedDocument(document: OpenApiDocument): JsonObject { + const cached = generatedDocuments.get(document) + if (cached) return cached + + const generated = generateOpenApiDocument(document) + generatedDocuments.set(document, generated) + return generated +} const ERROR_SCHEMA = z .object({ @@ -578,7 +590,7 @@ describe('OpenAPI generator', () => { }) it('documents nullable file share metadata from the response schema', () => { - const spec = generateOpenApiDocument(filesAuditOpenApiDocument) + const spec = generatedDocument(filesAuditOpenApiDocument) const schemas = (spec.components as JsonObject).schemas as JsonObject const metadata = schemas.V2FileMetadata as JsonObject const properties = metadata.properties as JsonObject @@ -588,7 +600,7 @@ describe('OpenAPI generator', () => { }) it('documents v2 billing storage coverage from the response schema', () => { - const spec = generateOpenApiDocument(billingOpenApiDocument) + const spec = generatedDocument(billingOpenApiDocument) const paths = spec.paths as JsonObject const schemas = (spec.components as JsonObject).schemas as JsonObject const response = schemas.V2BillingStatusResponse as JsonObject @@ -626,7 +638,7 @@ describe('OpenAPI generator', () => { * is why it stays and is pinned here instead. */ it('uses string wire values for a stringbool query param', () => { - const spec = generateOpenApiDocument(filesAuditOpenApiDocument) + const spec = generatedDocument(filesAuditOpenApiDocument) const deleteFolder = getOperation(spec, '/api/v2/files/folders', 'delete') const deleteFolderParameters = deleteFolder.parameters as JsonObject[] const recursive = deleteFolderParameters.find((parameter) => parameter.name === 'recursive') @@ -641,14 +653,14 @@ describe('OpenAPI generator', () => { * callers to send a string for what four sibling params took as a boolean. */ it('documents boolean query flags as booleans', () => { - const auditSpec = generateOpenApiDocument(filesAuditOpenApiDocument) + const auditSpec = generatedDocument(filesAuditOpenApiDocument) const listAuditLogParameters = getOperation(auditSpec, '/api/v2/audit-logs', 'get') .parameters as JsonObject[] const includeDeparted = listAuditLogParameters.find( (parameter) => parameter.name === 'includeDeparted' ) - const workflowSpec = generateOpenApiDocument(workflowsOpenApiDocument) + const workflowSpec = generatedDocument(workflowsOpenApiDocument) const getRunParameters = getOperation( workflowSpec, '/api/v2/workflows/{workflowId}/runs/{runId}', @@ -661,7 +673,7 @@ describe('OpenAPI generator', () => { }) it('documents binary download response headers', () => { - const spec = generateOpenApiDocument(filesAuditOpenApiDocument) + const spec = generatedDocument(filesAuditOpenApiDocument) const operation = getOperation(spec, '/api/v2/files/{fileId}', 'get') const response = (operation.responses as JsonObject)['200'] as JsonObject @@ -717,7 +729,7 @@ describe('OpenAPI generator', () => { }) it('publishes a distinct example under every documented error status', () => { - const spec = generateOpenApiDocument(workflowsOpenApiDocument) + const spec = generatedDocument(workflowsOpenApiDocument) const responses = (spec.components as JsonObject).responses as JsonObject const byStatus = new Map>() diff --git a/scripts/openapi/generator.ts b/scripts/openapi/generator.ts index cc8cd84c0f2..550113a1ce8 100644 --- a/scripts/openapi/generator.ts +++ b/scripts/openapi/generator.ts @@ -10,6 +10,7 @@ import type { } from '@/lib/api/openapi/types' type JsonObject = Record +type SchemaIo = 'input' | 'output' const HTTP_SUCCESS_MIN = 200 const HTTP_SUCCESS_MAX = 299 @@ -41,6 +42,12 @@ const outputExampleValidator = new Ajv2020({ allErrors: true, validateFormats: false, }) +const comparableSchemaCache = new WeakMap>() +const generatedSchemaCache = new WeakMap>() +const outputValidatorCache = new WeakMap< + ApiSchema, + ReturnType +>() function invariant(condition: unknown, message: string): asserts condition { if (!condition) throw new Error(message) @@ -98,8 +105,11 @@ function stripLegacySchemaIds(value: unknown): unknown { ) } -function comparableSchema(schema: ApiSchema, io: 'input' | 'output'): unknown { - return stripSchemaDocumentation( +function comparableSchema(schema: ApiSchema, io: SchemaIo): unknown { + const cached = comparableSchemaCache.get(schema)?.get(io) + if (cached) return cached + + const comparable = stripSchemaDocumentation( sanitizeSchema( z.toJSONSchema(schema, { io, @@ -110,6 +120,10 @@ function comparableSchema(schema: ApiSchema, io: 'input' | 'output'): unknown { }) ) ) + const byIo = comparableSchemaCache.get(schema) ?? new Map() + byIo.set(io, comparable) + comparableSchemaCache.set(schema, byIo) + return comparable } function addComponent( @@ -178,28 +192,35 @@ function validateNoSilentOpaqueSchemas(schema: JsonObject, label: string, path = function generateSchema( schema: ApiSchema, - io: 'input' | 'output', + io: SchemaIo, components: JsonObject, label: string, includeRootComponent = true ): { name: string; schema: JsonObject; metadata: z.core.GlobalMeta } { const metadata = schemaMetadata(schema, label) - const { $defs: definitions, ...generated } = z.toJSONSchema(schema, { - io, - target: 'draft-2020-12', - unrepresentable: 'any', - cycles: 'ref', - reused: 'inline', - override: ({ zodSchema, path }) => { - const current = zodSchema as ApiSchema - validateExamples( - current, - z.globalRegistry.get(current)?.examples, - io, - `${label} at ${path.join('.') || ''}` - ) - }, - }) as JsonObject + let generatedWithDefinitions = generatedSchemaCache.get(schema)?.get(io) + if (!generatedWithDefinitions) { + generatedWithDefinitions = z.toJSONSchema(schema, { + io, + target: 'draft-2020-12', + unrepresentable: 'any', + cycles: 'ref', + reused: 'inline', + override: ({ zodSchema, path }) => { + const current = zodSchema as ApiSchema + validateExamples( + current, + z.globalRegistry.get(current)?.examples, + io, + `${label} at ${path.join('.') || ''}` + ) + }, + }) as JsonObject + const byIo = generatedSchemaCache.get(schema) ?? new Map() + byIo.set(io, generatedWithDefinitions) + generatedSchemaCache.set(schema, byIo) + } + const { $defs: definitions, ...generated } = generatedWithDefinitions if (definitions !== undefined) { invariant( @@ -377,12 +398,7 @@ function statusSuccessContent( return content } -function validateExamples( - schema: ApiSchema, - examples: unknown, - io: 'input' | 'output', - label: string -): void { +function validateExamples(schema: ApiSchema, examples: unknown, io: SchemaIo, label: string): void { if (examples === undefined) return invariant( Array.isArray(examples) && examples.length > 0, @@ -397,14 +413,18 @@ function validateExamples( ) continue } - const outputSchema = z.toJSONSchema(schema, { - io: 'output', - target: 'draft-2020-12', - unrepresentable: 'any', - cycles: 'ref', - reused: 'inline', - }) - const validate = outputExampleValidator.compile(stripLegacySchemaIds(outputSchema)) + let validate = outputValidatorCache.get(schema) + if (!validate) { + const outputSchema = z.toJSONSchema(schema, { + io: 'output', + target: 'draft-2020-12', + unrepresentable: 'any', + cycles: 'ref', + reused: 'inline', + }) + validate = outputExampleValidator.compile(stripLegacySchemaIds(outputSchema)) + outputValidatorCache.set(schema, validate) + } invariant( validate(example), `${label} example ${index + 1} is invalid for the output schema: ${outputExampleValidator.errorsText(validate.errors)}` @@ -494,8 +514,9 @@ function operationFor( if (!contractSchema) continue invariant(documentedSchema, `${label} is missing its documented ${name} schema`) invariant( - JSON.stringify(comparableSchema(contractSchema, io)) === - JSON.stringify(comparableSchema(documentedSchema, io)), + contractSchema === documentedSchema || + JSON.stringify(comparableSchema(contractSchema, io)) === + JSON.stringify(comparableSchema(documentedSchema, io)), `${label} documented ${name} schema does not match the contract schema` ) } @@ -529,8 +550,9 @@ function operationFor( } for (const status of expectedStatuses) { invariant( - JSON.stringify(comparableSchema(contractStatusSchemas[status], 'output')) === - JSON.stringify(comparableSchema(schemas.responses[status], 'output')), + contractStatusSchemas[status] === schemas.responses[status] || + JSON.stringify(comparableSchema(contractStatusSchemas[status], 'output')) === + JSON.stringify(comparableSchema(schemas.responses[status], 'output')), `${label} documented schema for status ${status} does not match the contract schema` ) } From 2a3cea0d2f732fa999df5d6cce5259ec2b4232bc Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 1 Sep 2026 11:46:40 -0700 Subject: [PATCH 2/3] fix(audits): resolve local bins under vitest --- scripts/local-bin.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/local-bin.ts b/scripts/local-bin.ts index de00cc5f2c5..8ef07336b67 100644 --- a/scripts/local-bin.ts +++ b/scripts/local-bin.ts @@ -1,6 +1,7 @@ import path from 'node:path' +import { fileURLToPath } from 'node:url' -const ROOT = path.resolve(import.meta.dir, '..') +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') /** * Absolute path to a locally-installed executable. From 81be2339218aa40c4f3a7703479aa086b399687e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 1 Sep 2026 11:48:28 -0700 Subject: [PATCH 3/3] fix(audits): include drizzle subpath imports --- scripts/check-db-audit-candidates.test.ts | 4 ++++ scripts/check-sql-date-binding.ts | 19 ++++++++++--------- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/scripts/check-db-audit-candidates.test.ts b/scripts/check-db-audit-candidates.test.ts index 02ce386329f..20f93abaee8 100644 --- a/scripts/check-db-audit-candidates.test.ts +++ b/scripts/check-db-audit-candidates.test.ts @@ -31,6 +31,10 @@ describe('database audit candidate scans', () => { 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) }) diff --git a/scripts/check-sql-date-binding.ts b/scripts/check-sql-date-binding.ts index 131155dacae..3078a7b89db 100644 --- a/scripts/check-sql-date-binding.ts +++ b/scripts/check-sql-date-binding.ts @@ -28,6 +28,13 @@ const ALLOW_ANNOTATION = '// sql-date-bound:' const DRIZZLE_MODULE = 'drizzle-orm' const STATEMENT_TYPE = /(Statement|Declaration)$/ +function isDrizzleModule(value: unknown): value is string { + return ( + typeof value === 'string' && + (value === DRIZZLE_MODULE || value.startsWith(`${DRIZZLE_MODULE}/`)) + ) +} + interface Violation { file: string line: number @@ -223,10 +230,7 @@ function collectSqlBindings(program: SyntaxNode): SqlBindings { const visit = (node: SyntaxNode) => { if (node.type === 'ImportDeclaration' && isSyntaxNode(node.source)) { const source = node.source.value - const isDrizzle = - typeof source === 'string' && - (source === DRIZZLE_MODULE || source.startsWith(`${DRIZZLE_MODULE}/`)) - if (isDrizzle && Array.isArray(node.specifiers)) { + if (isDrizzleModule(source) && Array.isArray(node.specifiers)) { for (const specifier of node.specifiers) { if (!isSyntaxNode(specifier) || !isSyntaxNode(specifier.local)) continue const local = specifier.local.name @@ -270,10 +274,7 @@ function isDrizzleImportCall(node: unknown): boolean { const args = Array.isArray(current.arguments) ? current.arguments : [] const source = isSyntaxNode(current.source) ? current.source : args.find(isSyntaxNode) const value = source?.value - return ( - typeof value === 'string' && - (value === DRIZZLE_MODULE || value.startsWith(`${DRIZZLE_MODULE}/`)) - ) + return isDrizzleModule(value) } const unwrapAwait = (node: SyntaxNode): unknown => @@ -562,7 +563,7 @@ export function mayBindDrizzleSql(source: string): boolean { if ( (token === ts.SyntaxKind.StringLiteral || token === ts.SyntaxKind.NoSubstitutionTemplateLiteral) && - value === DRIZZLE_MODULE + isDrizzleModule(value) ) { hasDrizzleModule = true }