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
56 changes: 54 additions & 2 deletions src/entrypoints/matching.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}

Expand Down Expand Up @@ -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<string, TSType>,
): { classEps: TSEntrypoint[]; methodEps: Map<string, TSEntrypoint[]> } {
const classEps: TSEntrypoint[] = [];
const methodEps = new Map<string, TSEntrypoint[]>();
const directBases = (t: TSType): string[] => (t.base_classes ?? []).map((b) => resolve(b, t));
const allBases = (transitive: boolean): string[] => {
const seen = new Set<string>();
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 };
}
36 changes: 34 additions & 2 deletions src/entrypoints/pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<string, TSType>();
const resolveByType = new Map<string, (written: string) => 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) {
Expand Down
57 changes: 57 additions & 0 deletions test/entrypoints-bases.test.ts
Original file line number Diff line number Diff line change
@@ -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, string>): 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<string, unknown> } | undefined; const methods: Record<string, unknown[]> = {};
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<string, unknown[]> = {};
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([]); });
});
});
Loading