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
18 changes: 14 additions & 4 deletions src/entrypoints/matching.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,10 +135,18 @@ const INLINE = /^(async\s*)?(\(|function\b|[A-Za-z_$][\w$]*\s*=>)/;
* receiver.method matches a `heuristics.calls` rule attaches the record to the HANDLER argument's
* callable, not to the call site itself — the call site has no `entrypoints` of its own.
*/
/**
* Two tiers, like decorators (#167): the FRAMEWORK tier passes `resolve` and matches the
* import-table-resolved callee (`app.on` → `electron.app.on`), so `app.on` in an Express app cannot
* register as Electron; the HEURISTIC tier passes none and matches the written spelling. A callee
* the resolver cannot name is skipped by the framework tier — never matched on its written form.
*/
export function entrypointsFromCalls(
mod: TSModule,
rules: readonly CallRule[],
unresolved: (key: string) => void,
framework = "heuristic",
resolve?: (written: string) => string | undefined,
): Array<{ target: TSCallable; ep: TSEntrypoint }> {
const out: Array<{ target: TSCallable; ep: TSEntrypoint }> = [];
const callables: TSCallable[] = [];
Expand All @@ -156,13 +164,15 @@ export function entrypointsFromCalls(
});
for (const { owner, key, site } of sites) {
const written = site.receiver_expr ? `${site.receiver_expr}.${site.method_name}` : site.method_name;
const candidate = resolve ? resolve(written) : written;
if (candidate === undefined) continue; // framework tier: an unresolvable callee is not a match
for (const rule of rules) {
if (!matchPattern(rule.match, written)) continue;
if (!matchPattern(rule.match, candidate)) continue;
const target = resolveHandler(site, rule, callables);
if (!target) { unresolved(written); continue; }
if (!target) { unresolved(candidate); continue; }
const ep: TSEntrypoint = {
framework: "heuristic", confidence: rule.confidence, rule: rule.id, ruleset: rule.origin, evidence: written,
http_methods: methodsOf(site.arguments, {}, rule.methods, written),
framework, confidence: rule.confidence, rule: rule.id, ruleset: rule.origin, evidence: candidate,
http_methods: methodsOf(site.arguments, {}, rule.methods, candidate),
via: `${owner}${key}`,
};
const route = routeOf(site.arguments, rule.route);
Expand Down
12 changes: 11 additions & 1 deletion src/entrypoints/pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,21 @@ export function detectEntrypoints(app: AnalysisInternal, opts: AnalysisOptions,
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 CALL rules (#167): gated on the framework and matched on the resolved
// callee, so the claim is framework-claimed for never-doubles. Runs before the heuristic
// calls stage below.
const callRules = rules.frameworks[name]!.calls;
if (callRules.length) {
const table = importTable(mod.imports ?? []);
for (const { target, ep } of entrypointsFromCalls(mod, callRules, bump, name, (w) => resolveWritten(table, w))) {
(target.entrypoints ??= []).push(ep);
target.is_entrypoint = true;
}
}
}
}

Expand Down
9 changes: 7 additions & 2 deletions src/entrypoints/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ export interface Framework {
decorators: DecoratorRule[];
bases: BaseRule[];
files: FileRule[];
/** Framework-tier call rules (#167): gated on `detect:`, matched on the import-table-RESOLVED callee, default `certain`. */
calls: CallRule[];
}

export interface RuleSet {
Expand All @@ -88,7 +90,7 @@ const CONFIDENCE: ReadonlySet<string> = new Set(["declared", "certain", "heurist
// loudly instead of loading clean and doing nothing.
const TOP_LEVEL = new Set(["version", "frameworks", "heuristics", "manifest", "disable"]);
const HEURISTIC_KEYS = new Set(["decorators", "calls"]);
const FRAMEWORK_KEYS = new Set(["detect", "decorators", "bases", "files"]);
const FRAMEWORK_KEYS = new Set(["detect", "decorators", "bases", "files", "calls"]);

type Raw = Record<string, unknown>;

Expand Down Expand Up @@ -126,11 +128,13 @@ function merge(out: RuleSet, data: Raw, origin: string): void {
const bad = Object.keys(body).filter((k) => !FRAMEWORK_KEYS.has(k));
if (bad.length) throw new RulesError(`${origin}: framework \`${name}\`: unknown key(s): ${bad.join(", ")}`);
if (body.detect !== undefined && !Array.isArray(body.detect)) throw new RulesError(`${origin}: framework \`${name}\`: \`detect\` must be a list`);
const fw = (out.frameworks[name] ??= { name, detect: [], decorators: [], bases: [], files: [] });
const fw = (out.frameworks[name] ??= { name, detect: [], decorators: [], bases: [], files: [], calls: [] });
fw.detect = [...new Set([...fw.detect, ...list(body.detect).map(String)])].sort();
for (const raw of list(body.decorators)) fw.decorators.push(decoratorRule(raw, origin));
for (const raw of list(body.bases)) fw.bases.push(baseRule(raw, origin));
for (const raw of list(body.files)) fw.files.push(fileRule(raw, origin));
// NOT forced heuristic: a framework call rule is gated and resolved, so it earns its confidence.
for (const raw of list(body.calls)) fw.calls.push(callRule(raw, origin));
}

const heuristics = data.heuristics ?? {};
Expand All @@ -146,6 +150,7 @@ function merge(out: RuleSet, data: Raw, origin: string): void {
fw.decorators = fw.decorators.filter((r) => !disabled.has(r.id));
fw.bases = fw.bases.filter((r) => !disabled.has(r.id));
fw.files = fw.files.filter((r) => !disabled.has(r.id));
fw.calls = fw.calls.filter((r) => !disabled.has(r.id));
}
out.heuristics.decorators = out.heuristics.decorators.filter((r) => !disabled.has(r.id));
out.heuristics.calls = out.heuristics.calls.filter((r) => !disabled.has(r.id));
Expand Down
39 changes: 39 additions & 0 deletions src/entrypoints/rules.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,39 @@ frameworks:
exports: [GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS]
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 —
# the nearest field; python's `route` is HTTP-only.
electron:
detect: [electron]
calls:
- id: electron.app-on
match: "electron.app.{on,once}"
route: {from: positional, index: 0}
handler: {from: positional, index: -1}
- id: electron.ipc
match: "electron.ipcMain.{on,once,handle,handleOnce}"
route: {from: positional, index: 0}
handler: {from: positional, index: -1}

commander:
detect: [commander]
calls:
# `program.command("x").option(...).action(run)` — the chain between `program` and `.action`
# is one to three dotless segments (a call with parentheses has no dot).
- id: commander.action
match: "commander.program.{*,*.*,*.*.*}.action"
handler: {from: positional, index: -1}

worker_threads:
detect: ["node:worker_threads", worker_threads]
calls:
- id: worker_threads.parent-port
match: "{node:worker_threads,worker_threads}.parentPort.{on,once}"
route: {from: positional, index: 0}
handler: {from: positional, index: -1}

# Framework-independent tier. Matched on the WRITTEN spelling, never a resolved name, so a shape
# that reads as an HTTP entrypoint is flagged even when no `frameworks:` block knows the library
# (or it is not installed). Confidence `heuristic` is forced by the loader. Runs LAST; a node a
Expand All @@ -67,6 +100,12 @@ heuristics:
route: {from: positional, index: 0}
methods: {from: match_suffix}
handler: {from: positional, index: -1}
# `process` is a Node global — nothing to gate on, and the spelling is distinctive enough to be
# a heuristic. Signal and lifecycle handlers are invoked from outside the program.
- id: heuristic.process-on
match: "process.{on,once}"
route: {from: positional, index: 0}
handler: {from: positional, index: -1}

# Manifest-declared entrypoints: what runs when the package is executed. Confidence `declared`.
manifest:
Expand Down
6 changes: 3 additions & 3 deletions test/entrypoints-gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ const rootOf = (r: { application: unknown }) => (r.application as { application:

const nestRules: RuleSet = {
...EMPTY_RULES,
frameworks: { nestjs: { name: "nestjs", detect: ["@nestjs/common"], decorators: [], bases: [], files: [] },
celery: { name: "celery", detect: ["celery"], decorators: [], bases: [], files: [] } },
frameworks: { nestjs: { name: "nestjs", detect: ["@nestjs/common"], decorators: [], bases: [], files: [], calls: [] },
celery: { name: "celery", detect: ["celery"], decorators: [], bases: [], files: [], calls: [] } },
};

describe("stage-0 framework gate", () => {
Expand All @@ -53,7 +53,7 @@ describe("stage-0 framework gate", () => {
});

test("comparison is case-insensitive on both sides", () => {
const rules: RuleSet = { ...EMPTY_RULES, frameworks: { flask: { name: "flask", detect: ["Flask"], decorators: [], bases: [], files: [] } } };
const rules: RuleSet = { ...EMPTY_RULES, frameworks: { flask: { name: "flask", detect: ["Flask"], decorators: [], bases: [], files: [], calls: [] } } };
const app = { symbol_table: { "a.ts": { imports: [{ module: "flask", name: "Flask", is_type_only: false, import_kind: "named" }] } }, dependencies: [] } as never;
expect([...detectedFrameworks(app, rules)]).toEqual(["flask"]);
});
Expand Down
83 changes: 83 additions & 0 deletions test/entrypoints-nonweb.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/**
* Framework-tier call rules and the non-web ruleset (#167). The property that matters most is the
* NEGATIVE one: `app.on(...)` exists on every EventEmitter, so with `electron` absent from the
* manifest the identical source must register nothing from these rules — the gate plus the
* resolved-callee match are what keep a false positive out.
*/
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 } from "../src/schema";

function fixture(files: Record<string, string>): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cants-nonweb-"));
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) => ({ input, appName: "nw", 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;
const byName = (root: TSApplication) => { const o: Record<string, Array<{ rule: string; confidence: string; framework: string; route?: string; evidence?: string }>> = {}; for (const m of Object.values(root.symbol_table)) forEachCallable(m, (c) => { o[c.name] = (c.entrypoints ?? []) as never; }); return o; };

const MAIN = [
'import { app, ipcMain as ipc } from "electron";',
"function onReady(): void {}",
"app.on('ready', onReady);",
"ipc.handle('ping', (_e: unknown) => 1);",
"app.on('error', onReady);",
].join("\n");

describe("framework-tier call rules (#167)", () => {
test("electron: gated on the dependency, matched on the RESOLVED callee (alias included), certain", async () => {
const dir = fixture({ "package.json": JSON.stringify({ name: "x", dependencies: { electron: "^30" } }), "src/main.ts": MAIN });
const root = rootOf(await analyze(opts(dir)));
expect(root.entrypoint_report.frameworks_detected).toEqual(["electron"]);
const n = byName(root);
// two app.on registrations of the same handler → two records, both certain, evidence resolved
expect(n.onReady).toEqual([
expect.objectContaining({ framework: "electron", rule: "electron.app-on", confidence: "certain", route: "ready", evidence: "electron.app.on" }),
expect.objectContaining({ rule: "electron.app-on", route: "error" }),
]);
// `ipc` is an ALIAS of ipcMain: the import table maps it back before matching
expect(n["(anonymous)"]?.[0]).toMatchObject({ rule: "electron.ipc", confidence: "certain", route: "ping", evidence: "electron.ipcMain.handle" });
});

test("the gate: the identical source WITHOUT electron in the manifest registers nothing from these rules", async () => {
const dir = fixture({ "package.json": JSON.stringify({ name: "x", dependencies: { express: "^4" } }), "src/main.ts": MAIN.replace('"electron"', '"./events"'), "src/events.ts": "export const app = { on(_e: string, _h: unknown) {} }; export const ipcMain = { handle(_c: string, _h: unknown) {} };" });
const root = rootOf(await analyze(opts(dir)));
expect(root.entrypoint_report.frameworks_detected).toEqual([]);
const n = byName(root);
expect(n.onReady).toEqual([]); // no framework claim — and no heuristic either: `app.on` is not an HTTP verb
expect(n["(anonymous)"] ?? []).toEqual([]);
});

test("commander: a chained receiver resolves through its head", async () => {
const dir = fixture({ "package.json": JSON.stringify({ name: "x", dependencies: { commander: "^12" } }),
"src/cli.ts": 'import { program } from "commander";\nfunction run(): void {}\nprogram.command("start").option("-v").action(run);\nprogram.parse();' });
const n = byName(rootOf(await analyze(opts(dir))));
expect(n.run?.[0]).toMatchObject({ framework: "commander", rule: "commander.action", confidence: "certain", evidence: 'commander.program.command("start").option("-v").action' });
});

test("worker_threads via node: specifier, and process.on as a dependency-free heuristic", async () => {
const dir = fixture({ "package.json": JSON.stringify({ name: "x" }),
"src/w.ts": 'import { parentPort } from "node:worker_threads";\nfunction onMsg(): void {}\nfunction onSig(): void {}\nparentPort.on("message", onMsg);\nprocess.on("SIGINT", onSig);' });
const root = rootOf(await analyze(opts(dir)));
expect(root.entrypoint_report.frameworks_detected).toEqual(["worker_threads"]);
const n = byName(root);
expect(n.onMsg?.[0]).toMatchObject({ framework: "worker_threads", rule: "worker_threads.parent-port", confidence: "certain", route: "message" });
expect(n.onSig?.[0]).toMatchObject({ framework: "heuristic", rule: "heuristic.process-on", confidence: "heuristic", route: "SIGINT" });
});

test("never doubles: a framework call claim blocks a heuristic call record on the same handler", async () => {
// `app.get` is the shipped heuristic Express shape; `app.on` (electron) claims `h` first.
const dir = fixture({ "package.json": JSON.stringify({ name: "x", dependencies: { electron: "^30" } }),
"src/m.ts": 'import { app } from "electron";\nfunction h(): void {}\napp.on("ready", h);\n(app as unknown as { get(p: string, f: unknown): void }).get("/x", h);' });
const n = byName(rootOf(await analyze(opts(dir))));
expect(n.h?.map((e) => e.rule)).toEqual(["electron.app-on"]);
});
});
6 changes: 4 additions & 2 deletions test/entrypoints-rules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@ 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", "nestjs", "nextjs", "sveltekit"]);
expect(Object.keys(r.frameworks).sort()).toEqual(["angular", "commander", "electron", "nestjs", "nextjs", "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"]);
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)
expect(r.frameworks.electron?.calls.map((c) => [c.id, c.confidence])).toEqual([["electron.app-on", "certain"], ["electron.ipc", "certain"]]);
expect(r.manifest.map((m) => m.id)).toEqual(["manifest.bin", "manifest.main"]);
// heuristic confidence is FORCED, whatever the file says
for (const d of [...r.heuristics.decorators, ...r.heuristics.calls]) expect(d.confidence).toBe("heuristic");
Expand Down
Loading