From 41e218bc63d65a695e298268367697e932ab49ff Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Fri, 11 Sep 2026 10:37:38 -0400 Subject: [PATCH] feat(entrypoints): Remix, Astro and Next.js `src/` layout rules (#206) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four framework rows the shipped ruleset was missing, plus the resolver gaps they exposed. No schema change: no new field, node label, edge type or property — only new values in `framework`/`rule`/`route`/`http_methods`, the same class of change a user's own `--entrypoint-rules` file makes. Rules (`src/entrypoints/rules.yml`): - `remix` — detected on any `@remix-run/*` or on `react-router` alone, since v7 absorbed Remix. Three file rules over `app/routes/**`, one per export (`loader`, `action`, `default`), so the `rule` id 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 needs a per-export method source the rule grammar does not have. - `astro` — `astro.api-route` over `src/pages/**/*.{ts,js}`, verb exports plus `ALL`. `.astro` files are not parsed, so this covers the endpoints only. - `nextjs` — `nextjs.app-route-src` and `nextjs.pages-api-src` for the `src/` layout Next.js supports officially. Separate rows rather than a loosened glob: a convention path must match exactly, not anywhere in the tree. Resolver fixes (`src/entrypoints/matching.ts`): - `methodsOf` filters `export_name` through `HTTP_VERBS`, as `match_suffix` already did. Astro's `ALL` is a real handler but not an HTTP method, and `http_methods` is a field consumers filter on. - `routeFromFileKey` strips the glob's leading LAYOUT directories — `app/`, `pages/`, Remix's `app/routes/`, and an optional `src/` in front of any of them. Whatever literal prefix survives is a route prefix, so `pages/api/` still serves at `/api`. A glob with no literal prefix is untouched. - `resolveHandler`'s INLINE branch no longer gates on `name === "(anonymous)"`: `app.get("/x", function named(req, res) {})` is a named function expression, and gating on the name resolved nothing while counting the site unresolved. Position identifies it; the outermost callable in the span wins, so a callable nested inside the handler is never picked. - `resolveDefaultExport` handles `export default ()` — Nitro/Nuxt's `defineEventHandler(h)` and every `withSentry(h)` shape. The gap between token and callable must be nothing but call openings, which is what keeps `defineHandler({onRequest: fn})` counted unresolved instead of claiming an unrelated later callable. Tests: `test/entrypoints-jsts-frameworks.test.ts` (11 tests) covers each rule id, the three-framework project where astro and nextjs globs overlap but their `exports:` lists disagree, both resolver gaps, and the guard that a wrapper whose handler is not a direct argument stays unresolved. --- src/entrypoints/matching.ts | 31 +++- src/entrypoints/rules.yml | 40 +++++ test/entrypoints-jsts-frameworks.test.ts | 191 +++++++++++++++++++++++ test/entrypoints-rules.test.ts | 2 +- 4 files changed, 259 insertions(+), 5 deletions(-) create mode 100644 test/entrypoints-jsts-frameworks.test.ts diff --git a/src/entrypoints/matching.ts b/src/entrypoints/matching.ts index f155142..bb666de 100644 --- a/src/entrypoints/matching.ts +++ b/src/entrypoints/matching.ts @@ -98,7 +98,9 @@ export function methodsOf(args: string[], kwargs: Record, 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 []; } @@ -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]); @@ -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; @@ -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 ()` (#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 diff --git a/src/entrypoints/rules.yml b/src/entrypoints/rules.yml index 47e4eef..2158f9f 100644 --- a/src/entrypoints/rules.yml +++ b/src/entrypoints/rules.yml @@ -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"] @@ -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 — diff --git a/test/entrypoints-jsts-frameworks.test.ts b/test/entrypoints-jsts-frameworks.test.ts new file mode 100644 index 0000000..7aad444 --- /dev/null +++ b/test/entrypoints-jsts-frameworks.test.ts @@ -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 { + 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; + +/** `:` → its entrypoint records. */ +async function entrypointsOf(dir: string): Promise<{ eps: Record; root: TSApplication }> { + const root = rootOf(await analyze(opts(dir))); + const eps: Record = {}; + 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 {}", + "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 {}", + }); + 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"]); + }); +}); diff --git a/test/entrypoints-rules.test.ts b/test/entrypoints-rules.test.ts index 14c2a7c..8786497 100644 --- a/test/entrypoints-rules.test.ts +++ b/test/entrypoints-rules.test.ts @@ -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)