diff --git a/src/entrypoints/matching.ts b/src/entrypoints/matching.ts index d7da803..962f877 100644 --- a/src/entrypoints/matching.ts +++ b/src/entrypoints/matching.ts @@ -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[] = []; @@ -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); diff --git a/src/entrypoints/pipeline.ts b/src/entrypoints/pipeline.ts index ce25f68..9c4f76f 100644 --- a/src/entrypoints/pipeline.ts +++ b/src/entrypoints/pipeline.ts @@ -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; + } + } } } diff --git a/src/entrypoints/rules.ts b/src/entrypoints/rules.ts index 1c8b673..ec2bce8 100644 --- a/src/entrypoints/rules.ts +++ b/src/entrypoints/rules.ts @@ -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 { @@ -88,7 +90,7 @@ const CONFIDENCE: ReadonlySet = 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; @@ -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 ?? {}; @@ -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)); diff --git a/src/entrypoints/rules.yml b/src/entrypoints/rules.yml index c80cf8c..47e4eef 100644 --- a/src/entrypoints/rules.yml +++ b/src/entrypoints/rules.yml @@ -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 @@ -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: diff --git a/test/entrypoints-gate.test.ts b/test/entrypoints-gate.test.ts index 2391bee..1aa28cb 100644 --- a/test/entrypoints-gate.test.ts +++ b/test/entrypoints-gate.test.ts @@ -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", () => { @@ -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"]); }); diff --git a/test/entrypoints-nonweb.test.ts b/test/entrypoints-nonweb.test.ts new file mode 100644 index 0000000..b3cad84 --- /dev/null +++ b/test/entrypoints-nonweb.test.ts @@ -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 { + 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> = {}; 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"]); + }); +}); diff --git a/test/entrypoints-rules.test.ts b/test/entrypoints-rules.test.ts index 2485e83..14c2a7c 100644 --- a/test/entrypoints-rules.test.ts +++ b/test/entrypoints-rules.test.ts @@ -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");