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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 30 additions & 2 deletions src/map/entries.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { Endpoint, Sink, TsModule } from './types.js';
import { hasExport, isFnLike, methodFromObjectArg, spanOf, unwindChain } from './ast.js';
import type { Bindings } from './bindings.js';
import { functionNameFromPath, isRoutePath, ROUTE_REGISTER, routeFromChain, routeObject } from './routes.js';
import { functionNameFromPath, isPagesApiFile, isRoutePath, ROUTE_REGISTER, routeFromChain, routeObject } from './routes.js';
import { declarationOf } from './scope.js';
import { withCoordinates } from './coordinates.js';
import { inputsFromHandler, inputsFromValidator } from './inputs.js';
import { sinksFrom, type LocalSinks, type SinkContext } from './sinks.js';
Expand All @@ -14,6 +15,7 @@ const HTTP_METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', '
export function extractFromFile(sf: any, ts: TsModule, localSinks: LocalSinks, bindings: Bindings, ctx: SinkContext): Omit<Endpoint, 'file'>[] {
const out: Omit<Endpoint, 'file'>[] = [];
const isServerActionsFile = fileHasUseServer(sf, ts);
const pagesApi = isPagesApiFile(ctx.owner);

const visit = (node: any) => {
if (ts.isVariableStatement(node) && hasExport(node, ts)) {
Expand Down Expand Up @@ -62,6 +64,15 @@ export function extractFromFile(sf: any, ts: TsModule, localSinks: LocalSinks, b
}
}

// (2d) Next.js Pages Router: the default export of a `pages/api/**` file handles that route, whether
// declared in place or exported by name (`export default handler`).
if (pagesApi) {
const handler = defaultExportedHandler(node, ts);
if (handler) {
out.push(handlerEntry(handler.name ?? 'default', 'default-export', handler.fn.parameters, handler.fn.body, ts, localSinks, bindings, ctx, spanOf(handler.fn)));
}
}

// (2c) Deno / WinterCG function entry: `Deno.serve(handler)` or `serve(handler)` — Supabase Edge
// Functions, Base44 backend functions, Deno workers. These platforms have no router and no route
// file: one handler per module, invoked by the function's NAME, so the endpoint's identity comes
Expand Down Expand Up @@ -117,6 +128,21 @@ export function extractFromFile(sf: any, ts: TsModule, localSinks: LocalSinks, b
return out;
}

/** The function a module's default export names, when it is one: `export default function`, `export default fn`. */
function defaultExportedHandler(node: any, ts: TsModule): { name?: string; fn: any } | undefined {
const isDefault = (n: any) => Boolean(n.modifiers?.some((m: any) => m.kind === ts.SyntaxKind.DefaultKeyword));
if (ts.isFunctionDeclaration(node) && hasExport(node, ts) && isDefault(node) && node.body) return { name: node.name?.text, fn: node };
if (!ts.isExportAssignment(node) || node.isExportEquals) return undefined;
const target = node.expression;
if (isFnLike(target, ts)) return { fn: target };
if (!ts.isIdentifier(target)) return undefined;
const declaration = declarationOf(target, ts);
const owner = declaration?.parent;
if (owner && ts.isFunctionDeclaration(owner) && owner.body) return { name: target.text, fn: owner };
if (owner && ts.isVariableDeclaration(owner) && owner.initializer && isFnLike(owner.initializer, ts)) return { name: target.text, fn: owner.initializer };
return undefined;
}

// Next server actions: a `'use server'` directive at the top of a module (whole file) or a function body.
function fileHasUseServer(sf: any, ts: TsModule): boolean {
const first = sf.statements?.[0];
Expand Down Expand Up @@ -148,7 +174,7 @@ function handlerEntry(
: 'route-handler';
// A server action receives its payload as the first argument; a route handler receives a Request.
const payloadStyle = kindLabel === 'server-action';
const inputs = inputsFromHandler(params, body, ts, bindings, {
const { inputs, schemaUnresolved } = inputsFromHandler(params, body, ts, bindings, {
payloadParam: payloadStyle,
validatorSource: payloadStyle ? 'server-fn-data' : 'json-body',
});
Expand All @@ -164,5 +190,7 @@ function handlerEntry(
inputs,
sinks,
...linkedFlows(body, params, inputs, sinks, ts, handlerInvocations(body, ts, bindings, ctx)),
// The handler validates its request with a schema declared in another module: its fields are unknown.
...(schemaUnresolved ? { inputsResolved: false as const } : {}),
};
}
4 changes: 2 additions & 2 deletions src/map/extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { guessScriptKind } from './ast.js';
import { buildModuleBindings } from './bindings.js';
import { collectSources, detectDeploymentShapes, detectFramework, hasEntrySignal, type WalkStats } from './sources.js';
import { classifyServerSurface, surfaceNote } from './surface.js';
import { functionNameFromPath, routeFromFilePath } from './routes.js';
import { functionNameFromPath, isPagesApiFile, routeFromFilePath } from './routes.js';
import { collectLocalSinks } from './sinks.js';
import { createModuleGraph } from './module-graph.js';
import { isProvenFlow } from './coordinates.js';
Expand Down Expand Up @@ -65,7 +65,7 @@ export async function extractInputMap(cwd: string, ts: TsModule, options: Extrac
// Imports are collected from EVERY file, entry point or not: the data layer of an AI-built app
// usually lives in a file with no handler in it, so a pre-filtered file is exactly where the
// interesting dependency is imported.
if (!hasEntrySignal(text)) {
if (!hasEntrySignal(text) && !isPagesApiFile(relFile)) {
preFiltered++;
const scanned = scanFileImports(text, ts);
if (scanned === null) importScanFailures++; // this file's imports are unknown, not empty
Expand Down
115 changes: 101 additions & 14 deletions src/map/flows.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { AddressSpace, ApiInvocation, ArgumentRole, DependencyInputFlow, Flow, InputField, Limitation, Sink, TsModule } from './types.js';
import { bindingKey, calleeName, isValueRead, lineOf, rootIdentifierNode } from './ast.js';
import { declarationOf } from './scope.js';
import { declarationOf, isGlobal } from './scope.js';
import { REQ_SOURCES } from './inputs.js';
import { addressSpaceOf } from './coordinates.js';
import { argumentRoleOf, CANDIDATE_FAMILIES } from './sinks.js';
Expand Down Expand Up @@ -29,6 +29,11 @@ interface Root {
* of reachability but never of an exact, untransformed value.
*/
reassigned?: boolean;
/**
* The binding is a URL of the request (`new URL(request.url)`, `request.nextUrl`, an event's `url`).
* Its members are not request fields: only its `searchParams` read the query string.
*/
url?: boolean;
}

/**
Expand Down Expand Up @@ -162,9 +167,9 @@ function linkFlows(
const rootPath = new Roots(ts);
// A binding assigned again after its declaration may no longer hold what it was declared with.
const reassigned = reassignedDeclarations(bodyNode, ts);
const addRoot = (declaration: any, path: string, space?: AddressSpace, accessor = false, request = false, inherited = false) => {
const addRoot = (declaration: any, path: string, space?: AddressSpace, accessor = false, request = false, inherited = false, url = false) => {
const changed = inherited || reassigned.has(declaration);
rootPath.add(declaration, { path, space, ...(accessor ? { accessor } : {}), ...(request ? { request } : {}), ...(changed ? { reassigned: true } : {}) });
rootPath.add(declaration, { path, space, ...(accessor ? { accessor } : {}), ...(request ? { request } : {}), ...(changed ? { reassigned: true } : {}), ...(url ? { url } : {}) });
};
for (const [index, p] of (params ?? []).entries()) {
if (!p?.name) continue;
Expand All @@ -174,6 +179,9 @@ function linkFlows(
for (const el of p.name.elements) {
if (!ts.isBindingElement(el) || !ts.isIdentifier(el.name)) continue;
const key = bindingKey(el, ts);
// A request event (SvelteKit, Astro) carries the request and its URL: `({ request, url })`.
if (index === 0 && key === 'request') { addRoot(el.name, '', undefined, false, true); continue; }
if (index === 0 && key === 'url') { addRoot(el.name, '', undefined, false, false, false, true); continue; }
// A destructured request source (`{ body }`) is a container: its members ARE the paths.
const container = key !== undefined && CONTAINER_KEYS.has(key);
addRoot(el.name, container ? '' : key ?? el.name.text, container ? spaceOfKey(key) : undefined, container && ACCESSOR_NAMESPACES.has(key!));
Expand All @@ -188,14 +196,19 @@ function linkFlows(
let cur = init;
while (cur && (ts.isAwaitExpression(cur) || ts.isParenthesizedExpression(cur) || ts.isAsExpression(cur) || ts.isNonNullExpression(cur))) cur = cur.expression;
if (!cur) return undefined;
const params = searchParamsOrigin(cur, ts, rootPath);
if (params) return { path: '', space: 'get', accessor: true, ...carried(params) };
const url = requestUrlOrigin(cur, ts, rootPath);
if (url) return { path: '', url: true, ...carried(url) };
if (ts.isCallExpression(cur) && ts.isPropertyAccessExpression(cur.expression)) {
const m = cur.expression.name.text;
if (['json', 'formData', 'text'].includes(m)) {
const root = rootIdentifierNode(cur.expression.expression, ts);
const owner = root ? rootPath.get(root) : undefined;
// A body read: whatever the field names turn out to be, they are addressed in `post`.
if (!root || !rootPath.get(root)) return undefined;
if (!owner) return undefined;

return m === 'formData' ? { path: '', space: 'post', accessor: true } : { path: '', space: 'post' };
return m === 'formData' ? { path: '', space: 'post', accessor: true, ...carried(owner) } : { path: '', space: 'post', ...carried(owner) };
}
if (['parse', 'safeParse', 'validate', 'cast'].includes(m)) {
for (const a of cur.arguments) {
Expand All @@ -212,11 +225,16 @@ function linkFlows(
if (ts.isVariableDeclaration(n) && n.initializer) {
const base = requestReadPath(n.initializer);
if (base !== undefined) {
if (ts.isIdentifier(n.name)) addRoot(n.name, base.path, base.space, base.accessor === true, false, base.reassigned === true);
if (ts.isIdentifier(n.name)) addRoot(n.name, base.path, base.space, base.accessor === true, base.request === true, base.reassigned === true, base.url === true);
else if (ts.isObjectBindingPattern(n.name)) {
for (const el of n.name.elements) {
if (!ts.isBindingElement(el) || !ts.isIdentifier(el.name)) continue;
const key = bindingKey(el, ts);
// Off a URL of the request, only `searchParams` is request data.
if (base.url === true) {
if (key === 'searchParams') addRoot(el.name, '', 'get', true, false, base.reassigned === true);
continue;
}
// A request namespace (`query`, `headers`, …) only when destructured from the request itself:
// off a parsed body the same key is an ordinary field, and its members are paths under it.
const namespace = base.request === true && key !== undefined && REQ_SOURCES.includes(key);
Expand Down Expand Up @@ -572,10 +590,74 @@ function accessorRead(node: any, ts: TsModule, rootPath: Roots): Root | undefine
while (cur && (ts.isAwaitExpression(cur) || ts.isParenthesizedExpression(cur) || ts.isNonNullExpression(cur))) cur = cur.expression;
if (!cur || !ts.isCallExpression(cur) || !ts.isPropertyAccessExpression(cur.expression)) return undefined;
const [name] = cur.arguments;
if (cur.expression.name.text !== 'get' || cur.arguments.length !== 1 || !name || !ts.isStringLiteralLike(name)) return undefined;
if (!isAccessor(cur.expression.expression, ts, rootPath)) return undefined;
if (cur.arguments.length !== 1 || !name || !ts.isStringLiteralLike(name)) return undefined;
const method = cur.expression.name.text;
const receiver = cur.expression.expression;
// Hono: `c.req.query('q')`, `c.req.param('id')`, `c.req.header('x-token')` on the handler's context.
const hono = HONO_ACCESSOR_SPACES[method];
if (hono && ts.isPropertyAccessExpression(receiver) && receiver.name.text === 'req' && ts.isIdentifier(receiver.expression)) {
const context = rootPath.get(receiver.expression);
if (context?.request === true) return { path: normalizePath(name.text), space: hono, ...carried(context) };
}
if (method !== 'get') return undefined;
// `new URL(request.url).searchParams.get('q')`: the query-string field `q`.
const params = ts.isIdentifier(receiver) ? undefined : searchParamsOrigin(receiver, ts, rootPath);
if (params) return { path: normalizePath(name.text), space: 'get', ...carried(params) };
if (!isAccessor(receiver, ts, rootPath)) return undefined;

return pathFromTainted(cur.expression.expression, ts, rootPath, [name.text]);
return pathFromTainted(receiver, ts, rootPath, [name.text]);
}

/** Hono request accessors and the address space each one reads. */
const HONO_ACCESSOR_SPACES: Record<string, AddressSpace> = { query: 'get', param: 'route-param', header: 'server' };

/** The reassignment marker of the binding a read derives from, to carry onto the read. */
function carried(origin: Root): { reassigned?: true } {
return origin.reassigned === true ? { reassigned: true } : {};
}

/**
* When `node` is a URL of the request — `new URL(request.url)` (also on a Hono context's `req`),
* `request.nextUrl`, or a binding that holds one — the binding it derives from. `URL` must be the
* global constructor: a local or imported `URL` can return anything.
*/
function requestUrlOrigin(node: any, ts: TsModule, rootPath: Roots): Root | undefined {
let cur = node;
while (cur && (ts.isParenthesizedExpression(cur) || ts.isNonNullExpression(cur) || ts.isAwaitExpression(cur) || ts.isAsExpression(cur))) cur = cur.expression;
if (!cur) return undefined;
if (ts.isIdentifier(cur)) {
const root = rootPath.get(cur);
return root?.url === true ? root : undefined;
}
const request = (e: any): Root | undefined => {
let inner = e;
while (inner && (ts.isParenthesizedExpression(inner) || ts.isNonNullExpression(inner) || ts.isAsExpression(inner))) inner = inner.expression;
if (!inner || !ts.isIdentifier(inner)) return undefined;
const root = rootPath.get(inner);
return root?.request === true ? root : undefined;
};
if (ts.isPropertyAccessExpression(cur) && cur.name.text === 'nextUrl') return request(cur.expression);
if (ts.isNewExpression(cur) && isGlobal(cur.expression, 'URL', ts)) {
const [href] = cur.arguments ?? [];
if (!href || !ts.isPropertyAccessExpression(href) || href.name.text !== 'url') return undefined;
const owner = href.expression;
if (ts.isPropertyAccessExpression(owner) && owner.name.text === 'req') return request(owner.expression) ?? request(owner);
return request(owner);
}
return undefined;
}

/** When `node` is the `searchParams` of a request URL, or a binding that holds them, the binding they derive from. */
function searchParamsOrigin(node: any, ts: TsModule, rootPath: Roots): Root | undefined {
let cur = node;
while (cur && (ts.isParenthesizedExpression(cur) || ts.isNonNullExpression(cur) || ts.isAsExpression(cur))) cur = cur.expression;
if (!cur) return undefined;
if (ts.isIdentifier(cur)) {
const root = rootPath.get(cur);
return root?.accessor === true && root.space === 'get' ? root : undefined;
}
if (!ts.isPropertyAccessExpression(cur) || cur.name.text !== 'searchParams') return undefined;
return requestUrlOrigin(cur.expression, ts, rootPath);
}

/**
Expand Down Expand Up @@ -614,7 +696,7 @@ function pathFromTainted(node: any, ts: TsModule, rootPath: Roots, trailing: str
}
if (!cur || !ts.isIdentifier(cur)) return undefined;
const base = rootPath.get(cur);
if (base === undefined) return undefined;
if (base === undefined || base.url === true) return undefined;
segs.push(...trailing);
let space = base.space;
// Drop a leading NAMESPACE segment (`req.body.webhookUrl` → `webhookUrl`). Input names — and the
Expand All @@ -624,14 +706,19 @@ function pathFromTainted(node: any, ts: TsModule, rootPath: Roots, trailing: str
// proven.
// The dropped segment is exactly what names the address space, so capture it before discarding it —
// losing it is what made `req.query.id` and `req.body.id` the same read.
if (base.request === true && base.path === '' && segs.length > 1 && REQ_SOURCES.includes(segs[0]!)) {
space = spaceOfKey(segs[0]!) ?? space;
// A bare namespace (`const b = req.body`) is that namespace's container: its members are the fields.
let namespace: string | undefined;
if (base.request === true && base.path === '' && segs.length > 0 && REQ_SOURCES.includes(segs[0]!)) {
namespace = segs[0]!;
space = spaceOfKey(namespace) ?? space;
segs.shift();
}
// Read bare, the request is still the request — which is what lets `const { headers } = request`
// destructure a namespace, and `const { headers } = await request.json()` not.
const request = base.request === true && segs.length === 0 ? { request: true } : {};
const request = base.request === true && namespace === undefined && segs.length === 0 ? { request: true } : {};
// `const headers = request.headers` holds an accessor, so `headers.get('x')` reads the header `x`.
const accessor = namespace !== undefined && segs.length === 0 && ACCESSOR_NAMESPACES.has(namespace) ? { accessor: true } : {};
const reassigned = base.reassigned === true ? { reassigned: true } : {};

return { path: normalizePath([base.path, ...segs].filter(Boolean).join('.')), space, ...request, ...reassigned };
return { path: normalizePath([base.path, ...segs].filter(Boolean).join('.')), space, ...request, ...accessor, ...reassigned };
}
Loading
Loading