From b522a835f3134822dccdfac39a53bd4560019e6f Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Sat, 5 Sep 2026 22:57:32 -0400 Subject: [PATCH 1/4] feat(entrypoints): file-convention matcher (Next.js app/pages, SvelteKit +server) --- src/entrypoints/matching.ts | 81 +++++++++++++++++++++++++++++++++- src/entrypoints/pipeline.ts | 17 ++++++- test/entrypoints-files.test.ts | 61 +++++++++++++++++++++++++ 3 files changed, 157 insertions(+), 2 deletions(-) create mode 100644 test/entrypoints-files.test.ts diff --git a/src/entrypoints/matching.ts b/src/entrypoints/matching.ts index 3967f80..3ce0989 100644 --- a/src/entrypoints/matching.ts +++ b/src/entrypoints/matching.ts @@ -6,7 +6,7 @@ */ import { forEachCallable, type TSCallable, type TSCallsite, type TSDecorator, type TSEntrypoint, type TSModule, type TSType } from "../schema"; import { callBodyKeys } from "../schema/l1Body"; -import type { ArgSpec, BaseRule, CallRule, DecoratorRule } from "./rules"; +import type { ArgSpec, BaseRule, CallRule, DecoratorRule, FileRule } from "./rules"; export class PatternError extends Error {} @@ -237,3 +237,82 @@ export function entrypointsFromBases( } return { classEps, methodEps }; } + +/** + * File-convention tier (#161; python has no analog — TS/JS-only). `match:` is a GLOB (`**` = any + * path prefix incl. none, `*` = within one segment, `{a,b}` alternation) tested against the + * module's project-relative POSIX file key, not a dotted name — so this gets its own tiny glob + * engine rather than reusing `compilePattern`. + */ +const globCache = new Map(); +export function globToRegExp(glob: string): RegExp { + const hit = globCache.get(glob); + if (hit) return hit; + let out = ""; + let i = 0; + while (i < glob.length) { + const ch = glob[i]!; + if (glob.startsWith("**/", i)) { out += "(?:.*/)?"; i += 3; } + else if (glob.startsWith("**", i)) { out += ".*"; i += 2; } + else if (ch === "*") { out += "[^/]*"; i++; } + else if (ch === "{") { + const j = glob.indexOf("}", i); + if (j < 0) throw new PatternError(`unclosed '{' in ${JSON.stringify(glob)}`); + out += `(?:${glob.slice(i + 1, j).split(",").map((a) => a.trim().replace(/[.+?^$()|[\]\\]/g, "\\$&")).join("|")})`; + i = j + 1; + } else { out += ch.replace(/[.+?^$()|[\]\\/]/g, "\\$&"); i++; } + } + const re = new RegExp(`^${out}$`); + globCache.set(glob, re); + return re; +} + +/** + * A convention, not a contract (#161): strips the glob's literal prefix directory only when it is + * `app/` (Next.js app router routes have no other segment worth keeping); `pages/api/...` keeps + * its `/api/...` tail since that's the actual served path. Drops the extension and a trailing + * `/route` or `/+server` segment; always anchors with a leading `/`; the app root `app/route.ts` + * → `/`. `app/users/route.ts` → `/users`; `pages/api/hello.ts` → `/api/hello`; + * `src/routes/x/+server.ts` → `/src/routes/x`. + */ +export function routeFromFileKey(fileKey: string, glob: string): string { + const literalPrefix = glob.split(/[*{]/, 1)[0]!; // "app/", "pages/api/", or "" (no literal prefix) + 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}` : "") || "/"; +} + +/** + * 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 + * `export default` (`TSModule.exports` never records these — see `l1Body`/builder notes); any + * other name resolves to the exported callable of that exact name. A name with no matching + * callable simply yields nothing — a route file legitimately exports only some verbs. + */ +export function entrypointsFromFiles( + mod: TSModule, + fileKey: string, + framework: string, + rules: readonly FileRule[], +): Array<{ target: TSCallable; ep: TSEntrypoint }> { + const out: Array<{ target: TSCallable; ep: TSEntrypoint }> = []; + for (const rule of rules) { + if (!globToRegExp(rule.match).test(fileKey)) continue; + for (const exp of rule.exports) { + const target = Object.values(mod.functions).find((c) => c.is_exported && (exp === "default" + ? mod.source.slice(c.span.bytes[0], c.span.bytes[1]).trimStart().startsWith("export default") + : c.name === exp)); + if (!target) continue; + out.push({ + target, + ep: { + framework, confidence: rule.confidence, rule: rule.id, ruleset: rule.origin, evidence: fileKey, + route: routeFromFileKey(fileKey, rule.match), http_methods: methodsOf([], {}, rule.methods, exp), + }, + }); + } + } + return out; +} diff --git a/src/entrypoints/pipeline.ts b/src/entrypoints/pipeline.ts index 6e7d8d6..4bda8c0 100644 --- a/src/entrypoints/pipeline.ts +++ b/src/entrypoints/pipeline.ts @@ -9,7 +9,7 @@ import { forEachCallable, forEachType, type AnalysisInternal, type TSCallable, type TSEntrypointReport, type TSType } from "../schema"; import { importTable, resolveWritten } from "../syntactic_analysis/importResolver"; import { detectedFrameworks, knownHeads, unnameable } from "./detect"; -import { entrypointsFromBases, entrypointsFromCalls, entrypointsFromDecorators } from "./matching"; +import { entrypointsFromBases, entrypointsFromCalls, entrypointsFromDecorators, entrypointsFromFiles } from "./matching"; import { EMPTY_RULES, type RuleSet } from "./rules"; export function detectEntrypoints(app: AnalysisInternal, rules: RuleSet = EMPTY_RULES): TSEntrypointReport { @@ -66,6 +66,21 @@ export function detectEntrypoints(app: AnalysisInternal, rules: RuleSet = EMPTY_ }); } + // File-convention tier (#161; TS/JS-only, no python analog): per module, per detected + // framework's `files:` rules, matched against the module's own file key — BEFORE the decorator + // tiers, same as bases above, so a file-rule claim counts as framework-claimed for + // never-doubles (`visit`'s `entrypoints.length === 0` gate on the heuristic decorator tier). + for (const [fileKey, mod] of Object.entries(app.symbol_table)) { + for (const name of frameworks) { + const fileRules = rules.frameworks[name]!.files; + if (!fileRules.length) continue; + for (const { target, ep } of entrypointsFromFiles(mod, fileKey, name, fileRules)) { + (target.entrypoints ??= []).push(ep); + target.is_entrypoint = target.entrypoints.length > 0; + } + } + } + // Framework tier (matches `qualified_name`), then the heuristic tier LAST (matches `name` as // written; never doubles a node a framework rule already claimed — python #185 parity). const visit = (node: TSCallable | TSType): void => { diff --git a/test/entrypoints-files.test.ts b/test/entrypoints-files.test.ts new file mode 100644 index 0000000..c8748f0 --- /dev/null +++ b/test/entrypoints-files.test.ts @@ -0,0 +1,61 @@ +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 { globToRegExp, routeFromFileKey } from "../src/entrypoints/matching"; +import type { AnalysisOptions } from "../src/options"; +import type { TSApplication } from "../src/schema"; +import { forEachCallable } from "../src/schema"; + +function fixture(files: Record): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cants-epf-")); + 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"] })); + 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; + +describe("file-convention matcher", () => { + test("globToRegExp", () => { + expect(globToRegExp("app/**/route.{ts,js}").test("app/users/[id]/route.ts")).toBe(true); + expect(globToRegExp("app/**/route.{ts,js}").test("app/route.ts")).toBe(true); + expect(globToRegExp("app/**/route.{ts,js}").test("src/app/route.ts")).toBe(false); + expect(globToRegExp("**/+server.ts").test("src/routes/x/+server.ts")).toBe(true); + expect(globToRegExp("pages/api/*.ts").test("pages/api/a/b.ts")).toBe(false); + }); + + test("routeFromFileKey: strips the glob's literal `app/`/`pages/` prefix and a trailing route/+server segment", () => { + expect(routeFromFileKey("app/users/route.ts", "app/**/route.{ts,tsx,js,mjs}")).toBe("/users"); + expect(routeFromFileKey("app/route.ts", "app/**/route.{ts,tsx,js,mjs}")).toBe("/"); + expect(routeFromFileKey("pages/api/hello.ts", "pages/api/**/*.{ts,tsx,js,mjs}")).toBe("/api/hello"); + expect(routeFromFileKey("src/routes/x/+server.ts", "**/+server.{ts,js}")).toBe("/src/routes/x"); + }); + + test("Next.js app router: exported verb functions are entrypoints, gated on the `next` dependency", async () => { + const dir = fixture({ + "package.json": JSON.stringify({ name: "x", dependencies: { next: "^14.0.0" } }), + "app/users/route.ts": "export async function GET(): Promise {}\nexport function POST(): void {}\nfunction helper(): void {}", + "pages/api/hello.ts": "export default function handler(): void {}\nexport function notDefault(): void {}", + "src/other.ts": "export function GET(): void {}", + }); + 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 ?? []; }); + expect(eps["app/users/route.ts:GET"]?.[0]).toMatchObject({ framework: "nextjs", rule: "nextjs.app-route", confidence: "certain", evidence: "app/users/route.ts", route: "/users", http_methods: ["GET"] }); + expect(eps["app/users/route.ts:POST"]?.[0]).toMatchObject({ http_methods: ["POST"] }); + expect(eps["app/users/route.ts:helper"]).toEqual([]); + expect(eps["pages/api/hello.ts:handler"]?.[0]).toMatchObject({ rule: "nextjs.pages-api", route: "/api/hello" }); + expect(eps["pages/api/hello.ts:notDefault"]).toEqual([]); + expect(eps["src/other.ts:GET"]).toEqual([]); // not under the convention path + expect(root.entrypoint_report.frameworks_detected).toEqual(["nextjs"]); + }); + + test("without the dependency, the same files register nothing", async () => { + const dir = fixture({ "app/users/route.ts": "export function GET(): void {}" }); + const root = rootOf(await analyze(opts(dir))); + for (const m of Object.values(root.symbol_table)) forEachCallable(m, (c) => { expect(c.entrypoints ?? []).toEqual([]); }); + }); +}); From 75167abdf2af51f0798bb67495ba973009efc044 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Sat, 5 Sep 2026 23:07:03 -0400 Subject: [PATCH 2/4] =?UTF-8?q?feat(entrypoints):=20manifest=20matcher=20(?= =?UTF-8?q?package.json=20main/bin=20=E2=86=92=20top-level-called=20free?= =?UTF-8?q?=20functions)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/entrypoints/matching.ts | 71 ++++++++++++++++++++++++++++++- src/entrypoints/pipeline.ts | 13 +++++- src/schema/emit.ts | 2 +- test/entrypoints-manifest.test.ts | 60 ++++++++++++++++++++++++++ 4 files changed, 141 insertions(+), 5 deletions(-) create mode 100644 test/entrypoints-manifest.test.ts diff --git a/src/entrypoints/matching.ts b/src/entrypoints/matching.ts index 3ce0989..fbaea2f 100644 --- a/src/entrypoints/matching.ts +++ b/src/entrypoints/matching.ts @@ -4,9 +4,11 @@ * Patterns are dotted names: `{a,b}` alternates (a `*` inside an alternative keeps its meaning), * `*` matches ONE dotless segment, everything else is literal, and the match is anchored. */ -import { forEachCallable, type TSCallable, type TSCallsite, type TSDecorator, type TSEntrypoint, type TSModule, type TSType } from "../schema"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { forEachCallable, type AnalysisInternal, type TSCallable, type TSCallsite, type TSDecorator, type TSEntrypoint, type TSModule, type TSType } from "../schema"; import { callBodyKeys } from "../schema/l1Body"; -import type { ArgSpec, BaseRule, CallRule, DecoratorRule, FileRule } from "./rules"; +import type { ArgSpec, BaseRule, CallRule, DecoratorRule, FileRule, ManifestRule } from "./rules"; export class PatternError extends Error {} @@ -316,3 +318,68 @@ export function entrypointsFromFiles( } return out; } + +/** + * Manifest tier (#161; python has no analog): `package.json` is read from the artifact layer + * (keyed by repo-relative path — the artifact record for the root manifest is always `"package.json"`), + * falling back to disk when the artifact layer has no record (e.g. repo sections skipped). + */ +function manifestOf(app: AnalysisInternal, input: string): Record | undefined { + const text = app.artifacts?.["package.json"]?.source + ?? (() => { try { return fs.readFileSync(path.join(input, "package.json"), "utf8"); } catch { return undefined; } })(); + if (!text) return undefined; + try { const j = JSON.parse(text); return typeof j === "object" && j ? (j as Record) : undefined; } catch { return undefined; } +} + +const EXTS = ["", ".ts", ".tsx", ".js", ".mjs", ".cjs"]; +/** `dist/index.js` → the module `src/index.ts` (or `index.ts`, or as written) — whichever the symbol table has. */ +function moduleForPath(app: AnalysisInternal, declared: string): string | undefined { + const rel = declared.replace(/\\/g, "/").replace(/^\.\//, ""); + const stem = rel.replace(/\.(tsx|ts|jsx|js|mjs|cjs)$/, ""); + const bases = [stem, stem.replace(/^(dist|out|build|lib)\//, "src/"), stem.replace(/^(dist|out|build|lib)\//, "")]; + for (const b of bases) for (const ext of EXTS) if (app.symbol_table[b + ext]) return b + ext; + return undefined; +} + +/** + * Manifest tier: a `main`/`bin` entry names a FILE, and "what runs when that file is executed" is + * its module-scope calls (python has no analog — an npm-specific convention). Not a "heuristic" + * framework — `never-doubles` (pipeline.ts calls tier) does not apply: a callable can legitimately + * be both a framework handler AND a manifest-declared root, so this pushes unconditionally. + */ +export function entrypointsFromManifest( + app: AnalysisInternal, + input: string, + rules: readonly ManifestRule[], + unresolved: (key: string) => void, +): Array<{ target: TSCallable; ep: TSEntrypoint }> { + const out: Array<{ target: TSCallable; ep: TSEntrypoint }> = []; + const pkg = manifestOf(app, input); + if (!pkg) return out; + for (const rule of rules) { + const raw = pkg[rule.field]; + const paths: string[] = typeof raw === "string" ? [raw] + : raw && typeof raw === "object" ? Object.values(raw as Record).filter((v): v is string => typeof v === "string") + : []; + for (const p of paths) { + const key = moduleForPath(app, p); + const mod = key ? app.symbol_table[key] : undefined; + if (!mod) { unresolved(`package.json#${rule.field}:${p}`); continue; } + const free = new Map(Object.values(mod.functions ?? {}).map((c) => [c.name, c] as const)); + let hit = false; + for (const site of mod.call_sites ?? []) { + if (site.receiver_expr) continue; + const target = free.get(site.method_name); + if (!target) continue; + hit = true; + out.push({ + target, + ep: { framework: "manifest", confidence: rule.confidence, rule: rule.id, ruleset: rule.origin, + evidence: `package.json#${rule.field}`, http_methods: [], via: mod.id }, + }); + } + if (!hit) unresolved(`package.json#${rule.field}:${p}`); + } + } + return out; +} diff --git a/src/entrypoints/pipeline.ts b/src/entrypoints/pipeline.ts index 4bda8c0..b9f7c08 100644 --- a/src/entrypoints/pipeline.ts +++ b/src/entrypoints/pipeline.ts @@ -6,13 +6,14 @@ * analysis — so the error path records into the report rather than throwing. (Loading the rules * is the one hard-error step, and it happens in analyze(), before any of this.) */ +import type { AnalysisOptions } from "../options"; import { forEachCallable, forEachType, type AnalysisInternal, type TSCallable, type TSEntrypointReport, type TSType } from "../schema"; import { importTable, resolveWritten } from "../syntactic_analysis/importResolver"; import { detectedFrameworks, knownHeads, unnameable } from "./detect"; -import { entrypointsFromBases, entrypointsFromCalls, entrypointsFromDecorators, entrypointsFromFiles } from "./matching"; +import { entrypointsFromBases, entrypointsFromCalls, entrypointsFromDecorators, entrypointsFromFiles, entrypointsFromManifest } from "./matching"; import { EMPTY_RULES, type RuleSet } from "./rules"; -export function detectEntrypoints(app: AnalysisInternal, rules: RuleSet = EMPTY_RULES): TSEntrypointReport { +export function detectEntrypoints(app: AnalysisInternal, opts: AnalysisOptions, rules: RuleSet = EMPTY_RULES): TSEntrypointReport { const report: TSEntrypointReport = { frameworks_detected: [], rulesets: [...rules.rulesets], unresolved: {}, errors: [] }; const bump = (k: string): void => { report.unresolved[k] = (report.unresolved[k] ?? 0) + 1; }; try { @@ -107,6 +108,14 @@ export function detectEntrypoints(app: AnalysisInternal, rules: RuleSet = EMPTY_ target.is_entrypoint = true; } } + + // Manifest tier (#161), last: `package.json` main/bin name FILES, not framework spellings — + // never-doubles does not apply (a callable can legitimately be both a framework handler and a + // manifest-declared root), so this pushes unconditionally. + for (const { target, ep } of entrypointsFromManifest(app, opts.input, rules.manifest, bump)) { + (target.entrypoints ??= []).push(ep); + target.is_entrypoint = true; + } } catch (e) { report.errors.push((e as Error).message); } diff --git a/src/schema/emit.ts b/src/schema/emit.ts index 4ec8675..4f17f33 100644 --- a/src/schema/emit.ts +++ b/src/schema/emit.ts @@ -100,7 +100,7 @@ export function finalizeAnalysis( populateL1Body(app); resolveHeritageIds(app, idBySig); // Level-free, after heritage: unit 4 matches on resolved extends_ids. - const entrypoint_report = detectEntrypoints(app, rules); + const entrypoint_report = detectEntrypoints(app, opts, rules); const root: TSApplication = { id: appId, diff --git a/test/entrypoints-manifest.test.ts b/test/entrypoints-manifest.test.ts new file mode 100644 index 0000000..183d7c6 --- /dev/null +++ b/test/entrypoints-manifest.test.ts @@ -0,0 +1,60 @@ +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 { entrypointsFromManifest } from "../src/entrypoints/matching"; +import { loadRules } from "../src/entrypoints/rules"; +import type { AnalysisOptions } from "../src/options"; +import type { AnalysisInternal, TSApplication } from "../src/schema"; +import { forEachCallable } from "../src/schema"; + +function fixture(files: Record): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cants-epm-")); + 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: ["src/**/*.ts"] })); + return dir; +} +const opts = (input: string, extra: Record = {}) => ({ input, appName: "m", analysisLevel: 1, eager: true, noBuild: true, emit: "json", + graphs: ["cfg","dfg","pdg","sdg"], graphFieldDepth: 3, jobs: 1, skipTests: true, phantoms: true, entrypointRules: null, ...extra }) as unknown as AnalysisOptions; +const rootOf = (r: { application: unknown }) => (r.application as { application: TSApplication }).application; +const collect = (root: TSApplication) => { const o: Record = {}; for (const m of Object.values(root.symbol_table)) forEachCallable(m, (c) => { o[c.name] = c.entrypoints ?? []; }); return o; }; + +describe("manifest matcher", () => { + test("main → the free functions the entry module calls at top level, confidence declared", async () => { + const dir = fixture({ + "package.json": JSON.stringify({ name: "x", main: "dist/index.js", bin: { cli: "./dist/cli.js" } }), + "src/index.ts": "export function boot(): void {}\nboot();\nconsole.log('x');", + "src/cli.ts": "import { boot } from './index';\nfunction run(): void { boot(); }\nrun();", + "src/lib.ts": "export function unused(): void {}", + }); + const root = rootOf(await analyze(opts(dir))); + const eps = collect(root); + expect(eps.boot).toEqual([{ framework: "manifest", confidence: "declared", rule: "manifest.main", ruleset: "shipped", + evidence: "package.json#main", http_methods: [], via: expect.stringMatching(/src\/index\.ts$/) }]); + expect(eps.run?.[0]).toMatchObject({ rule: "manifest.bin", evidence: "package.json#bin" }); + expect(eps.unused).toEqual([]); + expect(root.entrypoint_report.unresolved).toEqual({}); // console.log has a receiver → not a candidate, not "unresolved" + }); + + test("a main that points nowhere is counted, not fabricated", async () => { + const dir = fixture({ "package.json": JSON.stringify({ name: "x", main: "dist/nope.js" }), "src/a.ts": "export const x = 1;" }); + const root = rootOf(await analyze(opts(dir))); + expect(root.entrypoint_report.unresolved).toEqual({ "package.json#main:dist/nope.js": 1 }); + }); + + // Amendment (noRepoSections doesn't exist on this branch): call entrypointsFromManifest directly + // to exercise the disk-fallback path used when the artifact layer hasn't captured package.json. + // Uses `res.internal` (the live tree) rather than the wire application: `call_sites` is + // INTERNAL and stripped from the wire clone (src/schema/emit.ts `stripInternal`) — a + // module-scope call has no `body{}` home to survive onto the wire at all. + test("entrypointsFromManifest resolves main directly, independent of the artifact layer", async () => { + const dir = fixture({ "package.json": JSON.stringify({ name: "x", main: "src/index.ts" }), "src/index.ts": "export function boot(): void {}\nboot();" }); + const res = await analyze(opts(dir)); + const app = { symbol_table: res.internal.symbol_table, artifacts: {} } as unknown as AnalysisInternal; + const rules = loadRules([]); + const unresolved = new Map(); + const records = entrypointsFromManifest(app, dir, rules.manifest, (k) => unresolved.set(k, (unresolved.get(k) ?? 0) + 1)); + expect(records.some((r) => r.target.name === "boot" && r.ep.rule === "manifest.main")).toBe(true); + }); +}); From 02d7c39dadcc903ebb48536882ec8523e42789fe Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Sat, 5 Sep 2026 23:12:47 -0400 Subject: [PATCH 3/4] test(entrypoints): level invariance across a warm cache with every tier firing; regenerate README help --- README.md | 2 ++ test/entrypoints-invariance.test.ts | 35 +++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 test/entrypoints-invariance.test.ts diff --git a/README.md b/README.md index 3d681bd..1094979 100644 --- a/README.md +++ b/README.md @@ -190,6 +190,8 @@ Options: binding (default: repo files only) --no-artifact-text keep the artifact inventory but drop captured raw text + --entrypoint-rules extra entrypoint rules file(s), merged with the + shipped set; repeatable -c, --cache-dir cache/intermediate directory -v, --verbose increase verbosity (repeatable) -h, --help display help for command diff --git a/test/entrypoints-invariance.test.ts b/test/entrypoints-invariance.test.ts new file mode 100644 index 0000000..c304a95 --- /dev/null +++ b/test/entrypoints-invariance.test.ts @@ -0,0 +1,35 @@ +// test/entrypoints-invariance.test.ts +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 type { AnalysisOptions } from "../src/options"; +import type { TSApplication } from "../src/schema"; +import { forEachCallable, forEachType } from "../src/schema"; + +const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cants-epi-")); +fs.mkdirSync(path.join(dir, "src")); +fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ name: "x", main: "src/index.ts", dependencies: { "@nestjs/common": "^10", express: "^4" } })); +fs.writeFileSync(path.join(dir, "src", "index.ts"), [ + 'import { Controller, Get } from "@nestjs/common";', 'import express from "express";', + "@Controller('/u') export class U { @Get() list(): string { return ''; } }", + "const app = express(); export function h(): void {} app.get('/h', h); export function boot(): void {} boot();", +].join("\n")); +fs.writeFileSync(path.join(dir, "tsconfig.json"), JSON.stringify({ compilerOptions: { target: "ES2020", experimentalDecorators: true }, include: ["src/**/*.ts"] })); +const opts = (analysisLevel: number, eager: boolean) => ({ input: dir, appName: "i", analysisLevel, eager, 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; +const stamped = (root: TSApplication) => { const o: Record = { __report: root.entrypoint_report }; + for (const m of Object.values(root.symbol_table)) { forEachCallable(m, (c) => { o[c.id] = [c.entrypoints, c.is_entrypoint]; }); forEachType(m, (t) => { o[t.id] = [t.entrypoints, t.is_entrypoint]; }); } return o; }; + +describe("entrypoints are identical at every -a", () => { + test("L1 cold, then L2-L4 warm, with every matcher kind firing", async () => { + const l1 = stamped(rootOf(await analyze(opts(1, true)))); + expect(l1.__report).toMatchObject({ frameworks_detected: ["nestjs"], errors: [] }); + // sanity: something actually fired at each tier + const all = JSON.stringify(l1); + for (const rule of ["nestjs.controller", "nestjs.verb", "heuristic.http-verb-call", "manifest.main"]) expect(all).toContain(rule); + for (const level of [2, 3, 4]) expect(stamped(rootOf(await analyze(opts(level, false))))).toEqual(l1); + }); +}); From 9a060ad472484326bf32fcc019c03dbf5f82319d Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Sat, 5 Sep 2026 23:25:30 -0400 Subject: [PATCH 4/4] fix(entrypoints): manifest fallback survives --no-artifact-text; count missing pages/api defaults, bad manifests; validate file globs at load --- src/entrypoints/matching.ts | 70 +++++++++++++++++++++++++------ src/entrypoints/pipeline.ts | 2 +- src/entrypoints/rules.ts | 9 +++- test/entrypoints-files.test.ts | 37 ++++++++++++++++ test/entrypoints-manifest.test.ts | 25 +++++++++++ test/entrypoints-rules.test.ts | 6 +++ 6 files changed, 134 insertions(+), 15 deletions(-) diff --git a/src/entrypoints/matching.ts b/src/entrypoints/matching.ts index fbaea2f..d7da803 100644 --- a/src/entrypoints/matching.ts +++ b/src/entrypoints/matching.ts @@ -286,27 +286,60 @@ export function routeFromFileKey(fileKey: string, glob: string): string { return prefixDir + (noTail ? `/${noTail}` : "") || "/"; } +const DEFAULT_NAMED_EXPORT = /^\s*export\s+default\s+([A-Za-z_$][\w$]*)\s*;?\s*$/m; +const DEFAULT_EXPORT_TOKEN = /export\s+default\s+/g; + +/** + * Two spellings the direct-declaration check above misses (findings, unit 5 review): `export + * default handler;` naming a callable declared elsewhere (the identifier need not itself be + * `is_exported`), and `export default (…) => {}`/`export default async (…) => {}` whose anonymous + * callable's span starts right after the `export default ` token, not at `export`. + */ +function resolveDefaultExport(mod: TSModule): TSCallable | undefined { + const named = mod.source.match(DEFAULT_NAMED_EXPORT); + if (named) { + const target = Object.values(mod.functions).find((c) => c.name === named[1]); + if (target) return target; + } + DEFAULT_EXPORT_TOKEN.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = DEFAULT_EXPORT_TOKEN.exec(mod.source))) { + const end = 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; + } + return undefined; +} + /** * 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 - * `export default` (`TSModule.exports` never records these — see `l1Body`/builder notes); any - * other name resolves to the exported callable of that exact name. A name with no matching - * callable simply yields nothing — a route file legitimately exports only some verbs. + * `export default` (`TSModule.exports` never records these — see `l1Body`/builder notes); failing + * that, `resolveDefaultExport` tries the `export default ;` and anonymous-inline spellings. + * Any other name resolves to the exported callable of that exact name. A name with no matching + * callable yields nothing for that name — a route file legitimately exports only some verbs — EXCEPT + * `"default"`, whose continued absence is counted via `unresolved` so a missed default export isn't + * silent. */ export function entrypointsFromFiles( mod: TSModule, fileKey: string, framework: string, rules: readonly FileRule[], + unresolved: (key: string) => void, ): Array<{ target: TSCallable; ep: TSEntrypoint }> { const out: Array<{ target: TSCallable; ep: TSEntrypoint }> = []; for (const rule of rules) { if (!globToRegExp(rule.match).test(fileKey)) continue; for (const exp of rule.exports) { - const target = Object.values(mod.functions).find((c) => c.is_exported && (exp === "default" + let target = Object.values(mod.functions).find((c) => c.is_exported && (exp === "default" ? mod.source.slice(c.span.bytes[0], c.span.bytes[1]).trimStart().startsWith("export default") : c.name === exp)); - if (!target) continue; + if (!target && exp === "default") target = resolveDefaultExport(mod); + if (!target) { + if (exp === "default") unresolved(`${fileKey}#default`); + continue; + } out.push({ target, ep: { @@ -322,13 +355,24 @@ export function entrypointsFromFiles( /** * Manifest tier (#161; python has no analog): `package.json` is read from the artifact layer * (keyed by repo-relative path — the artifact record for the root manifest is always `"package.json"`), - * falling back to disk when the artifact layer has no record (e.g. repo sections skipped). + * falling back to disk when the artifact layer has no record OR recorded an empty `source` + * (`--no-artifact-text` stores `""`, not absence — `||`, not `??`, so that case still falls + * through to disk instead of silently disabling this whole tier). + * + * Returns `undefined` when there is no manifest text at all (nothing to report), or `{ error: + * true }` when text existed but did not parse as a JSON object (a malformed manifest — counted by + * the caller, not swallowed). */ -function manifestOf(app: AnalysisInternal, input: string): Record | undefined { +function manifestOf(app: AnalysisInternal, input: string): { pkg: Record } | { error: true } | undefined { const text = app.artifacts?.["package.json"]?.source - ?? (() => { try { return fs.readFileSync(path.join(input, "package.json"), "utf8"); } catch { return undefined; } })(); + || (() => { try { return fs.readFileSync(path.join(input, "package.json"), "utf8"); } catch { return undefined; } })(); if (!text) return undefined; - try { const j = JSON.parse(text); return typeof j === "object" && j ? (j as Record) : undefined; } catch { return undefined; } + try { + const j = JSON.parse(text); + return typeof j === "object" && j ? { pkg: j as Record } : { error: true }; + } catch { + return { error: true }; + } } const EXTS = ["", ".ts", ".tsx", ".js", ".mjs", ".cjs"]; @@ -354,8 +398,10 @@ export function entrypointsFromManifest( unresolved: (key: string) => void, ): Array<{ target: TSCallable; ep: TSEntrypoint }> { const out: Array<{ target: TSCallable; ep: TSEntrypoint }> = []; - const pkg = manifestOf(app, input); - if (!pkg) return out; + const result = manifestOf(app, input); + if (!result) return out; + if ("error" in result) { unresolved("package.json"); return out; } + const pkg = result.pkg; for (const rule of rules) { const raw = pkg[rule.field]; const paths: string[] = typeof raw === "string" ? [raw] @@ -365,7 +411,7 @@ export function entrypointsFromManifest( const key = moduleForPath(app, p); const mod = key ? app.symbol_table[key] : undefined; if (!mod) { unresolved(`package.json#${rule.field}:${p}`); continue; } - const free = new Map(Object.values(mod.functions ?? {}).map((c) => [c.name, c] as const)); + const free = new Map(Object.values(mod.functions).map((c) => [c.name, c] as const)); let hit = false; for (const site of mod.call_sites ?? []) { if (site.receiver_expr) continue; diff --git a/src/entrypoints/pipeline.ts b/src/entrypoints/pipeline.ts index b9f7c08..ce25f68 100644 --- a/src/entrypoints/pipeline.ts +++ b/src/entrypoints/pipeline.ts @@ -75,7 +75,7 @@ export function detectEntrypoints(app: AnalysisInternal, opts: AnalysisOptions, for (const name of frameworks) { const fileRules = rules.frameworks[name]!.files; if (!fileRules.length) continue; - for (const { target, ep } of entrypointsFromFiles(mod, fileKey, name, fileRules)) { + for (const { target, ep } of entrypointsFromFiles(mod, fileKey, name, fileRules, bump)) { (target.entrypoints ??= []).push(ep); target.is_entrypoint = target.entrypoints.length > 0; } diff --git a/src/entrypoints/rules.ts b/src/entrypoints/rules.ts index 23c6db7..1c8b673 100644 --- a/src/entrypoints/rules.ts +++ b/src/entrypoints/rules.ts @@ -79,7 +79,7 @@ export const EMPTY_RULES: RuleSet = { frameworks: {}, heuristics: { decorators: // --- loader (Task 3) -------------------------------------------------------------------------- import * as fs from "node:fs"; import { parse as parseYaml } from "yaml"; -import { PatternError, validatePattern } from "./matching"; +import { PatternError, globToRegExp, validatePattern } from "./matching"; import SHIPPED_YAML from "./rules.yml" with { type: "text" }; const CONFIDENCE: ReadonlySet = new Set(["declared", "certain", "heuristic"]); @@ -212,7 +212,12 @@ function fileRule(raw0: unknown, origin: string): FileRule { const raw = asRaw(raw0, origin); const exports = list(require(raw, "exports", origin)).map(String); if (!exports.length) throw new RulesError(`${origin}: file rule ${JSON.stringify(raw.id)} needs a non-empty \`exports\``); - return { id: String(require(raw, "id", origin)), match: String(require(raw, "match", origin)), exports, + const matchGlob = String(require(raw, "match", origin)); + try { globToRegExp(matchGlob); } catch (e) { + if (e instanceof PatternError) throw new RulesError(`${origin}: file rule ${JSON.stringify(raw.id ?? raw)}: ${e.message}`); + throw e; + } + return { id: String(require(raw, "id", origin)), match: matchGlob, exports, confidence: confidence(raw, origin), methods: argSpec(raw.methods, "methods", origin), origin }; } function manifestRule(raw0: unknown, origin: string): ManifestRule { diff --git a/test/entrypoints-files.test.ts b/test/entrypoints-files.test.ts index c8748f0..400868c 100644 --- a/test/entrypoints-files.test.ts +++ b/test/entrypoints-files.test.ts @@ -53,6 +53,43 @@ describe("file-convention matcher", () => { expect(root.entrypoint_report.frameworks_detected).toEqual(["nextjs"]); }); + // Important 2 (unit 5 review): two `pages/api` default-export spellings the direct-declaration + // check misses. (a) a named function declared separately, then `export default handler;`. + test("pages/api: `export default ;` naming a separately-declared function (path a)", async () => { + const dir = fixture({ + "package.json": JSON.stringify({ name: "x", dependencies: { next: "^14.0.0" } }), + "pages/api/named.ts": "function handler() {}\nexport default handler;", + }); + 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 ?? []; }); + expect(eps["pages/api/named.ts:handler"]?.[0]).toMatchObject({ rule: "nextjs.pages-api", route: "/api/named" }); + }); + + // (b) an anonymous inline default export — the callable's span starts right after `default `. + test("pages/api: `export default async (req, res) => {}` inline anonymous handler (path b)", async () => { + const dir = fixture({ + "package.json": JSON.stringify({ name: "x", dependencies: { next: "^14.0.0" } }), + "pages/api/inline.ts": "export default async (req: unknown, res: unknown) => {};", + }); + 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 ?? []; }); + const hits = Object.entries(eps).filter(([k, v]) => k.startsWith("pages/api/inline.ts:") && (v as unknown[]).length > 0); + expect(hits).toHaveLength(1); + expect(hits[0]![1][0]).toMatchObject({ rule: "nextjs.pages-api", route: "/api/inline" }); + }); + + // (c) no default export at all: the miss is counted, not silent. + test("pages/api with no default export counts as unresolved (path c)", async () => { + const dir = fixture({ + "package.json": JSON.stringify({ name: "x", dependencies: { next: "^14.0.0" } }), + "pages/api/x.ts": "export function notDefault(): void {}", + }); + const root = rootOf(await analyze(opts(dir))); + expect(root.entrypoint_report.unresolved["pages/api/x.ts#default"]).toBe(1); + }); + test("without the dependency, the same files register nothing", async () => { const dir = fixture({ "app/users/route.ts": "export function GET(): void {}" }); const root = rootOf(await analyze(opts(dir))); diff --git a/test/entrypoints-manifest.test.ts b/test/entrypoints-manifest.test.ts index 183d7c6..bacfa59 100644 --- a/test/entrypoints-manifest.test.ts +++ b/test/entrypoints-manifest.test.ts @@ -43,6 +43,31 @@ describe("manifest matcher", () => { expect(root.entrypoint_report.unresolved).toEqual({ "package.json#main:dist/nope.js": 1 }); }); + // Important 1 (unit 5 review): `--no-artifact-text` stores `source: ""` on the artifact record, + // not absence — `manifestOf`'s old `??` fallback treated "" as present and never fell through to + // disk, silently disabling this whole tier. Same fixture/assertion as the first test above, run + // with text capture off. + test("--no-artifact-text still resolves the manifest tier via the disk fallback", async () => { + const dir = fixture({ + "package.json": JSON.stringify({ name: "x", main: "dist/index.js", bin: { cli: "./dist/cli.js" } }), + "src/index.ts": "export function boot(): void {}\nboot();\nconsole.log('x');", + "src/cli.ts": "import { boot } from './index';\nfunction run(): void { boot(); }\nrun();", + "src/lib.ts": "export function unused(): void {}", + }); + const root = rootOf(await analyze(opts(dir, { artifactText: false }))); + const eps = collect(root); + expect(eps.boot).toEqual([{ framework: "manifest", confidence: "declared", rule: "manifest.main", ruleset: "shipped", + evidence: "package.json#main", http_methods: [], via: expect.stringMatching(/src\/index\.ts$/) }]); + }); + + // Minor 3: a manifest that exists but fails to parse is counted, not silently dropped. + test("a malformed package.json is counted as unresolved, and the pass otherwise completes", async () => { + const dir = fixture({ "package.json": "{ not json", "src/a.ts": "export const x = 1;" }); + const root = rootOf(await analyze(opts(dir))); + expect(root.entrypoint_report.unresolved).toEqual({ "package.json": 1 }); + expect(root.entrypoint_report.errors).toEqual([]); + }); + // Amendment (noRepoSections doesn't exist on this branch): call entrypointsFromManifest directly // to exercise the disk-fallback path used when the artifact layer hasn't captured package.json. // Uses `res.internal` (the live tree) rather than the wire application: `call_sites` is diff --git a/test/entrypoints-rules.test.ts b/test/entrypoints-rules.test.ts index 16c0610..2485e83 100644 --- a/test/entrypoints-rules.test.ts +++ b/test/entrypoints-rules.test.ts @@ -55,6 +55,12 @@ describe("rules loader", () => { expect(() => loadRules([tmp("version: 1\nframeworks:\n x:\n detect: \"@x/y\"\n")])).toThrow(/`detect` must be a list/); }); + test("a file rule's glob is validated at load, not at detection time (Minor 4)", () => { + expect(() => loadRules([tmp( + "version: 1\nframeworks:\n x:\n files:\n - {id: x.a, match: \"app/**/{route.ts\", exports: [default]}\n", + )])).toThrow(RulesError); + }); + test("--entrypoint-rules is repeatable and lands in options", () => { const o = parseArgs(["-i", ".", "--entrypoint-rules", "a.yml", "--entrypoint-rules", "b.yml"]); expect(o.entrypointRules).toEqual(["a.yml", "b.yml"]);