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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,8 @@ Options:
binding (default: repo files only)
--no-artifact-text keep the artifact inventory but drop captured
raw text
--entrypoint-rules <yaml...> extra entrypoint rules file(s), merged with the
shipped set; repeatable
-c, --cache-dir <dir> cache/intermediate directory
-v, --verbose increase verbosity (repeatable)
-h, --help display help for command
Expand Down
196 changes: 194 additions & 2 deletions src/entrypoints/matching.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } from "./rules";
import type { ArgSpec, BaseRule, CallRule, DecoratorRule, FileRule, ManifestRule } from "./rules";

export class PatternError extends Error {}

Expand Down Expand Up @@ -237,3 +239,193 @@ 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<string, RegExp>();
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}` : "") || "/";
}

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); failing
* that, `resolveDefaultExport` tries the `export default <name>;` 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) {
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 && exp === "default") target = resolveDefaultExport(mod);
if (!target) {
if (exp === "default") unresolved(`${fileKey}#default`);
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;
}

/**
* 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 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): { pkg: Record<string, unknown> } | { error: true } | 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 ? { pkg: j as Record<string, unknown> } : { error: true };
} catch {
return { error: true };
}
}

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 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]
: raw && typeof raw === "object" ? Object.values(raw as Record<string, unknown>).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;
}
28 changes: 26 additions & 2 deletions src/entrypoints/pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } 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 {
Expand Down Expand Up @@ -66,6 +67,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, bump)) {
(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 => {
Expand All @@ -92,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);
}
Expand Down
9 changes: 7 additions & 2 deletions src/entrypoints/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> = new Set(["declared", "certain", "heuristic"]);
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion src/schema/emit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading