diff --git a/src/entrypoints/matching.ts b/src/entrypoints/matching.ts index 3103d7e..3967f80 100644 --- a/src/entrypoints/matching.ts +++ b/src/entrypoints/matching.ts @@ -4,9 +4,9 @@ * 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 } from "../schema"; +import { forEachCallable, type TSCallable, type TSCallsite, type TSDecorator, type TSEntrypoint, type TSModule, type TSType } from "../schema"; import { callBodyKeys } from "../schema/l1Body"; -import type { ArgSpec, CallRule, DecoratorRule } from "./rules"; +import type { ArgSpec, BaseRule, CallRule, DecoratorRule } from "./rules"; export class PatternError extends Error {} @@ -185,3 +185,55 @@ function resolveHandler(site: TSCallsite, rule: CallRule, callables: readonly TS } return undefined; } + +/** + * Base-class tier: a rule matches when ANY base of the class — resolved through the import + * table, else as written — matches `rule.match`. `transitive: true` also walks the bases of + * every in-project ancestor reachable through `extends_ids` (an external ancestor has no node, + * so the walk stops there); a `seen` set guards cycles. `dispatch:` only ever fires for a name + * the class itself DEFINES as a method — `cls.callables` is keyed by plain method name + * (`memberKey`, confirmed against a fixture: `get` → key `"get"`), so `Object.keys` is the + * intersection, no `Object.values(...).map(c => c.name)` fallback needed. + * + * `resolve` takes the OWNER of the base spelling, not just `cls`: a transitive ancestor's + * `base_classes` is WRITTEN in *that ancestor's own file*, so it can only be resolved through + * that file's own import table — `cls`'s table has no binding for a name it never imports. + */ +export function entrypointsFromBases( + cls: TSType, + framework: string, + rules: readonly BaseRule[], + resolve: (written: string, owner: TSType) => string, + typeById: Map, +): { classEps: TSEntrypoint[]; methodEps: Map } { + const classEps: TSEntrypoint[] = []; + const methodEps = new Map(); + const directBases = (t: TSType): string[] => (t.base_classes ?? []).map((b) => resolve(b, t)); + const allBases = (transitive: boolean): string[] => { + const seen = new Set(); + const out: string[] = []; + const stack: TSType[] = [cls]; + while (stack.length) { + const t = stack.pop()!; + if (seen.has(t.id)) continue; + seen.add(t.id); + out.push(...directBases(t)); + if (transitive) for (const id of t.extends_ids ?? []) { const p = typeById.get(id); if (p) stack.push(p); } + } + return out; + }; + const defined = new Set(Object.keys(cls.callables ?? {})); + for (const rule of rules) { + if (!allBases(rule.transitive).some((b) => matchPattern(rule.match, b))) continue; + classEps.push({ framework, confidence: rule.confidence, rule: rule.id, ruleset: rule.origin, evidence: cls.signature, http_methods: [] }); + for (const name of rule.dispatch) { + if (!defined.has(name)) continue; + const ep: TSEntrypoint = { + framework, confidence: rule.confidence, rule: `${rule.id}.dispatch`, ruleset: rule.origin, + evidence: cls.signature, http_methods: HTTP_VERBS.has(name.toLowerCase()) ? [name.toUpperCase()] : [], via: cls.id, + }; + (methodEps.get(name) ?? methodEps.set(name, []).get(name)!).push(ep); + } + } + return { classEps, methodEps }; +} diff --git a/src/entrypoints/pipeline.ts b/src/entrypoints/pipeline.ts index 63eb805..6e7d8d6 100644 --- a/src/entrypoints/pipeline.ts +++ b/src/entrypoints/pipeline.ts @@ -7,8 +7,9 @@ * is the one hard-error step, and it happens in analyze(), before any of this.) */ 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 { entrypointsFromCalls, entrypointsFromDecorators } from "./matching"; +import { entrypointsFromBases, entrypointsFromCalls, entrypointsFromDecorators } from "./matching"; import { EMPTY_RULES, type RuleSet } from "./rules"; export function detectEntrypoints(app: AnalysisInternal, rules: RuleSet = EMPTY_RULES): TSEntrypointReport { @@ -33,9 +34,40 @@ export function detectEntrypoints(app: AnalysisInternal, rules: RuleSet = EMPTY_ }); } + const frameworks = report.frameworks_detected; + + // Base-class tier (#159): class-only, one detected framework at a time, BEFORE the decorator + // tiers — so a class/method a base rule claims counts as framework-claimed for never-doubles + // (the heuristic decorator tier and the calls tier both check `entrypoints.length === 0`). + // `typeById` and a resolver PER TYPE (its own module's import table — a transitive ancestor's + // `base_classes` is written in that ancestor's own file, so `cls`'s import table cannot name + // it) are both built once per run by this single walk. + const typeById = new Map(); + const resolveByType = new Map string>(); + for (const mod of Object.values(app.symbol_table)) { + const table = importTable(mod.imports ?? []); + const resolveInModule = (written: string): string => resolveWritten(table, written) ?? written; + forEachType(mod, (t) => { typeById.set(t.id, t); resolveByType.set(t.id, resolveInModule); }); + } + const resolve = (written: string, owner: TSType): string => (resolveByType.get(owner.id) ?? ((w: string) => w))(written); + for (const mod of Object.values(app.symbol_table)) { + forEachType(mod, (t) => { + if (t.kind !== "class") return; + for (const name of frameworks) { + const baseRules = rules.frameworks[name]!.bases; + if (!baseRules.length) continue; + const { classEps, methodEps } = entrypointsFromBases(t, name, baseRules, resolve, typeById); + (t.entrypoints ??= []).push(...classEps); + for (const [methodName, eps] of methodEps) { + const m = t.callables?.[methodName]; + if (m) (m.entrypoints ??= []).push(...eps); + } + } + }); + } + // 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 frameworks = report.frameworks_detected; const visit = (node: TSCallable | TSType): void => { node.entrypoints = node.entrypoints ?? []; for (const name of frameworks) { diff --git a/test/entrypoints-bases.test.ts b/test/entrypoints-bases.test.ts new file mode 100644 index 0000000..6158253 --- /dev/null +++ b/test/entrypoints-bases.test.ts @@ -0,0 +1,57 @@ +// test/entrypoints-bases.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"; + +function fixture(files: Record): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cants-epb-")); + 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 RULES = "version: 1\nframeworks:\n viewfw:\n detect: [viewfw]\n bases:\n - id: viewfw.view\n match: viewfw.View\n transitive: true\n dispatch: [get, post, list]\n"; +const opts = (input: string, rules: string) => ({ input, appName: "b", analysisLevel: 1, eager: true, noBuild: true, emit: "json", graphs: ["cfg","dfg","pdg","sdg"], + graphFieldDepth: 3, jobs: 1, skipTests: true, phantoms: true, entrypointRules: [rules] }) as unknown as AnalysisOptions; +const rootOf = (r: { application: unknown }) => (r.application as { application: TSApplication }).application; + +describe("base-class matcher", () => { + test("direct base via import table; dispatch only for defined methods; via = class id", async () => { + const dir = fixture({ "src/v.ts": 'import { View } from "viewfw";\nexport class Users extends View { get(): void {} other(): void {} }' }); + const rules = path.join(dir, "r.yml"); fs.writeFileSync(rules, RULES); + const root = rootOf(await analyze(opts(dir, rules))); + let cls: { id: string; entrypoints?: unknown[]; callables?: Record } | undefined; const methods: Record = {}; + for (const m of Object.values(root.symbol_table)) { forEachType(m, (t) => { if (t.name === "Users") cls = t; }); forEachCallable(m, (c) => { methods[c.name] = c.entrypoints ?? []; }); } + // Task 6 decision #3: cls.callables is keyed by plain method name ("get"), confirmed here — see task-6-report.md. + expect(Object.keys(cls?.callables ?? {})).toEqual(["get", "other", "constructor"]); + expect(cls?.entrypoints).toEqual([{ framework: "viewfw", confidence: "certain", rule: "viewfw.view", ruleset: `user:${rules}`, evidence: "src/v.Users", http_methods: [] }]); + expect(methods.get?.[0]).toMatchObject({ rule: "viewfw.view.dispatch", via: cls?.id, http_methods: ["GET"] }); + expect(methods.post).toBeUndefined(); // not defined → no phantom record + expect(methods.other).toEqual([]); // defined but not dispatched + }); + + test("transitive: an in-project ancestor's base matches", async () => { + const dir = fixture({ + "src/base.ts": 'import { View } from "viewfw";\nexport class BaseView extends View {}', + "src/v.ts": 'import { BaseView } from "./base";\nexport class Users extends BaseView { list(): void {} }', + }); + const rules = path.join(dir, "r.yml"); fs.writeFileSync(rules, RULES); + const root = rootOf(await analyze(opts(dir, rules))); + const eps: Record = {}; + for (const m of Object.values(root.symbol_table)) { forEachType(m, (t) => { eps[t.name] = t.entrypoints ?? []; }); forEachCallable(m, (c) => { eps[`m:${c.name}`] = c.entrypoints ?? []; }); } + expect(eps.Users?.length).toBe(1); + expect(eps.BaseView?.length).toBe(1); // the ancestor itself directly extends View + expect(eps["m:list"]?.[0]).toMatchObject({ rule: "viewfw.view.dispatch", http_methods: [] }); // `list` is not an HTTP verb + }); + + test("the gate still applies: no viewfw import or dependency → nothing", async () => { + const dir = fixture({ "src/v.ts": "class View {}\nexport class Users extends View { get(): void {} }" }); + const rules = path.join(dir, "r.yml"); fs.writeFileSync(rules, RULES); + const root = rootOf(await analyze(opts(dir, rules))); + for (const m of Object.values(root.symbol_table)) forEachType(m, (t) => { expect(t.entrypoints ?? []).toEqual([]); }); + }); +});