chore(audits): check raw-route parseRequest contracts and enforce surface-neutral application imports (#8883)

* chore(audits): check raw-route parseRequest contracts and enforce surface-neutral application imports

check:route-verbs now resolves the contract each raw withRouteHandler route
passes to parseRequest and checks the exported verb and path against it
(322 sites across 258 files, previously unchecked). check:boundaries now bans
next/server and app/api imports, and runtime route-contract, presenter and
Copilot-handler imports, from application code. listSearchSources takes its
cursor route from the adapter instead of importing the route contract.

* fix(tests): pass cursorRoute in the org source-summary integration case

* fix(audits): resolve aliased contract imports and export-list verbs in check:route-verbs

Read a contract by the name its module exports, not the local alias, and
read handler verbs from export lists (export { GET }, export { h as GET })
as well as inline exports. Both builder and raw parseRequest sites share
the fix.

* fix(audits): follow aliased parseRequest imports and relative application imports

check:route-verbs matched raw parseRequest calls only by the literal name, so
`import { parseRequest as parse }` left the contract unchecked; it now matches
every local name bound to parseRequest from @/lib/api/server(/validation).

check:boundaries applied the application rules only to @/ specifiers, so a
relative import of a contract object, presenter, Copilot handler or app/api
module passed; relative specifiers are now normalized to their @/ form first.

* fix(audits): accept HEAD on a GET contract and treat empty import/export clauses as runtime edges

* fix(audits): keep the HEAD-on-GET allowance off defineScimRoute and catch extension-suffixed presenter imports

* fix(audits): follow exported non-verb helpers when resolving raw-route contracts
This commit is contained in:
Waleed
2026-10-10 01:23:13 -07:00
committed by GitHub
parent b2cd2a93d2
commit b6476ccde8
12 changed files with 758 additions and 179 deletions
@@ -226,7 +226,7 @@ Do not call shared authorization, principal audit attribution, or `recordAudit`
Inspect legacy orchestration before reusing it. If it already authorizes, audits, notifies, or captures analytics, call a lower-level primitive or remove duplicate responsibility for migrated callers.
Application code must remain surface-neutral. It must not import `app/api/**`, `next/server`, internal/v1/v2 contracts or presenters, or Copilot tool handlers. Return domain values and let each surface presenter project its own wire result.
Application code stays surface-neutral (`check:boundaries`): never `next/server` or `app/api/**`, and never a runtime import of a route contract object, presenter, or Copilot handler (contract types, schemas, and constants are fine); a surface fact, such as a cursor's route, comes in as input. Return domain values and let each surface presenter project its own wire result.
## Adapt internal APIs
+1 -1
View File
@@ -72,7 +72,7 @@ packages/
- Every protected read, write, canonical resource lookup, or authorization-sensitive reference resolution enters through an authorized application use case.
- Define one stable semantic operation with its minimum role, workspace-key policy, allowed principal kinds, and delegated services. Internal APIs, v2 APIs, Copilot, and trusted tools call the same use case when the domain behavior is the same.
- Surface adapters authenticate and construct a `Principal`, apply request-rate policy, parse contracts, map input, and present their own result. They never query protected data, decide resource authorization, implement business transactions, or record semantic audit.
- Application use cases load canonical context, compare asserted scope, authorize current access, execute managers/repositories, project semantic audit, and trigger shared domain effects. Managers accept canonical IDs and scope, never credentials or principals. Application code stays surface-neutral: it never imports `app/api/**`, `next/server`, route contracts/presenters, or Copilot handlers.
- Application use cases load canonical context, compare asserted scope, authorize current access, execute managers/repositories, project semantic audit, and trigger shared domain effects. Managers accept canonical IDs and scope, never credentials or principals. Application code stays surface-neutral (`check:boundaries`): never `next/server` or `app/api/**`, and never a runtime import of a route contract object, presenter, or Copilot handler (contract types, schemas, and constants are fine); a surface fact, such as a cursor's route, comes in as input.
- Copilot is a surface adapter. Use `createCopilotApplicationAdapter` and the domain's registered operation object; never a Copilot-only authorization or business implementation.
- Protected compound mutations belong in one top-level semantic application operation, never a sequence of independently committing mutations in a route or tool adapter.
- Never substitute a billing owner, uploader, creator, or API-key owner for the acting principal. Fail fast when the identity model or operation policy cannot express the caller.
@@ -1,4 +1,5 @@
import { listSearchSourcesContract } from '@/lib/api/contracts/knowledge'
import { cursorRoute } from '@/lib/api/cursor-binding'
import {
defineInternalJsonRoute,
internalRateLimits,
@@ -16,7 +17,7 @@ export const GET = defineInternalJsonRoute({
reason: 'Workspace source summaries for the Search page and indexing status polling',
}),
errorPolicy: internalKnowledgeErrorPolicies.connectors,
mapInput: ({ query }) => query,
mapInput: ({ query }) => ({ ...query, cursorRoute: cursorRoute(listSearchSourcesContract) }),
useCase: listSearchSources,
present: (page) => ({ success: true as const, data: page }),
staticResponseHeaders: { 'Cache-Control': 'private, no-store' },
@@ -23,7 +23,8 @@ const sourceIds = Array.from({ length: 105 }, () => generateId())
.sort()
.reverse()
const olderSourceId = generateId()
const input = { workspaceId: ids.workspaceId }
const cursorRoute = { method: 'GET', path: '/api/knowledge/sim-search/sources' }
const input = { workspaceId: ids.workspaceId, cursorRoute }
beforeAll(async () => {
await seedKnowledgeAclFixture(ids)
@@ -157,8 +158,10 @@ describe('bounded live Search source configuration pagination', () => {
connectorType: 'google_drive',
approved: false,
})
const owner = { organizationId: ids.organizationId }
const summary = await listSearchSources.execute({ principal: alice, input: owner })
const summary = await listSearchSources.execute({
principal: alice,
input: { organizationId: ids.organizationId, cursorRoute },
})
expect(summary.sources[0]).toMatchObject({
connectorId: approvalSourceId,
approved: false,
@@ -47,7 +47,8 @@ workspaceAuthzMockFns.mockPermissionSatisfies.mockImplementation(
)
const principal = createSessionPrincipal({ userId: 'reader', sessionId: 'session' })
const input = { workspaceId: 'workspace' }
const cursorRoute = { method: 'GET', path: '/api/knowledge/sim-search/sources' }
const input = { workspaceId: 'workspace', cursorRoute }
const LAST_SYNC = new Date('2026-09-05T12:00:00.000Z')
function source(id: string, connectorType = 'google_drive', accessMode = 'admin') {
@@ -156,7 +157,7 @@ describe('organization Search source summaries', () => {
seed([source('drive')])
const result = await listSearchSources.execute({
principal,
input: { organizationId: 'org-1' },
input: { organizationId: 'org-1', cursorRoute },
})
expect(result.sources[0]).toMatchObject({
connectorId: 'drive',
@@ -174,7 +175,7 @@ describe('organization Search source summaries', () => {
})
queueTableRows(member, [])
await expect(
listSearchSources.execute({ principal, input: { organizationId: 'org-1' } })
listSearchSources.execute({ principal, input: { organizationId: 'org-1', cursorRoute } })
).rejects.toThrow('Organization not found')
})
})
@@ -3,11 +3,8 @@ import { db } from '@sim/db'
import { knowledgeBase, knowledgeConnector } from '@sim/db/schema'
import { toRecord } from '@sim/utils/object'
import { and, desc, eq, inArray, isNull, lt, ne, or, sql } from 'drizzle-orm'
import {
listSearchSourcesContract,
searchSourceCursorSchema,
} from '@/lib/api/contracts/knowledge/connectors'
import { cursorRoute, cursorScopeKey } from '@/lib/api/cursor-binding'
import { searchSourceCursorSchema } from '@/lib/api/contracts/knowledge/connectors'
import { type CursorScopeRoute, cursorScopeKey } from '@/lib/api/cursor-binding'
import { OrchestrationError } from '@/lib/core/orchestration/types'
import { type ResourceOwner, resourceScopeFromOwner } from '@/lib/core/resource-scope'
import { resourceScopeCondition } from '@/lib/core/resource-scope.server'
@@ -24,6 +21,8 @@ import { describeSearchSource } from '@/lib/sim-search/source-identity'
import { getConnectorMeta } from '@/connectors/registry'
export interface ListSearchSourcesInput extends ResourceOwner {
/** The list endpoint the adapter's cursors belong to; binds every cursor to it. */
cursorRoute: CursorScopeRoute
cursor?: string
connectorId?: string
connectorType?: string
@@ -41,7 +40,7 @@ export const listSearchSources = defineAuthorizedKnowledgeUseCase({
const search = input.search?.trim().toLowerCase() ?? ''
const connectorType = input.connectorType?.trim()
const excludeConnectorType = input.excludeConnectorType?.trim()
const cursorScope = cursorScopeKey(cursorRoute(listSearchSourcesContract), {
const cursorScope = cursorScopeKey(input.cursorRoute, {
workspaceId: context.workspaceId,
organizationId: context.organizationId,
userId: userId,
@@ -68,7 +68,15 @@ describe('Search source direct tool', () => {
audience: 'sim:knowledge',
resourceScope: { chatId: 'actual-chat' },
}),
input: { organizationId: 'actual-org', cursor: 'previous' },
input: {
organizationId: 'actual-org',
cursor: 'previous',
cursorRoute: {
method: 'GET',
path: '/api/knowledge/sim-search/sources',
params: undefined,
},
},
})
expect(mocks.chat).toHaveBeenCalledBefore(mocks.list)
})
@@ -77,7 +85,17 @@ describe('Search source direct tool', () => {
tool.execute({ action: 'get', connectorId: 'foreign' }, context)
).rejects.toMatchObject({ code: 'not_found' })
expect(mocks.list).toHaveBeenCalledWith(
expect.objectContaining({ input: { organizationId: 'actual-org', connectorId: 'foreign' } })
expect.objectContaining({
input: {
organizationId: 'actual-org',
connectorId: 'foreign',
cursorRoute: {
method: 'GET',
path: '/api/knowledge/sim-search/sources',
params: undefined,
},
},
})
)
})
it('returns existing setup UI without creating an index, connecting or changing approval', async () => {
@@ -1,9 +1,11 @@
import { listSearchSourcesContract } from '@/lib/api/contracts/knowledge/connectors'
import {
type OrganizationSearchSourcesInput,
type OrganizationSearchSourcesOutput,
organizationSearchSourcesInputSchema,
organizationSearchSourcesOutputSchema,
} from '@/lib/api/contracts/mothership-search-sources'
import { cursorRoute } from '@/lib/api/cursor-binding'
import { OrchestrationError } from '@/lib/core/orchestration/types'
import { knowledgeDelegationPolicy } from '@/lib/knowledge/application/authorization'
import {
@@ -47,18 +49,23 @@ export const organizationSearchSourcesServerTool: BaseServerTool<
context?.userStopSignal?.throwIfAborted()
const input = organizationSearchSourcesInputSchema.parse(args)
const owner = { organizationId: trusted.organizationId }
// Shares the Search page's cursor namespace, so a page cursor stays valid on either surface.
const sourcesCursorRoute = cursorRoute(listSearchSourcesContract)
switch (input.action) {
case 'list': {
const { action, ...filters } = input
return {
action,
...(await listSearchSources.execute({ principal, input: { ...owner, ...filters } })),
...(await listSearchSources.execute({
principal,
input: { ...owner, ...filters, cursorRoute: sourcesCursorRoute },
})),
}
}
case 'get': {
const page = await listSearchSources.execute({
principal,
input: { ...owner, connectorId: input.connectorId },
input: { ...owner, connectorId: input.connectorId, cursorRoute: sourcesCursorRoute },
})
const source = page.sources[0]
if (!source) throw new OrchestrationError('not_found', 'Search source not found')
+64
View File
@@ -66,4 +66,68 @@ describe('package boundary command', () => {
rmSync(fixture, { recursive: true, force: true })
}
}, 30_000)
it('keeps application code surface-neutral while allowing shared contract vocabulary', () => {
const fixture = mkdtempSync(path.join(ROOT, 'node_modules/.boundary-audit-'))
try {
mkdirSync(path.join(fixture, 'scripts'))
mkdirSync(path.join(fixture, 'packages'))
mkdirSync(path.join(fixture, 'apps/sim/lib/widgets/application'), { recursive: true })
copyFileSync(
path.join(ROOT, 'scripts/check-monorepo-boundaries.ts'),
path.join(fixture, 'scripts/check-monorepo-boundaries.ts')
)
const useCase = 'apps/sim/lib/widgets/application/use-case.ts'
const cases = [
["import { NextResponse } from 'next/server'", useCase, false],
["import type { NextRequest } from 'next/server'", useCase, false],
["import { GET } from '@/app/api/widgets/route'", useCase, false],
["import { listWidgetsContract } from '@/lib/api/contracts/widgets'", useCase, false],
["export { listWidgetsContract } from '@/lib/api/contracts/widgets'", useCase, false],
["import * as contracts from '@/lib/api/contracts/widgets'", useCase, false],
["import { presentWidget } from '@/lib/api/server/widget-presenters'", useCase, false],
["import { presentWidget } from '@/lib/api/server/widget-presenters.ts'", useCase, false],
["import { run } from '@/lib/mothership/tools/handlers/run-code'", useCase, false],
["import {} from '@/lib/mothership/tools/handlers/run-code'", useCase, false],
["export {} from '@/lib/api/server/widget-presenters'", useCase, false],
["const tool = import('@/lib/mothership/tools/server/widgets')", useCase, false],
["import { listWidgetsContract } from '../../api/contracts/widgets'", useCase, false],
["import { GET } from '../../../app/api/widgets/route'", useCase, false],
["import type { ListWidgetsContract } from '../../api/contracts/widgets'", useCase, true],
["import type { ListWidgetsContract } from '@/lib/api/contracts/widgets'", useCase, true],
["import { type listWidgetsContract } from '@/lib/api/contracts/widgets'", useCase, true],
[
"import { widgetBodySchema, MAX_WIDGETS } from '@/lib/api/contracts/widgets'",
useCase,
true,
],
[
"import type { ServerToolContext } from '@/lib/mothership/tools/server/base-tool'",
useCase,
true,
],
[
"import { NextResponse } from 'next/server'",
'apps/sim/lib/widgets/application/use-case.test.ts',
true,
],
["import { NextResponse } from 'next/server'", 'apps/sim/lib/widgets/routes.ts', true],
] as const
for (const [source, file, allowed] of cases) {
rmSync(path.join(fixture, 'apps/sim/lib/widgets'), { recursive: true, force: true })
mkdirSync(path.join(fixture, 'apps/sim/lib/widgets/application'), { recursive: true })
writeFileSync(path.join(fixture, file), `${source}\n`)
const result = spawnSync(
'bun',
[path.join(fixture, 'scripts/check-monorepo-boundaries.ts')],
{ cwd: fixture, encoding: 'utf8' }
)
expect.soft(result.status, `${file}: ${source}\n${result.stderr}`).toBe(allowed ? 0 : 1)
if (!allowed) expect.soft(result.stderr).toContain(`${file}:1`)
}
} finally {
rmSync(fixture, { recursive: true, force: true })
}
}, 30_000)
})
+186 -54
View File
@@ -1,20 +1,26 @@
#!/usr/bin/env bun
/**
* Fails when any file under `packages/*` imports from `apps/*`.
* Import boundaries no type-check can see.
*
* Dependencies point toward shared code: apps consume packages, never the reverse. A package
* that reaches into an app drags that app's module graph (Next.js, the block and tool
* registries, the executor) into every other consumer, including `apps/realtime`, and couples
* the package to an app's internal layout.
* 1. Packages never import from `apps/*`. Dependencies point toward shared code: a package that
* reaches into an app drags that app's module graph (Next.js, the block and tool registries,
* the executor) into every other consumer, including `apps/realtime`. Flagged, including
* type-only imports, re-exports, `import()` and `require()`: an `@/…` or `apps/…` specifier,
* an app's package name (or a subpath of it), and a relative path that resolves into
* `apps/`. Fix by moving the shared code into a package both sides import, or by keeping the
* code that needs it in the app.
* 2. Application code (every non-test file under an `application/` folder in `apps/sim`) stays
* surface-neutral, so internal routes, v2, Copilot and jobs can all call the same use case:
* - never `next/server` or `@/app/api/**`, not even for a type;
* - never, at runtime, a route contract object (a `*Contract` binding from
* `@/lib/api/contracts`), a presenter module (`*-presenter(s)`) or a Copilot handler
* (`@/lib/mothership/tools/{handlers,server}/**`). Contract types, schemas and constants
* are shared domain vocabulary and stay allowed, as does `import type` of a Copilot
* context type.
* Fix by taking the surface fact (a cursor's route identity, a wire shape) as input from
* the adapter.
*
* Flagged, including type-only imports, re-exports, `import()` and `require()`: an `@/…` or
* `apps/…` specifier, an app's package name (or a subpath of it), and a relative path that
* resolves into `apps/`.
*
* Fix a finding by moving the shared code into a package that both sides import, or by keeping
* the code that needs it in the app. There is no allowlist.
*
* Run: `bun run check:boundaries`
* There is no allowlist. Run: `bun run check:boundaries`
*/
import { readdir, readFile } from 'node:fs/promises'
import path from 'node:path'
@@ -22,6 +28,150 @@ import ts from '@typescript/typescript6'
const ROOT = path.resolve(import.meta.dir, '..')
const PACKAGES_DIR = path.join(ROOT, 'packages')
const SIM_DIR = path.join(ROOT, 'apps/sim')
const TEST_FILE = /\.(test|spec|integration)\.tsx?$/
function matchesModule(specifier: string, module: string): boolean {
return specifier === module || specifier.startsWith(`${module}/`)
}
/**
* A relative specifier rewritten into its `@/…` form against the importing file
* (the `apps/sim` root is `@/`), so the application rules see one spelling.
* Bare and already-aliased specifiers, and relative ones leaving `apps/sim`,
* come back unchanged.
*/
function aliasedSpecifier(file: string, specifier: string): string {
if (!specifier.startsWith('.')) return specifier
const target = path.relative(SIM_DIR, path.resolve(path.dirname(file), specifier))
if (target.startsWith('..') || path.isAbsolute(target)) return specifier
return target ? `@/${target.split(path.sep).join('/')}` : '@'
}
/**
* Why an application file may not take this import edge, or `null` when it may.
* An empty `bindings` list is an edge the compiler erases.
*/
function applicationViolation(
specifier: string,
bindings: readonly string[] | 'all'
): string | null {
if (matchesModule(specifier, 'next/server') || matchesModule(specifier, '@/app/api')) {
return `application code imports a surface module: ${specifier}`
}
if (bindings !== 'all' && bindings.length === 0) return null
if (
matchesModule(specifier, '@/lib/mothership/tools/handlers') ||
matchesModule(specifier, '@/lib/mothership/tools/server')
) {
return `application code imports a Copilot handler: ${specifier}`
}
if (specifier.startsWith('@/') && /(^|[/-])presenters?(\.[cm]?[jt]sx?)?$/.test(specifier)) {
return `application code imports a presenter: ${specifier}`
}
if (matchesModule(specifier, '@/lib/api/contracts')) {
const contracts =
bindings === 'all' ? ['*'] : bindings.filter((name) => name.endsWith('Contract'))
if (contracts.length > 0) {
return `application code imports route contract ${contracts.join(', ')} from ${specifier}; take the surface fact as input from the adapter`
}
}
return null
}
/**
* Runtime (non-type) names an import/export declaration binds: `'all'` for a
* namespace, default, star, empty-clause or side-effect edge, `[]` for one the compiler erases.
*/
function runtimeBindings(node: ts.ImportDeclaration | ts.ExportDeclaration): string[] | 'all' {
if (ts.isImportDeclaration(node)) {
const clause = node.importClause
if (!clause) return 'all'
if (clause.isTypeOnly) return []
if (clause.name) return 'all'
const named = clause.namedBindings
if (!named) return []
if (ts.isNamespaceImport(named) || named.elements.length === 0) return 'all'
return named.elements
.filter((element) => !element.isTypeOnly)
.map((element) => (element.propertyName ?? element.name).text)
}
if (node.isTypeOnly) return []
const clause = node.exportClause
if (!clause || ts.isNamespaceExport(clause) || clause.elements.length === 0) return 'all'
return clause.elements
.filter((element) => !element.isTypeOnly)
.map((element) => (element.propertyName ?? element.name).text)
}
interface ImportEdge {
module: ts.StringLiteralLike
/** See {@link runtimeBindings}; dynamic `import()` and `require` bind `'all'`. */
bindings: string[] | 'all'
}
/** Every static, type-level and dynamic module reference in a file. */
function importEdges(source: ts.SourceFile): ImportEdge[] {
const edges: ImportEdge[] = []
function visit(node: ts.Node): void {
let module: ts.Node | undefined
let bindings: string[] | 'all' = 'all'
if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) {
module = node.moduleSpecifier
bindings = runtimeBindings(node)
} else if (
ts.isImportEqualsDeclaration(node) &&
ts.isExternalModuleReference(node.moduleReference)
) {
module = node.moduleReference.expression
if (node.isTypeOnly) bindings = []
} else if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument)) {
module = node.argument.literal
bindings = []
} else if (
ts.isCallExpression(node) &&
(node.expression.kind === ts.SyntaxKind.ImportKeyword ||
(ts.isIdentifier(node.expression) && node.expression.text === 'require'))
) {
module = node.arguments[0]
}
if (module && ts.isStringLiteralLike(module)) edges.push({ module, bindings })
ts.forEachChild(node, visit)
}
visit(source)
return edges
}
interface Offender {
file: string
line: number
description: string
snippet: string
}
/** Parses `file` and reports each import edge `violation` describes. */
async function scanImports(
file: string,
violation: (edge: ImportEdge) => string | null
): Promise<Offender[]> {
const content = await readFile(file, 'utf8')
const source = ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true)
const lines = content.split('\n')
const offenders: Offender[] = []
for (const edge of importEdges(source)) {
const description = violation(edge)
if (!description) continue
const { line } = source.getLineAndCharacterOfPosition(edge.module.getStart(source))
offenders.push({
file: path.relative(ROOT, file),
line: line + 1,
description,
snippet: lines[line].trim(),
})
}
return offenders
}
/** Package dependencies point toward shared code, including type-only imports. */
function forbiddenImport(file: string, specifier: string, appNames: ReadonlySet<string>): boolean {
@@ -71,53 +221,35 @@ async function main() {
.filter((entry) => entry.isDirectory())
.map((entry) => path.join(PACKAGES_DIR, entry.name))
const offenders: Array<{ file: string; line: number; description: string; snippet: string }> = []
const offenders: Offender[] = []
for (const dir of packageDirs) {
const files = await walk(dir)
for (const file of files) {
const content = await readFile(file, 'utf8')
const source = ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true)
const lines = content.split('\n')
function visit(node: ts.Node): void {
let module: ts.Node | undefined
if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) {
module = node.moduleSpecifier
} else if (
ts.isImportEqualsDeclaration(node) &&
ts.isExternalModuleReference(node.moduleReference)
) {
module = node.moduleReference.expression
} else if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument)) {
module = node.argument.literal
} else if (
ts.isCallExpression(node) &&
(node.expression.kind === ts.SyntaxKind.ImportKeyword ||
(ts.isIdentifier(node.expression) && node.expression.text === 'require'))
) {
module = node.arguments[0]
}
if (
module &&
ts.isStringLiteralLike(module) &&
forbiddenImport(file, module.text, appNames)
) {
const { line } = source.getLineAndCharacterOfPosition(module.getStart(source))
offenders.push({
file: path.relative(ROOT, file),
line: line + 1,
description: `import into apps/: ${module.text}`,
snippet: lines[line].trim(),
})
}
ts.forEachChild(node, visit)
}
visit(source)
for (const file of await walk(dir)) {
offenders.push(
...(await scanImports(file, ({ module }) =>
forbiddenImport(file, module.text, appNames) ? `import into apps/: ${module.text}` : null
))
)
}
}
for (const file of await walk(SIM_DIR).catch((error: NodeJS.ErrnoException) => {
if (error.code === 'ENOENT') return []
throw error
})) {
if (!path.relative(ROOT, file).split(path.sep).includes('application') || TEST_FILE.test(file))
continue
offenders.push(
...(await scanImports(file, ({ module, bindings }) =>
applicationViolation(aliasedSpecifier(file, module.text), bindings)
))
)
}
if (offenders.length === 0) {
console.log('✅ Monorepo boundaries OK: no package imports from apps/*')
console.log(
'✅ Monorepo boundaries OK: no package imports from apps/*, and application code is surface-neutral'
)
return
}
+127 -4
View File
@@ -1,5 +1,10 @@
import { describe, expect, it } from 'vitest'
import { wrappedRouteSites } from './check-route-verbs'
import {
checkSite,
importedNames,
rawRouteContractSites,
wrappedRouteSites,
} from './check-route-verbs'
describe('traced route declarations', () => {
const handler = `const handler = defineInternalJsonRoute({
@@ -10,7 +15,7 @@ describe('traced route declarations', () => {
export const POST = (request: NextRequest, context?: Parameters<typeof handler>[1]) =>
withIncomingGoSpan(request.headers, span, undefined, () => handler(request, context))`
expect(wrappedRouteSites(source)).toEqual([
{ verb: 'POST', optionsStart: source.indexOf('{') + 1 },
{ verb: 'POST', builder: 'defineInternalJsonRoute', optionsStart: source.indexOf('{') + 1 },
])
})
it('preserves a wrong verb so the contract comparison rejects it', () => {
@@ -25,8 +30,126 @@ const fallback = defineInternalJsonRoute({
})
export const POST = enabled ? handler : fallback`
expect(wrappedRouteSites(source)).toEqual([
{ verb: 'POST', optionsStart: source.indexOf('{') + 1 },
{ verb: 'POST', optionsStart: source.indexOf('{', source.indexOf('const fallback')) + 1 },
{ verb: 'POST', builder: 'defineInternalJsonRoute', optionsStart: source.indexOf('{') + 1 },
{
verb: 'POST',
builder: 'defineInternalJsonRoute',
optionsStart: source.indexOf('{', source.indexOf('const fallback')) + 1,
},
])
})
it('checks a builder handler exported through an export list', () => {
const source = `const GET = defineInternalJsonRoute({
contract: abortContract,
})
export { GET }`
expect(wrappedRouteSites(source)).toEqual([
{ verb: 'GET', builder: 'defineInternalJsonRoute', optionsStart: source.indexOf('{') + 1 },
])
})
})
describe('raw route parseRequest sites', () => {
it('attributes a contract parsed in a same-file helper to the verb that calls it', () => {
const source = `async function handle(request) {
return parseRequest(updateContract, request, {})
}
export const PATCH = withRouteHandler((request) => handle(request))`
expect(rawRouteContractSites(source)).toEqual([{ verb: 'PATCH', identifier: 'updateContract' }])
})
it('checks a verb alias only under the verb it forwards to', () => {
const source = `export const PATCH = withRouteHandler((request) => parseRequest(updateContract, request, {}))
export const PUT = withRouteHandler((request, context) => PATCH(request, context))`
expect(rawRouteContractSites(source)).toEqual([{ verb: 'PATCH', identifier: 'updateContract' }])
})
})
describe('export-list verbs on raw routes', () => {
it('follows an exported non-verb helper that parses the contract', () => {
const source = `import { updateContract } from '@/lib/api/contracts/things'
export async function parseUpdate(request) { return parseRequest(updateContract, request) }
export const PUT = withRouteHandler((request) => parseUpdate(request))`
expect(rawRouteContractSites(source)).toEqual([{ verb: 'PUT', identifier: 'updateContract' }])
})
it('checks a handler exported as \`export { GET }\`', () => {
const source = `const GET = withRouteHandler((request) => parseRequest(listContract, request, {}))
export { GET }`
expect(rawRouteContractSites(source)).toEqual([{ verb: 'GET', identifier: 'listContract' }])
})
it('attributes a renamed export to its exported verb, so a wrong verb is compared', () => {
const source = `const handler = withRouteHandler((request) => parseRequest(updateContract, request, {}))
export { handler as PUT }`
expect(rawRouteContractSites(source)).toEqual([{ verb: 'PUT', identifier: 'updateContract' }])
})
})
describe('aliased parseRequest imports', () => {
it('finds the contract parsed through an aliased parseRequest, so a wrong verb is compared', async () => {
const source = `import { parseRequest as parse } from '@/lib/api/server'
import { updateContract } from '@/lib/api/contracts/things'
export const PUT = withRouteHandler((request) => parse(updateContract, request, {}))`
const route = {
relative: 'route.ts',
bindings: importedNames(source),
expectedPath: '/api/things',
}
const loadModule = () =>
Promise.resolve({ updateContract: { method: 'PATCH', path: '/api/things' } })
const failures: string[] = []
for (const { verb, identifier } of rawRouteContractSites(source)) {
await checkSite(route, 'raw', verb, identifier, failures, false, loadModule)
}
expect(failures).toHaveLength(1)
expect(failures[0]).toContain('which declares PATCH /api/things')
})
})
describe('aliased contract imports', () => {
const source = `import { updateContract as contract } from '@/lib/api/contracts/things'`
const route = {
relative: 'route.ts',
bindings: importedNames(source),
expectedPath: '/api/things',
}
const loadModule = () =>
Promise.resolve({ updateContract: { method: 'PATCH', path: '/api/things' } })
it('reads the contract by its exported name and passes the matching verb', async () => {
const failures: string[] = []
expect(await checkSite(route, 'raw', 'PATCH', 'contract', failures, false, loadModule)).toBe(
true
)
expect(failures).toEqual([])
})
it('reports a verb that disagrees with the aliased contract', async () => {
const failures: string[] = []
await checkSite(route, 'builder', 'PUT', 'contract', failures, false, loadModule)
expect(failures).toHaveLength(1)
expect(failures[0]).toContain('which declares PATCH /api/things')
})
})
describe('HEAD handlers', () => {
const route = {
relative: 'route.ts',
bindings: importedNames(`import { readContract } from '@/lib/api/contracts/things'`),
expectedPath: '/api/things',
}
const loadModule = () => Promise.resolve({ readContract: { method: 'GET', path: '/api/things' } })
it('accepts a HEAD handler on a GET contract, as methodMatchesContract does', async () => {
const failures: string[] = []
await checkSite(route, 'raw', 'HEAD', 'readContract', failures, false, loadModule)
expect(failures).toEqual([])
})
it('rejects HEAD on a GET contract for defineScimRoute, which requires an exact method', async () => {
const failures: string[] = []
await checkSite(route, 'scim', 'HEAD', 'readContract', failures, false, loadModule)
expect(failures).toHaveLength(1)
})
it('still rejects any other verb on a GET contract', async () => {
const failures: string[] = []
await checkSite(route, 'raw', 'POST', 'readContract', failures, false, loadModule)
expect(failures).toHaveLength(1)
})
})
+333 -102
View File
@@ -31,15 +31,22 @@
* 3. Compare the exported verb symbol to `contract.method`, and the derived
* URL to `contract.path`.
*
* Known, intentional limitation: routes written as raw `withRouteHandler(...)`
* — the documented protocol/lifecycle exceptions for streaming, multipart
* control, large-body admission, OAuth and public execution — have no
* `contract:` key and are deliberately out of scope. They are excluded by
* requiring a builder call, not by an allowlist.
* Raw `withRouteHandler(...)` routes — the documented protocol/lifecycle
* exceptions for streaming, multipart control, large-body admission, OAuth and
* public execution — have no `contract:` key, but most still validate with
* `parseRequest(<contract>, …)`. Every `parseRequest` call reachable from an
* exported verb (directly, or through a same-file function or const it names)
* is checked the same way. The mismatch is just as silent there: `parseRequest`
* never reads a body for a `GET` contract, and `requestJson` sends the
* contract's verb, so clients get a 405.
*
* Nothing is skipped silently. A builder call site whose contract cannot be
* located, resolved, imported or read fails the build exactly like a mismatch:
* a guard that quietly ignores what it cannot parse guards nothing.
* a guard that quietly ignores what it cannot parse guards nothing. The same
* holds for a raw route's `parseRequest` argument imported from
* `@/lib/api/contracts`. A raw route whose argument is a local value or comes
* from any other module is out of scope: resolving it would mean importing
* server code.
*
* Usage:
* bun run scripts/check-route-verbs.ts
@@ -88,10 +95,63 @@ const CONTRACT_KEY_RE = /\n\s{2}contract:\s*([A-Za-z0-9_$]+)\s*,/
/** How far past the builder's `({` to look for the `contract:` key. */
const OPTIONS_SCAN_CHARS = 4000
type Statements = ReturnType<typeof parse>['program']['body']
function isVerb(name: string): boolean {
return VERBS.some((verb) => verb === name)
}
/**
* A module's top-level functions and variables (name -> body or initialiser),
* and every local export as `{ exported, local }` — both an inline
* `export const GET = …` and an export list such as `export { handler as GET }`.
* A re-export from another module (`export { GET } from '…'`) binds no local
* and is left out.
*/
function topLevelBindings(statements: Statements): {
locals: Map<string, unknown>
exports: Array<{ exported: string; local: string }>
} {
const locals = new Map<string, unknown>()
const exports: Array<{ exported: string; local: string }> = []
for (const statement of statements) {
const isExport = statement.type === 'ExportNamedDeclaration'
if (isExport && !statement.declaration && !statement.source) {
for (const specifier of statement.specifiers) {
if (specifier.type !== 'ExportSpecifier') continue
const exported =
specifier.exported.type === 'Identifier'
? specifier.exported.name
: specifier.exported.value
exports.push({ exported, local: specifier.local.name })
}
continue
}
const declaration = isExport ? statement.declaration : statement
const named: Array<[string, unknown]> = []
if (declaration?.type === 'FunctionDeclaration' && declaration.id) {
named.push([declaration.id.name, declaration.body])
} else if (declaration?.type === 'VariableDeclaration') {
for (const declarator of declaration.declarations) {
if (declarator.id.type === 'Identifier' && declarator.init) {
named.push([declarator.id.name, declarator.init])
}
}
}
for (const [name, root] of named) {
locals.set(name, root)
if (isExport) exports.push({ exported: name, local: name })
}
}
return { locals, exports }
}
/** Resolves exported handler references and tracing callbacks without importing server code. */
export function wrappedRouteSites(source: string): Array<{ verb: string; optionsStart: number }> {
export function wrappedRouteSites(
source: string
): Array<{ verb: string; builder: string; optionsStart: number }> {
const statements = parse(source, { sourceType: 'module', plugins: ['typescript'] }).program.body
const handlers = new Map<string, number>()
const handlers = new Map<string, { builder: string; optionsStart: number }>()
for (const statement of statements) {
if (statement.type !== 'VariableDeclaration' || statement.kind !== 'const') continue
for (const declaration of statement.declarations) {
@@ -101,41 +161,121 @@ export function wrappedRouteSites(source: string): Array<{ verb: string; options
continue
const options = call.arguments[0]
if (options?.type === 'ObjectExpression' && typeof options.start === 'number') {
handlers.set(declaration.id.name, options.start + 1)
handlers.set(declaration.id.name, {
builder: call.callee.name,
optionsStart: options.start + 1,
})
}
}
}
const sites: Array<{ verb: string; optionsStart: number }> = []
for (const statement of statements) {
if (
statement.type !== 'ExportNamedDeclaration' ||
statement.declaration?.type !== 'VariableDeclaration'
)
continue
for (const declaration of statement.declaration.declarations) {
if (
declaration.id.type !== 'Identifier' ||
!VERBS.some((verb) => verb === declaration.id.name)
)
continue
const verb = declaration.id.name
const found = new Set<number>()
const visit = (value: unknown): void => {
if (!value || typeof value !== 'object') return
if (Array.isArray(value)) {
value.forEach(visit)
return
}
const node = value as Record<string, unknown>
if (node.type === 'Identifier' && typeof node.name === 'string') {
const start = handlers.get(node.name)
if (start !== undefined) found.add(start)
}
Object.values(node).forEach(visit)
const { locals, exports } = topLevelBindings(statements)
const sites: Array<{ verb: string; builder: string; optionsStart: number }> = []
for (const { exported: verb, local } of exports) {
if (!isVerb(verb)) continue
const found = new Set<{ builder: string; optionsStart: number }>()
const direct = handlers.get(local)
if (direct !== undefined) found.add(direct)
const visit = (value: unknown): void => {
if (!value || typeof value !== 'object') return
if (Array.isArray(value)) {
value.forEach(visit)
return
}
visit(declaration.init)
for (const optionsStart of found) sites.push({ verb, optionsStart })
const node = value as Record<string, unknown>
if (node.type === 'Identifier' && typeof node.name === 'string') {
const handler = handlers.get(node.name)
if (handler !== undefined) found.add(handler)
}
Object.values(node).forEach(visit)
}
if (direct === undefined) visit(locals.get(local))
for (const handler of found) sites.push({ verb, ...handler })
}
return sites
}
/** Modules that export the server-side `parseRequest`. */
const PARSE_REQUEST_MODULES = new Set(['@/lib/api/server', '@/lib/api/server/validation'])
/**
* Local names a module binds to `parseRequest`: `parseRequest` itself, plus
* each alias of an `import { parseRequest as … }` from its real module.
*/
function parseRequestNames(statements: Statements): Set<string> {
const names = new Set(['parseRequest'])
for (const statement of statements) {
if (statement.type !== 'ImportDeclaration') continue
if (!PARSE_REQUEST_MODULES.has(statement.source.value)) continue
for (const specifier of statement.specifiers) {
if (specifier.type !== 'ImportSpecifier') continue
const imported =
specifier.imported.type === 'Identifier'
? specifier.imported.name
: specifier.imported.value
if (imported === 'parseRequest') names.add(specifier.local.name)
}
}
return names
}
/**
* The `parseRequest(<identifier>, …)` contract arguments reachable from each
* exported verb of a raw route, following same-file functions and consts the
* verb's handler names. Another exported verb is not followed: an alias such as
* `PUT` forwarding to `PATCH` is checked as `PATCH`. A non-identifier argument
* is not a resolvable contract and is left out.
*/
export function rawRouteContractSites(source: string): Array<{ verb: string; identifier: string }> {
const statements = parse(source, { sourceType: 'module', plugins: ['typescript'] }).program.body
const { locals, exports } = topLevelBindings(statements)
const parsers = parseRequestNames(statements)
const verbLocals = new Set(
exports.filter(({ exported }) => isVerb(exported)).map(({ local }) => local)
)
const sites: Array<{ verb: string; identifier: string }> = []
for (const { exported: verb, local } of exports) {
if (!isVerb(verb)) continue
const identifiers = new Set<string>()
const followed = new Set(verbLocals)
const visit = (value: unknown): void => {
if (!value || typeof value !== 'object') return
if (Array.isArray(value)) {
value.forEach(visit)
return
}
const node = value as Record<string, unknown>
if (node.type === 'CallExpression') {
const callee = node.callee as { type?: string; name?: string }
const [first] = node.arguments as Array<{ type?: string; name?: string }>
if (
callee.type === 'Identifier' &&
callee.name !== undefined &&
parsers.has(callee.name) &&
first?.type === 'Identifier' &&
first.name
) {
identifiers.add(first.name)
}
}
if (node.type === 'Identifier' && typeof node.name === 'string') {
const local = locals.get(node.name)
if (local !== undefined && !followed.has(node.name)) {
followed.add(node.name)
visit(local)
}
}
if (node.type === 'MemberExpression' && !node.computed) {
visit(node.object)
return
}
if (node.type === 'ObjectProperty' && !node.computed) {
visit(node.value)
return
}
Object.values(node).forEach(visit)
}
visit(locals.get(local))
for (const identifier of identifiers) sites.push({ verb, identifier })
}
return sites
}
@@ -154,9 +294,15 @@ function listRouteFiles(dir: string, found: string[] = []): string[] {
return found
}
/** Local binding name -> module specifier, from the file's import statements. */
function importedNames(source: string): Map<string, string> {
const bindings = new Map<string, string>()
/** Where an imported local binding comes from: its module and the name that module exports it as. */
interface ImportedBinding {
specifier: string
exported: string
}
/** Local binding name -> its source module and exported name, from the file's import statements. */
export function importedNames(source: string): Map<string, ImportedBinding> {
const bindings = new Map<string, ImportedBinding>()
for (const match of source.matchAll(
/import\s+(?:type\s+)?\{([^}]*)\}\s+from\s+['"]([^'"]+)['"]/g
)) {
@@ -164,7 +310,7 @@ function importedNames(source: string): Map<string, string> {
const clause = raw.trim().replace(/^type\s+/, '')
if (!clause) continue
const [original, alias] = clause.split(/\s+as\s+/).map((part) => part.trim())
bindings.set(alias ?? original, match[2])
bindings.set(alias ?? original, { specifier: match[2], exported: original })
}
}
return bindings
@@ -191,6 +337,14 @@ function loadContractModule(file: string): Promise<Record<string, unknown>> {
return loaded
}
/** Resolves and imports a contract module; null when the specifier resolves to no file. */
type ContractModuleLoader = (specifier: string) => Promise<Record<string, unknown>> | null
const loadContractSpecifier: ContractModuleLoader = (specifier) => {
const modulePath = resolveContractModule(specifier)
return modulePath ? loadContractModule(modulePath) : null
}
/** The URL Next.js actually serves this file at: its directory, minus route groups. */
function derivedPath(file: string): string {
const segments = path
@@ -200,6 +354,113 @@ function derivedPath(file: string): string {
return ['/api', ...segments].join('/')
}
/**
* Where a contract identifier is bound in a route: a builder's `contract:` key or a raw
* `parseRequest`. `scim` is `defineScimRoute`, the one builder that rejects a `HEAD` request
* against a `GET` contract instead of serving it through `methodMatchesContract`.
*/
type SiteKind = 'builder' | 'scim' | 'raw'
/** One route file, as every site in it is checked against. */
interface RouteFile {
relative: string
/** Local binding name -> its source module and exported name. */
bindings: Map<string, ImportedBinding>
/** The URL the file serves. */
expectedPath: string
}
/**
* Whether a route serves `contractPath`. A raw catch-all route (`[...slug]`,
* `[[...slug]]`) dispatches sub-paths itself, so any contract under its prefix
* is one it serves.
*/
function servesPath(kind: SiteKind, expectedPath: string, contractPath: string): boolean {
if (contractPath === expectedPath) return true
const catchAll = expectedPath.search(/\/\[{1,2}\.\.\./)
return (
kind === 'raw' &&
catchAll !== -1 &&
contractPath.startsWith(expectedPath.slice(0, catchAll + 1))
)
}
/**
* Resolves `identifier` through the route's imports and compares the contract to
* the exported verb and the path the file serves, pushing every disagreement to
* `failures`. Returns whether a contract was compared: false for a site that
* could not be resolved (a failure) or a raw route's non-contract argument (out
* of scope).
*/
export async function checkSite(
route: RouteFile,
kind: SiteKind,
verb: string,
identifier: string,
failures: string[],
verbose: boolean,
loadModule: ContractModuleLoader = loadContractSpecifier
): Promise<boolean> {
const { relative, bindings, expectedPath } = route
const binding = bindings.get(identifier)
if (kind === 'raw' && !binding?.specifier.startsWith('@/lib/api/contracts')) return false
if (!binding) {
failures.push(`${relative}: export const ${verb} uses \`${identifier}\`, which is not imported`)
return false
}
const { specifier, exported } = binding
const loading = loadModule(specifier)
if (!loading) {
failures.push(
`${relative}: export const ${verb} imports \`${identifier}\` from '${specifier}', which does not resolve to a contract module`
)
return false
}
let module: Record<string, unknown>
try {
module = await loading
} catch (error) {
failures.push(
`${relative}: export const ${verb} — importing '${specifier}' failed: ${(error as Error).message}`
)
return false
}
const contract = module[exported] as RouteContract | undefined
if (typeof contract?.method !== 'string' || typeof contract?.path !== 'string') {
failures.push(
`${relative}: export const ${verb} — \`${identifier}\` from '${specifier}' is not a route contract (no string \`method\`/\`path\`)`
)
return false
}
const subject =
kind !== 'raw'
? `export const ${verb} is built from \`${identifier}\``
: `export const ${verb} parses with \`${identifier}\``
const method = contract.method.toUpperCase()
if (method !== verb && !(verb === 'HEAD' && method === 'GET' && kind !== 'scim')) {
failures.push(
kind !== 'raw'
? `${relative}: ${subject}, which declares ${contract.method} ${contract.path}. Next routes by the exported symbol, so ${verb} requests 500 and ${contract.method} requests 404.`
: `${relative}: ${subject}, which declares ${contract.method} ${contract.path}. Clients calling through the contract send ${contract.method} and get a 405.`
)
}
if (!servesPath(kind, expectedPath, contract.path)) {
failures.push(
`${relative}: ${subject}, whose path is ${contract.path}, but this file serves ${expectedPath}.`
)
}
if (verbose) {
console.log(
`✓ ${relative} ${verb} ← ${identifier} (${kind}, ${contract.method} ${contract.path})`
)
}
return true
}
async function main() {
const verbose = process.argv.includes('--verbose')
@@ -211,28 +472,46 @@ async function main() {
const failures: string[] = []
let checked = 0
let files = 0
let rawChecked = 0
let rawFiles = 0
for (const file of listRouteFiles(API_DIR).sort()) {
const source = readFileSync(file, 'utf8')
const relative = path.relative(ROOT, file)
const route: RouteFile = {
relative,
bindings: importedNames(source),
expectedPath: derivedPath(file),
}
if (source.includes('parseRequest')) {
let rawSitesInFile = 0
for (const { verb, identifier } of rawRouteContractSites(source)) {
if (await checkSite(route, 'raw', verb, identifier, failures, verbose)) rawSitesInFile += 1
}
if (rawSitesInFile > 0) {
rawChecked += rawSitesInFile
rawFiles += 1
}
}
let builderCalls = 0
for (const _ of source.matchAll(BUILDER_CALL_RE)) builderCalls += 1
if (builderCalls === 0) continue
files += 1
const relative = path.relative(ROOT, file)
const bindings = importedNames(source)
const expectedPath = derivedPath(file)
let sitesInFile = 0
const directSites = [...source.matchAll(EXPORT_RE)].map((match) => ({
verb: match[1],
builder: match[2],
optionsStart: (match.index ?? 0) + match[0].length,
}))
const sites =
directSites.length === builderCalls
? directSites
: [...directSites, ...wrappedRouteSites(source)]
for (const { verb, optionsStart } of sites) {
for (const { verb, builder, optionsStart } of sites) {
sitesInFile += 1
const options = source.slice(optionsStart, optionsStart + OPTIONS_SCAN_CHARS)
@@ -242,56 +521,8 @@ async function main() {
continue
}
const identifier = contractKey[1]
const specifier = bindings.get(identifier)
if (!specifier) {
failures.push(
`${relative}: export const ${verb} uses \`${identifier}\`, which is not imported`
)
continue
}
const modulePath = resolveContractModule(specifier)
if (!modulePath) {
failures.push(
`${relative}: export const ${verb} imports \`${identifier}\` from '${specifier}', which does not resolve to a contract module`
)
continue
}
let module: Record<string, unknown>
try {
module = await loadContractModule(modulePath)
} catch (error) {
failures.push(
`${relative}: export const ${verb} — importing '${specifier}' failed: ${(error as Error).message}`
)
continue
}
const contract = module[identifier] as RouteContract | undefined
if (typeof contract?.method !== 'string' || typeof contract?.path !== 'string') {
failures.push(
`${relative}: export const ${verb} — \`${identifier}\` from '${specifier}' is not a route contract (no string \`method\`/\`path\`)`
)
continue
}
checked += 1
if (contract.method.toUpperCase() !== verb) {
failures.push(
`${relative}: export const ${verb} is built from \`${identifier}\`, which declares ${contract.method} ${contract.path}. Next routes by the exported symbol, so ${verb} requests 500 and ${contract.method} requests 404.`
)
}
if (contract.path !== expectedPath) {
failures.push(
`${relative}: export const ${verb} is built from \`${identifier}\`, whose path is ${contract.path}, but this file serves ${expectedPath}.`
)
}
if (verbose) {
console.log(`✓ ${relative} ${verb} ← ${identifier} (${contract.method} ${contract.path})`)
}
const kind = builder === 'defineScimRoute' ? 'scim' : 'builder'
if (await checkSite(route, kind, verb, identifier, failures, verbose)) checked += 1
}
if (sitesInFile < builderCalls) {
@@ -301,9 +532,9 @@ async function main() {
}
}
if (checked === 0) {
if (checked === 0 || rawChecked === 0) {
console.error(
'❌ No builder-backed route handlers found. Refusing to pass vacuously — the scan patterns are stale.'
'❌ No builder-backed or no raw parseRequest route handlers found. Refusing to pass vacuously — the scan patterns are stale.'
)
process.exit(1)
}
@@ -312,15 +543,15 @@ async function main() {
console.error(`\n❌ ${failures.length} route/contract disagreement(s):\n`)
for (const failure of failures) console.error(` ${failure}`)
console.error(
'\nThe builders only compare request.method to contract.method at runtime, so these'
'\nThe builders only compare request.method to contract.method at runtime, and raw'
)
console.error('fail as 500s in production rather than at build time. Fix the export symbol or')
console.error('point the route at the right contract.')
console.error('routes never do, so these fail in production rather than at build time. Fix the')
console.error('export symbol or point the route at the right contract.')
process.exit(1)
}
console.log(
`✓ ${checked} builder-backed route handler(s) across ${files} file(s) match their contract's method and path`
`✓ ${checked} builder-backed route handler(s) across ${files} file(s) and ${rawChecked} raw parseRequest site(s) across ${rawFiles} file(s) match their contract's method and path`
)
}