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
31 changes: 27 additions & 4 deletions src/entrypoints/matching.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,9 @@ export function methodsOf(args: string[], kwargs: Record<string, string>, spec:
if (typeof v === "string") return [v.toUpperCase()];
return [...(spec.default ?? [])];
}
if (spec.from === "export_name") return [matched.toUpperCase()];
// Filtered through HTTP_VERBS exactly as `match_suffix` is above (#206): Astro exports an `ALL`
// handler, and "ALL" is not an HTTP method — a junk value in a field consumers filter on.
if (spec.from === "export_name") return HTTP_VERBS.has(matched.toLowerCase()) ? [matched.toUpperCase()] : [];
return [];
}

Expand Down Expand Up @@ -190,7 +192,11 @@ function resolveHandler(site: TSCallsite, rule: CallRule, callables: readonly TS
if (!raw) return undefined;
if (/^[A-Za-z_$][\w$]*$/.test(raw)) return callables.find((c) => c.name === raw);
if (INLINE.test(raw)) {
const inside = callables.filter((c) => c.name === "(anonymous)" &&
// Not gated on `name === "(anonymous)"` (#206): `app.get("/x", function named(req, res) {})` is a
// NAMED function expression — it passes INLINE, and gating on the name resolved nothing, so the
// site was counted unresolved instead. Position alone identifies it; sorting below takes the
// OUTERMOST callable in the span, so a callable nested inside the handler is never picked.
const inside = callables.filter((c) =>
(c.span.start[0] > site.start_line || (c.span.start[0] === site.start_line && c.span.start[1] >= site.start_column)) &&
(c.span.start[0] < site.end_line || (c.span.start[0] === site.end_line && c.span.start[1] <= site.end_column)));
inside.sort((a, b) => a.span.start[0] - b.span.start[0] || a.span.start[1] - b.span.start[1]);
Expand Down Expand Up @@ -293,8 +299,13 @@ export function routeFromFileKey(fileKey: string, glob: string): string {
const rest = fileKey.startsWith(literalPrefix) ? fileKey.slice(literalPrefix.length) : fileKey;
const noExt = rest.replace(/\.(tsx|ts|jsx|js|mjs|cjs)$/, "");
const noTail = noExt.replace(/\/?(route|\+server)$/, "");
const prefixDir = literalPrefix.replace(/^app\//, "/").replace(/^pages\//, "/").replace(/\/$/, "");
return prefixDir + (noTail ? `/${noTail}` : "") || "/";
// The glob's leading LAYOUT directories are not route segments: `app/`, `pages/` and (Remix)
// `app/routes/` are where the framework looks, not what it serves, and an optional `src/` in
// front of any of them is the same path served from the `src` layout (#206). Whatever literal
// prefix survives IS a route prefix — `pages/api/` serves at `/api`. Applied to the LITERAL
// PREFIX only, so a glob with no literal prefix still yields the whole key (`**/+server`).
const layout = literalPrefix.replace(/^(?:src\/)?(?:app\/routes|app|pages)(?:\/|$)/, "").replace(/\/$/, "");
return (layout ? `/${layout}` : "") + (noTail ? `/${noTail}` : "") || "/";
}

const DEFAULT_NAMED_EXPORT = /^\s*export\s+default\s+([A-Za-z_$][\w$]*)\s*;?\s*$/m;
Expand All @@ -319,10 +330,22 @@ function resolveDefaultExport(mod: TSModule): TSCallable | undefined {
const end = offsets.toByte(m.index + m[0].length);
const target = Object.values(mod.functions).find((c) => c.name === "(anonymous)" && c.span.bytes[0] === end);
if (target) return target;
// `export default <wrapper>(<handler>)` (#206): Nitro/Nuxt's `defineEventHandler(h)`, and every
// `withSentry(h)`-shaped wrapper, displace the callable past the token so the exact-offset match
// above misses it. The gap between token and callable must be NOTHING BUT call openings, which is
// what stops an unrelated later callable in the file from being claimed — `export default
// defineHandler({onRequest: fn})` fails the test and stays counted in `unresolved`.
const wrapped = Object.values(mod.functions)
.filter((c) => c.span.bytes[0] > end && WRAPPER_GAP.test(sliceBytes(mod.source, [end, c.span.bytes[0]])))
.sort((a, b) => a.span.bytes[0] - b.span.bytes[0])[0];
if (wrapped) return wrapped;
}
return undefined;
}

/** Text allowed between `export default ` and a wrapped handler: one or more `ident(` openings. */
const WRAPPER_GAP = /^(?:[A-Za-z_$][\w$.]*\s*\(\s*)+$/;

/**
* File-convention matcher: a rule matches when the module's file key matches its glob. Per name in
* `exports`, `"default"` resolves to the exported callable whose declaration text starts with
Expand Down
40 changes: 40 additions & 0 deletions src/entrypoints/rules.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,16 @@ frameworks:
- id: nextjs.pages-api
match: "pages/api/**/*.{ts,tsx,js,mjs}"
exports: [default]
# The `src/` layout Next.js supports officially (#206). Separate rows, not a loosened glob:
# `globToRegExp` anchors a pattern containing `/` at the repo root by design, and a file must
# match a convention path exactly, not anywhere in the tree.
- id: nextjs.app-route-src
match: "src/app/**/route.{ts,tsx,js,mjs}"
exports: [GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS]
methods: {from: export_name}
- id: nextjs.pages-api-src
match: "src/pages/api/**/*.{ts,tsx,js,mjs}"
exports: [default]

sveltekit:
detect: ["@sveltejs/kit"]
Expand All @@ -44,6 +54,36 @@ frameworks:
exports: [GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS]
methods: {from: export_name}

# Remix, and React Router v7 which absorbed it (#206) — a route module exports named callables,
# so the file tier resolves them with no resolution work. One rule per export rather than one rule
# listing all three: the `rule` id is then what tells a consumer whether it is looking at a data
# loader, a mutation, or the rendered component. `http_methods` stays empty — mapping `loader` to
# GET and `action` to the mutating verbs needs a per-export method source the grammar lacks, and
# inventing one here would be a spelling no sibling analyzer has agreed to.
remix:
detect: ["@remix-run/node", "@remix-run/react", "@remix-run/server-runtime", "@remix-run/cloudflare", react-router]
files:
- id: remix.loader
match: "app/routes/**/*.{ts,tsx,js,jsx}"
exports: [loader]
- id: remix.action
match: "app/routes/**/*.{ts,tsx,js,jsx}"
exports: [action]
- id: remix.route-component
match: "app/routes/**/*.{ts,tsx,js,jsx}"
exports: [default]

# Astro API routes (#206): verb-named exports beside the `.astro` pages. `.astro` files themselves
# are not parsed, so this covers the `.ts`/`.js` endpoints only. `ALL` is listed because it is a
# real Astro handler; `methods: {from: export_name}` filters it to [] since it is not an HTTP verb.
astro:
detect: [astro]
files:
- id: astro.api-route
match: "src/pages/**/*.{ts,js}"
exports: [GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS, ALL]
methods: {from: export_name}

# ---- non-web (#167). Framework-tier CALL rules: gated on the dependency AND matched on the
# import-table-resolved callee, so `app.on(...)` in an Express app or on any EventEmitter can
# never register as Electron. `route` carries the event / channel name for `on`/`handle` rules —
Expand Down
191 changes: 191 additions & 0 deletions test/entrypoints-jsts-frameworks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import { describe, expect, test } from "bun:test";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { analyze } from "../src/core";
import { methodsOf, routeFromFileKey } from "../src/entrypoints/matching";
import type { AnalysisOptions } from "../src/options";
import type { TSApplication, TSEntrypoint } from "../src/schema";
import { forEachCallable } from "../src/schema";

function fixture(files: Record<string, string>): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cants-ep206-"));
for (const [rel, text] of Object.entries(files)) {
fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true });
fs.writeFileSync(path.join(dir, rel), text);
}
fs.writeFileSync(path.join(dir, "tsconfig.json"), JSON.stringify({ compilerOptions: { target: "ES2020" }, include: ["**/*.ts", "**/*.tsx"] }));
return dir;
}
const opts = (input: string) => ({ input, appName: "f", analysisLevel: 1, eager: true, noBuild: true, emit: "json", graphs: ["cfg", "dfg", "pdg", "sdg"],
graphFieldDepth: 3, jobs: 1, skipTests: true, phantoms: true, entrypointRules: null }) as unknown as AnalysisOptions;
const rootOf = (r: { application: unknown }) => (r.application as { application: TSApplication }).application;

/** `<fileKey>:<callable name>` → its entrypoint records. */
async function entrypointsOf(dir: string): Promise<{ eps: Record<string, TSEntrypoint[]>; root: TSApplication }> {
const root = rootOf(await analyze(opts(dir)));
const eps: Record<string, TSEntrypoint[]> = {};
for (const [key, m] of Object.entries(root.symbol_table)) forEachCallable(m, (c) => { eps[`${key}:${c.name}`] = c.entrypoints ?? []; });
return { eps, root };
}

describe("Remix / React Router v7 route modules (#206)", () => {
test("`loader`, `action` and the route component each get their own rule id", async () => {
const dir = fixture({
"package.json": JSON.stringify({ name: "x", dependencies: { "@remix-run/node": "^2.8.0" } }),
"app/routes/users.ts": "export async function loader() { return []; }\nexport async function action() { return {}; }\nexport default function UsersRoute() {}\nfunction helper(): void {}",
"src/elsewhere.ts": "export async function loader() { return []; }",
});
const { eps, root } = await entrypointsOf(dir);
expect(eps["app/routes/users.ts:loader"]?.[0]).toMatchObject({
framework: "remix", rule: "remix.loader", confidence: "certain", evidence: "app/routes/users.ts", route: "/users", http_methods: [],
});
expect(eps["app/routes/users.ts:action"]?.[0]).toMatchObject({ rule: "remix.action", route: "/users" });
expect(eps["app/routes/users.ts:UsersRoute"]?.[0]).toMatchObject({ rule: "remix.route-component", route: "/users" });
expect(eps["app/routes/users.ts:helper"]).toEqual([]);
expect(eps["src/elsewhere.ts:loader"]).toEqual([]); // outside the convention path
expect(root.entrypoint_report.frameworks_detected).toEqual(["remix"]);
});

test("detects on react-router alone (v7 absorbed Remix)", async () => {
const dir = fixture({
"package.json": JSON.stringify({ name: "x", dependencies: { "react-router": "^7.0.0" } }),
"app/routes/home.ts": "export async function loader() { return []; }",
});
const { eps } = await entrypointsOf(dir);
expect(eps["app/routes/home.ts:loader"]?.[0]).toMatchObject({ framework: "remix", rule: "remix.loader", route: "/home" });
});
});

describe("Astro API routes (#206)", () => {
test("verb exports carry their method; `ALL` is not an HTTP method so it carries none", async () => {
const dir = fixture({
"package.json": JSON.stringify({ name: "x", dependencies: { astro: "^4.5.0" } }),
"src/pages/api/items.ts": "export function GET() {}\nexport function POST() {}\nexport function ALL() {}\nfunction helper(): void {}",
});
const { eps, root } = await entrypointsOf(dir);
expect(eps["src/pages/api/items.ts:GET"]?.[0]).toMatchObject({
framework: "astro", rule: "astro.api-route", confidence: "certain", route: "/api/items", http_methods: ["GET"],
});
expect(eps["src/pages/api/items.ts:POST"]?.[0]).toMatchObject({ http_methods: ["POST"] });
expect(eps["src/pages/api/items.ts:ALL"]?.[0]).toMatchObject({ rule: "astro.api-route", http_methods: [] });
expect(eps["src/pages/api/items.ts:helper"]).toEqual([]);
expect(root.entrypoint_report.frameworks_detected).toEqual(["astro"]);
});
});

describe("Next.js `src/` layout (#206)", () => {
test("src/app/**/route.ts and src/pages/api/** match, and the route drops the `src/` prefix", async () => {
const dir = fixture({
"package.json": JSON.stringify({ name: "x", dependencies: { next: "^14.0.0" } }),
"src/app/users/route.ts": "export async function GET(): Promise<void> {}",
"src/pages/api/hello.ts": "export default function handler(): void {}",
});
const { eps } = await entrypointsOf(dir);
expect(eps["src/app/users/route.ts:GET"]?.[0]).toMatchObject({
framework: "nextjs", rule: "nextjs.app-route-src", route: "/users", http_methods: ["GET"],
});
expect(eps["src/pages/api/hello.ts:handler"]?.[0]).toMatchObject({ rule: "nextjs.pages-api-src", route: "/api/hello" });
});

test("routeFromFileKey strips a leading `src/` from the glob's literal prefix only", () => {
expect(routeFromFileKey("src/app/users/route.ts", "src/app/**/route.{ts,tsx,js,mjs}")).toBe("/users");
expect(routeFromFileKey("src/app/route.ts", "src/app/**/route.{ts,tsx,js,mjs}")).toBe("/");
expect(routeFromFileKey("src/pages/api/hello.ts", "src/pages/api/**/*.{ts,tsx,js,mjs}")).toBe("/api/hello");
expect(routeFromFileKey("src/pages/api/items.ts", "src/pages/**/*.{ts,js}")).toBe("/api/items");
// Unchanged: no literal prefix means the whole key is the route (#161).
expect(routeFromFileKey("src/routes/x/+server.ts", "**/+server.{ts,js}")).toBe("/src/routes/x");
});
});

describe("handler and default-export resolution gaps (#206)", () => {
test("a NAMED function expression handler resolves, and stops being counted unresolved", async () => {
const dir = fixture({
"package.json": JSON.stringify({ name: "x", dependencies: { express: "^4.19.0" } }),
"src/server.ts": [
'import express from "express";',
"const app = express();",
'app.get("/named", function namedFnExpr(req: any, res: any) { res.json(1); });',
'app.get("/arrow", (req: any, res: any) => { res.json(2); });',
].join("\n"),
});
const { eps, root } = await entrypointsOf(dir);
expect(eps["src/server.ts:namedFnExpr"]?.[0]).toMatchObject({
framework: "heuristic", rule: "heuristic.http-verb-call", route: "/named", http_methods: ["GET"],
});
expect(eps["src/server.ts:(anonymous)"]?.[0]).toMatchObject({ route: "/arrow" });
expect(root.entrypoint_report.unresolved["app.get"]).toBeUndefined();
});

test("a nested callable inside the handler is not mistaken for the handler", async () => {
const dir = fixture({
"package.json": JSON.stringify({ name: "x", dependencies: { express: "^4.19.0" } }),
"src/nested.ts": [
'import express from "express";',
"const app = express();",
'app.post("/outer", function outerHandler(req: any, res: any) {',
" const inner = function innerFn() { return 1; };",
" res.json(inner());",
"});",
].join("\n"),
});
const { eps } = await entrypointsOf(dir);
expect(eps["src/nested.ts:outerHandler"]?.[0]).toMatchObject({ route: "/outer", http_methods: ["POST"] });
expect(eps["src/nested.ts:innerFn"] ?? []).toEqual([]);
});

test("`export default defineEventHandler(handler)` resolves the wrapped callable (Nitro/Nuxt shape)", async () => {
const dir = fixture({
"package.json": JSON.stringify({ name: "x", dependencies: { next: "^14.0.0" } }),
"pages/api/wrapped.ts": [
"declare function withMiddleware(h: (req: any, res: any) => any): any;",
"export default withMiddleware(async (req: any, res: any) => { res.json(1); });",
].join("\n"),
});
const { eps, root } = await entrypointsOf(dir);
const recs = Object.entries(eps).filter(([k, v]) => k.startsWith("pages/api/wrapped.ts") && v.length);
expect(recs.length, `no entrypoint resolved for the wrapped default export: ${JSON.stringify(Object.keys(eps))}`).toBe(1);
expect(recs[0]![1][0]).toMatchObject({ framework: "nextjs", rule: "nextjs.pages-api", route: "/api/wrapped" });
expect(root.entrypoint_report.unresolved["pages/api/wrapped.ts#default"]).toBeUndefined();
});

test("a wrapper whose handler is not a direct argument stays unresolved rather than guessing", async () => {
const dir = fixture({
"package.json": JSON.stringify({ name: "x", dependencies: { next: "^14.0.0" } }),
"pages/api/opts.ts": [
"declare function defineHandler(o: { onRequest: (e: any) => any }): any;",
"export default defineHandler({ onRequest: (event: any) => 1 });",
].join("\n"),
});
const { root } = await entrypointsOf(dir);
expect(root.entrypoint_report.unresolved["pages/api/opts.ts#default"]).toBe(1);
});

test("methodsOf(export_name) filters through the HTTP verb set", () => {
expect(methodsOf([], {}, { from: "export_name" }, "GET")).toEqual(["GET"]);
expect(methodsOf([], {}, { from: "export_name" }, "options")).toEqual(["OPTIONS"]);
expect(methodsOf([], {}, { from: "export_name" }, "ALL")).toEqual([]);
expect(methodsOf([], {}, { from: "export_name" }, "loader")).toEqual([]);
});
});

describe("all three frameworks in one project (#206)", () => {
test("frameworks_detected is exactly the three, and each rule claims only its own files", async () => {
const dir = fixture({
"package.json": JSON.stringify({ name: "x", dependencies: { next: "^14.0.0", astro: "^4.5.0", "@remix-run/node": "^2.8.0" } }),
"app/routes/a.ts": "export async function loader() { return []; }",
"src/pages/api/b.ts": "export function GET() {}\nexport default function page() {}",
"src/app/c/route.ts": "export async function POST(): Promise<void> {}",
});
const { eps, root } = await entrypointsOf(dir);
expect(root.entrypoint_report.frameworks_detected).toEqual(["astro", "nextjs", "remix"]);
expect(eps["app/routes/a.ts:loader"]?.map((e) => e.rule)).toEqual(["remix.loader"]);
expect(eps["src/app/c/route.ts:POST"]?.map((e) => e.rule)).toEqual(["nextjs.app-route-src"]);
// `src/pages/api/b.ts` sits under BOTH astro's `src/pages/**` and nextjs' `src/pages/api/**`,
// and both frameworks are detected — but the two rules disagree on the EXPORT, so a verb export
// is astro's alone. That is the file tier's real discriminator: the glob narrows the candidates,
// the `exports:` list decides. A `default` export in the same file would be nextjs' alone.
expect(eps["src/pages/api/b.ts:GET"]?.map((e) => e.rule)).toEqual(["astro.api-route"]);
expect(eps["src/pages/api/b.ts:page"]?.map((e) => e.rule)).toEqual(["nextjs.pages-api-src"]);
});
});
2 changes: 1 addition & 1 deletion test/entrypoints-rules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ describe("rules loader", () => {
test("shipped rules load and cover the frameworks the spec names", () => {
const r = loadRules([]);
expect(r.rulesets).toEqual(["shipped"]);
expect(Object.keys(r.frameworks).sort()).toEqual(["angular", "commander", "electron", "nestjs", "nextjs", "sveltekit", "worker_threads"]);
expect(Object.keys(r.frameworks).sort()).toEqual(["angular", "astro", "commander", "electron", "nestjs", "nextjs", "remix", "sveltekit", "worker_threads"]);
expect(r.heuristics.decorators.map((d) => d.id)).toEqual(["heuristic.http-route", "heuristic.http-verb"]);
expect(r.heuristics.calls.map((c) => c.id)).toEqual(["heuristic.http-verb-call", "heuristic.process-on"]);
// framework-tier call rules are NOT forced heuristic (#167)
Expand Down
Loading