diff --git a/src/entrypoints/detect.ts b/src/entrypoints/detect.ts new file mode 100644 index 0000000..8a98599 --- /dev/null +++ b/src/entrypoints/detect.ts @@ -0,0 +1,64 @@ +/** + * Stage 0: which frameworks does this project actually use? (#72; python #27 parity) + * + * Gates every later stage, so a project without NestJS never pays for NestJS rules AND cannot + * false-positive on a locally defined `Controller`. A package counts as present if first-party + * source imports it OR the dependency manifest names it — either is sufficient, since an import + * may be dynamic. Both sides are lowercased: npm is case-insensitive in practice and a + * `detect: [Flask]` user rule must not silently miss a `flask` import. + */ +import type { AnalysisInternal, TSModule } from "../schema"; +import type { RuleSet } from "./rules"; + +export function detectedFrameworks(app: AnalysisInternal, rules: RuleSet): Set { + const present = new Set(); + for (const mod of Object.values(app.symbol_table)) { + for (const imp of mod.imports ?? []) present.add(packageOf(imp.module)); + } + for (const dep of app.dependencies ?? []) present.add(dep.name.toLowerCase()); + const out = new Set(); + for (const [name, fw] of Object.entries(rules.frameworks)) { + const probes = fw.detect.length ? fw.detect : [name]; + if (probes.some((p) => present.has(p.toLowerCase()))) out.add(name); + } + return out; +} + +/** The npm package a specifier belongs to: `@scope/name/sub` → `@scope/name`; `lodash/fp` → `lodash`. */ +export function packageOf(specifier: string): string { + const s = specifier.toLowerCase(); + if (s.startsWith(".") || s.startsWith("/")) return s; // relative: never a framework + const parts = s.split("/"); + return (s.startsWith("@") ? parts.slice(0, 2) : parts.slice(0, 1)).join("/"); +} + +/** + * ECMAScript + the DOM bases a first-party class commonly extends. A spelling headed by one of + * these is nameable without any import, so it is not "unresolved". Fixed list, not `globalThis` + * at analysis time: the counter must not depend on the analyzer's own runtime. + */ +export const JS_GLOBALS: ReadonlySet = new Set([ + "Object", "Function", "Array", "Boolean", "Number", "String", "Symbol", "BigInt", "Date", "RegExp", + "Error", "AggregateError", "EvalError", "RangeError", "ReferenceError", "SyntaxError", "TypeError", "URIError", + "Promise", "Map", "Set", "WeakMap", "WeakSet", "WeakRef", "Proxy", "Reflect", "JSON", "Math", + "ArrayBuffer", "SharedArrayBuffer", "DataView", "Int8Array", "Uint8Array", "Uint8ClampedArray", "Int16Array", + "Uint16Array", "Int32Array", "Uint32Array", "Float32Array", "Float64Array", "BigInt64Array", "BigUint64Array", + "Event", "EventTarget", "HTMLElement", "Element", "Node", +]); + +/** Names that can head a nameable spelling in this module: its declared types, module-level fields (incl. `declare const`), and every local import binding. */ +export function knownHeads(mod: TSModule): Set { + const heads = new Set(Object.values(mod.types ?? {}).map((t) => t.name)); + for (const f of Object.values(mod.fields ?? {})) heads.add(f.name); + for (const imp of mod.imports ?? []) { + if (imp.import_kind === "side_effect") continue; + heads.add(imp.alias ?? imp.name); // the LOCAL binding; for a named import without alias that is the name + } + return heads; +} + +/** Whether a written base/decorator spelling maps to nothing this module can name. Generics/subscripts stripped first. */ +export function unnameable(written: string, known: Set): boolean { + const head = written.split("<", 1)[0]!.split("[", 1)[0]!.split(".", 1)[0]!.trim(); + return head.length > 0 && !JS_GLOBALS.has(head) && !known.has(head); +} diff --git a/src/entrypoints/index.ts b/src/entrypoints/index.ts new file mode 100644 index 0000000..b108698 --- /dev/null +++ b/src/entrypoints/index.ts @@ -0,0 +1,2 @@ +export { detectEntrypoints } from "./pipeline"; +export { EMPTY_RULES, RulesError, type RuleSet } from "./rules"; diff --git a/src/entrypoints/pipeline.ts b/src/entrypoints/pipeline.ts new file mode 100644 index 0000000..03bda1e --- /dev/null +++ b/src/entrypoints/pipeline.ts @@ -0,0 +1,43 @@ +/** + * Entrypoint pass (#72; python #27 parity) — a level-free post-pass over the built L1 tree. + * + * Runs after heritage (unit 4 matches on resolved extends_ids). Per-run, like heritage: the cached + * tree is stamped fresh each run. Best-effort by contract — a failure here loses flags, never the + * 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 { forEachCallable, forEachType, type AnalysisInternal, type TSEntrypointReport } from "../schema"; +import { detectedFrameworks, knownHeads, unnameable } from "./detect"; +import { EMPTY_RULES, type RuleSet } from "./rules"; + +export function detectEntrypoints(app: AnalysisInternal, rules: RuleSet = EMPTY_RULES): TSEntrypointReport { + const report: TSEntrypointReport = { frameworks_detected: [], rulesets: [...rules.rulesets], unresolved: {}, errors: [] }; + try { + // Reset: the contract says every callable and every class carries the fields, empty by default. + for (const mod of Object.values(app.symbol_table)) { + forEachCallable(mod, (c) => { c.entrypoints = []; c.is_entrypoint = false; }); + forEachType(mod, (t) => { if (t.kind === "class") { t.entrypoints = []; t.is_entrypoint = false; } }); + } + report.frameworks_detected = [...detectedFrameworks(app, rules)].sort(); + + // The counter that makes silence visible (python #177): every decorator and base spelling + // that neither the import table nor the module itself can name. + for (const mod of Object.values(app.symbol_table)) { + const known = knownHeads(mod); + const bump = (k: string): void => { report.unresolved[k] = (report.unresolved[k] ?? 0) + 1; }; + forEachCallable(mod, (c) => { for (const d of c.decorators ?? []) if (!d.qualified_name && unnameable(d.name, known)) bump(d.name); }); + forEachType(mod, (t) => { + for (const d of t.decorators ?? []) if (!d.qualified_name && unnameable(d.name, known)) bump(d.name); + if (t.kind === "class") for (const b of t.base_classes ?? []) if (!isSignature(b) && unnameable(b, known)) bump(b); + }); + } + } catch (e) { + report.errors.push((e as Error).message); + } + return report; +} + +/** `base_classes` holds an in-project SIGNATURE when the checker resolved the base (`src/models.Entity`), else the written spelling. */ +function isSignature(base: string): boolean { + return base.includes("/") || (/^[^.<]+\.[^.<]/.test(base) && !/^[A-Z]/.test(base)); +} diff --git a/src/entrypoints/rules.ts b/src/entrypoints/rules.ts new file mode 100644 index 0000000..c596ed2 --- /dev/null +++ b/src/entrypoints/rules.ts @@ -0,0 +1,77 @@ +/** + * Entrypoint rules (#72; python #27 parity) — the declarative side of detection. + * + * Loading rules is CONFIGURATION, not detection: a malformed file is a hard error before analysis + * starts (RulesError, thrown from analyze()). Detection is best-effort and lives in pipeline.ts. + * + * Task 1 of the plan lands the TYPES only; the loader arrives with Task 3. + */ +export type Confidence = "declared" | "certain" | "heuristic"; + +/** Where a route or method list comes from, per rule. Mirrors python's `route:` / `methods:`. */ +export interface ArgSpec { + from: "positional" | "keyword" | "match_suffix" | "export_name"; + index?: number; // positional; `-1` = last argument + name?: string; // keyword + default?: string[]; // methods only +} + +export interface DecoratorRule { + id: string; + match: string; + confidence: Confidence; + route?: ArgSpec; + methods?: ArgSpec; + origin: string; // "shipped" | "user:" +} + +export interface CallRule extends DecoratorRule { + /** Which argument is the handler the request reaches. Default: `{from: "positional", index: -1}`. */ + handler: ArgSpec; +} + +export interface BaseRule { + id: string; + match: string; + confidence: Confidence; + transitive: boolean; + dispatch: string[]; + origin: string; +} + +export interface FileRule { + id: string; + match: string; // glob over the module file key, e.g. "app/**/route.{ts,js}" + exports: string[]; // exported callable names; "default" = the default export + confidence: Confidence; + methods?: ArgSpec; // {from: export_name} → the export name uppercased is the HTTP method + origin: string; +} + +export interface ManifestRule { + id: string; + source: "package.json"; + field: "main" | "bin"; + confidence: Confidence; + origin: string; +} + +export interface Framework { + name: string; + detect: string[]; + decorators: DecoratorRule[]; + bases: BaseRule[]; + files: FileRule[]; +} + +export interface RuleSet { + frameworks: Record; + /** Framework-independent, written-spelling tier. `confidence` is forced to "heuristic". */ + heuristics: { decorators: DecoratorRule[]; calls: CallRule[] }; + manifest: ManifestRule[]; + rulesets: string[]; +} + +export class RulesError extends Error {} + +export const EMPTY_RULES: RuleSet = { frameworks: {}, heuristics: { decorators: [], calls: [] }, manifest: [], rulesets: [] }; diff --git a/src/schema/emit.ts b/src/schema/emit.ts index 82ae389..7d34520 100644 --- a/src/schema/emit.ts +++ b/src/schema/emit.ts @@ -25,7 +25,7 @@ import type { ProgramGraphs } from "./graphs"; import { assignIds } from "./assignIds"; import { populateL1Body } from "./l1Body"; import { resolveHeritageIds } from "./heritage"; -import { detectEntrypoints } from "./entrypoints"; +import { detectEntrypoints } from "../entrypoints"; import { homeExternals, homeSynthesized } from "./homing"; import { backfillCallees, reidentifyCallGraph } from "./l2Callees"; import { applyDataflow } from "../dataflow/attach"; diff --git a/src/schema/entrypoints.ts b/src/schema/entrypoints.ts deleted file mode 100644 index fd0408f..0000000 --- a/src/schema/entrypoints.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Entrypoint pass (#72; python #27 parity) — a level-free post-pass over the built L1 tree. - * - * Unit 1 (#153): the CONTRACT only. Every callable and every class is stamped `entrypoints: []` / - * `is_entrypoint: false`, and the report is empty. Detection — the framework gate, the rules file, - * the matchers — is units 2-5. Landing the shape first means the fields exist at every -a before - * any detector does, and a consumer can already tell "no entrypoints" from "no pass ran". - * - * Per-run, like heritage: the cached tree is stamped fresh each run, so the cache stays free of - * per-run layers. Best-effort by contract: a failure here loses flags, never the analysis, so the - * error path records into the report rather than throwing. - */ -import { forEachCallable, forEachType, type AnalysisInternal, type TSEntrypointReport } from "./schema"; - -export function detectEntrypoints(app: AnalysisInternal): TSEntrypointReport { - const report: TSEntrypointReport = { frameworks_detected: [], rulesets: [], unresolved: {}, errors: [] }; - try { - for (const mod of Object.values(app.symbol_table)) { - forEachCallable(mod, (c) => { - c.entrypoints = []; - c.is_entrypoint = false; - }); - forEachType(mod, (t) => { - if (t.kind !== "class") return; // python stamps PyClass; nothing else can be an entrypoint - t.entrypoints = []; - t.is_entrypoint = false; - }); - } - } catch (e) { - report.errors.push((e as Error).message); - } - return report; -} diff --git a/test/entrypoints-gate.test.ts b/test/entrypoints-gate.test.ts new file mode 100644 index 0000000..2391bee --- /dev/null +++ b/test/entrypoints-gate.test.ts @@ -0,0 +1,88 @@ +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 { detectedFrameworks, knownHeads, unnameable } from "../src/entrypoints/detect"; +import { EMPTY_RULES, type RuleSet } from "../src/entrypoints/rules"; +import type { AnalysisOptions } from "../src/options"; +import type { TSApplication, TSModule } from "../src/schema"; + +function fixture(files: Record): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cants-epg-")); + fs.mkdirSync(path.join(dir, "src"), { recursive: true }); + 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); + } + if (!files["tsconfig.json"]) { + fs.writeFileSync(path.join(dir, "tsconfig.json"), + JSON.stringify({ compilerOptions: { target: "ES2020", experimentalDecorators: true }, include: ["src/**/*.ts"] })); + } + return dir; +} +const opts = (input: string) => + ({ input, appName: "g", analysisLevel: 1, eager: true, noBuild: true, emit: "json", + graphs: ["cfg", "dfg", "pdg", "sdg"], graphFieldDepth: 3, jobs: 1, skipTests: true, phantoms: true }) as unknown as AnalysisOptions; +const rootOf = (r: { application: unknown }) => (r.application as { application: TSApplication }).application; + +const nestRules: RuleSet = { + ...EMPTY_RULES, + frameworks: { nestjs: { name: "nestjs", detect: ["@nestjs/common"], decorators: [], bases: [], files: [] }, + celery: { name: "celery", detect: ["celery"], decorators: [], bases: [], files: [] } }, +}; + +describe("stage-0 framework gate", () => { + test("a framework is detected by a first-party import", async () => { + const dir = fixture({ "src/a.ts": 'import { Controller } from "@nestjs/common";\nexport class C {}' }); + const res = await analyze(opts(dir)); + // detectedFrameworks takes the INTERNAL app; the test reaches it through the finalized root's + // symbol_table, which is the same object graph. + const app = { symbol_table: rootOf(res).symbol_table, dependencies: rootOf(res).dependencies } as never; + expect([...detectedFrameworks(app, nestRules)]).toEqual(["nestjs"]); + }); + + test("a framework is detected by the dependency manifest alone (dynamic import case)", async () => { + const dir = fixture({ + "package.json": JSON.stringify({ name: "x", dependencies: { "@nestjs/common": "^10.0.0" } }), + "src/a.ts": "export const x = 1;", + }); + const res = await analyze(opts(dir)); + const app = { symbol_table: rootOf(res).symbol_table, dependencies: rootOf(res).dependencies } as never; + expect([...detectedFrameworks(app, nestRules)]).toEqual(["nestjs"]); + }); + + test("comparison is case-insensitive on both sides", () => { + const rules: RuleSet = { ...EMPTY_RULES, frameworks: { flask: { name: "flask", detect: ["Flask"], decorators: [], bases: [], files: [] } } }; + 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"]); + }); + + test("the report records frameworks_detected and an unresolved decorator/base counter", async () => { + const dir = fixture({ + "src/a.ts": [ + 'import { Controller } from "unknown-lib";', + "declare const Mystery: any;", + "@Controller('/u') @Mystery() export class A extends Unknowable {}", + "export class B extends Error {}", // builtin: nameable, not counted + "export class Local {}", + "export class C extends Local {}", // declared in module: not counted + ].join("\n"), + }); + const root = rootOf(await analyze(opts(dir))); + // `Mystery` is declared, so it is nameable; `Unknowable` is not. + expect(root.entrypoint_report.unresolved).toEqual({ Unknowable: 1 }); + }); + + test("unnameable: builtins, declared types and imported heads are nameable", () => { + const mod = { types: { Local: { name: "Local" } }, imports: [{ module: "x", name: "Foo", alias: "Bar", is_type_only: false, import_kind: "named" }] } as unknown as TSModule; + const known = knownHeads(mod); + expect(unnameable("Error", known)).toBe(false); + expect(unnameable("Local", known)).toBe(false); + expect(unnameable("Bar", known)).toBe(false); // alias is the local binding + expect(unnameable("Bar.Sub", known)).toBe(false); // head is imported + expect(unnameable("Foo", known)).toBe(true); // exported name, but the LOCAL binding is Bar + expect(unnameable("Generic", known)).toBe(true); + expect(unnameable("", known)).toBe(false); + }); +});