diff --git a/schema.neo4j.json b/schema.neo4j.json index 4add5c5..536844b 100644 --- a/schema.neo4j.json +++ b/schema.neo4j.json @@ -14,7 +14,9 @@ "max_level": "integer", "k_limit": "integer", "analyzer_name": "string", - "analyzer_version": "string" + "analyzer_version": "string", + "entrypoint_frameworks": "string[]", + "entrypoint_report_json": "string" } }, { @@ -97,7 +99,9 @@ "is_ambient": "boolean", "code": "string", "start_line": "integer", - "end_line": "integer" + "end_line": "integer", + "is_entrypoint": "boolean", + "entrypoint_frameworks": "string[]" } }, { @@ -194,7 +198,9 @@ "is_implicit": "boolean", "code": "string", "start_line": "integer", - "end_line": "integer" + "end_line": "integer", + "is_entrypoint": "boolean", + "entrypoint_frameworks": "string[]" } }, { diff --git a/src/build/neo4j/project.ts b/src/build/neo4j/project.ts index 6d7fc78..59f75c9 100644 --- a/src/build/neo4j/project.ts +++ b/src/build/neo4j/project.ts @@ -10,7 +10,7 @@ * for the incremental writer's per-module isolation); shared nodes (External) carry none. */ -import type { TSAnalysis, TSApplication, TSBodyNode, TSCallable, TSDecorator, TSField, TSModule, TSType } from "../../schema"; +import type { TSAnalysis, TSApplication, TSBodyNode, TSCallable, TSDecorator, TSEntrypoint, TSEntrypointReport, TSField, TSModule, TSType } from "../../schema"; import { purlNpm } from "../../schema/ids"; import { SCHEMA_VERSION } from "./schema"; import { type GraphRows, type NodeRef, type Props, RowBuilder, prune } from "./rows"; @@ -58,6 +58,10 @@ export function project(app: TSAnalysis, _appName?: string): GraphRows { // app-name param (project()'s _appName) and every other CanNode's bare `name`. analyzer_name: app.analyzer.name, analyzer_version: app.analyzer.version, + // Entrypoint report (#72; python #182 parity): the pass under-approximates by design, so a graph + // consumer must be able to tell "no entrypoints" from "the pass found nothing it could name". + entrypoint_frameworks: [...root.entrypoint_report.frameworks_detected], + entrypoint_report_json: reportJson(root.entrypoint_report), })); for (const mod of Object.values(root.symbol_table)) { @@ -289,12 +293,27 @@ function moduleProps(mod: TSModule, fileKey: string): Props { }); } +/** Sorted framework names of a node's entrypoints — the Neo4j-queryable summary of the list. */ +function frameworksOf(eps: TSEntrypoint[] | undefined): string[] { + return [...new Set((eps ?? []).map((e) => e.framework))].sort(); +} + +/** Key-sorted, matching python's `json.dumps(..., sort_keys=True)`, so the two projections diff. */ +function reportJson(r: TSEntrypointReport): string { + const unresolved: Record = {}; + for (const k of Object.keys(r.unresolved).sort()) unresolved[k] = r.unresolved[k] as number; + return JSON.stringify({ errors: r.errors, frameworks_detected: r.frameworks_detected, rulesets: r.rulesets, unresolved }); +} + function typeProps(t: TSType, fileKey: string, source: string): Props { return prune({ id: t.id, kind: t.kind, signature: t.signature, name: t.name, base_classes: strArr(t.base_classes), implements_types: strArr(t.implements_types), aliased_type: t.aliased_type ?? null, is_abstract: t.is_abstract ?? null, is_const: t.is_const ?? null, + // #72: class only — python stamps PyClass, and :TSClass is the only type label declaring these. + is_entrypoint: t.kind === "class" ? (t.is_entrypoint ?? false) : null, + entrypoint_frameworks: t.kind === "class" ? frameworksOf(t.entrypoints) : null, is_exported: t.is_exported, is_ambient: t.is_ambient, code: spanCode(source, t.span), ...span(t), }); @@ -306,6 +325,7 @@ function callableProps(c: TSCallable, fileKey: string, source: string): Props { return_type: c.return_type ?? null, cyclomatic_complexity: c.cyclomatic_complexity, accessibility: c.accessibility ?? null, accessor_kind: c.accessor_kind ?? null, is_static: c.is_static, is_abstract: c.is_abstract, + is_entrypoint: c.is_entrypoint ?? false, entrypoint_frameworks: frameworksOf(c.entrypoints), is_async: c.is_async, is_generator: c.is_generator, is_exported: c.is_exported, is_ambient: c.is_ambient, is_implicit: c.is_implicit, code: spanCode(source, c.span), ...span(c), _module: fileKey, diff --git a/src/build/neo4j/schema.ts b/src/build/neo4j/schema.ts index bb46939..002b725 100644 --- a/src/build/neo4j/schema.ts +++ b/src/build/neo4j/schema.ts @@ -62,6 +62,8 @@ export const NODE_LABELS: NodeLabel[] = [ // namespaced (not bare name/version) to avoid colliding with the app-name param / every // other CanNode's bare `name`. analyzer_name: "string", analyzer_version: "string", + // Entrypoint report (#72; python #182 parity) — sorted-key JSON, since Neo4j has no map type. + entrypoint_frameworks: "string[]", entrypoint_report_json: "string", }, }, // Repository-artifact layer (#101, python PR #160 parity): language-NEUTRAL labels — the @@ -115,6 +117,7 @@ export const NODE_LABELS: NodeLabel[] = [ properties: { ...COMMON, signature: "string", name: "string", base_classes: "string[]", implements_types: "string[]", is_abstract: "boolean", is_exported: "boolean", is_ambient: "boolean", code: "string", ...SPAN, + is_entrypoint: "boolean", entrypoint_frameworks: "string[]", // #72 (python PyClass parity) }, }, { @@ -150,6 +153,7 @@ export const NODE_LABELS: NodeLabel[] = [ accessibility: "string", accessor_kind: "string", is_static: "boolean", is_abstract: "boolean", is_async: "boolean", is_generator: "boolean", is_exported: "boolean", is_ambient: "boolean", is_implicit: "boolean", code: "string", ...SPAN, + is_entrypoint: "boolean", entrypoint_frameworks: "string[]", // #72 (python PyCallable parity) }, }, { label: "TSField", mergeLabel: CAN, key: "id", properties: { ...COMMON, name: "string", type: "string", ...SPAN } }, diff --git a/src/schema/emit.ts b/src/schema/emit.ts index 4434583..82ae389 100644 --- a/src/schema/emit.ts +++ b/src/schema/emit.ts @@ -25,6 +25,7 @@ import type { ProgramGraphs } from "./graphs"; import { assignIds } from "./assignIds"; import { populateL1Body } from "./l1Body"; import { resolveHeritageIds } from "./heritage"; +import { detectEntrypoints } from "./entrypoints"; import { homeExternals, homeSynthesized } from "./homing"; import { backfillCallees, reidentifyCallGraph } from "./l2Callees"; import { applyDataflow } from "../dataflow/attach"; @@ -96,6 +97,8 @@ export function finalizeAnalysis( const { appId, idBySig, callableBySig, collisions } = assignIds(app, appName); populateL1Body(app); resolveHeritageIds(app, idBySig); + // Level-free, after heritage: unit 4 matches on resolved extends_ids. + const entrypoint_report = detectEntrypoints(app); const root: TSApplication = { id: appId, @@ -109,6 +112,7 @@ export function finalizeAnalysis( unresolved_imports: app.unresolved_imports ?? [], config_uses: app.config_uses ?? [], config_reads: app.config_reads ?? [], + entrypoint_report, }; // L2 — home the off-tree edge endpoints, backfill `callee`, re-identify the call graph. diff --git a/src/schema/entrypoints.ts b/src/schema/entrypoints.ts new file mode 100644 index 0000000..fd0408f --- /dev/null +++ b/src/schema/entrypoints.ts @@ -0,0 +1,33 @@ +/** + * 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/src/schema/schema.ts b/src/schema/schema.ts index f2409f8..c1f35c5 100644 --- a/src/schema/schema.ts +++ b/src/schema/schema.ts @@ -245,6 +245,35 @@ export type TSCallableKind = | "arrow" | "function_expression"; +/** + * One way a callable or class is invoked from outside the application (#72; python #27 parity). + * A node may hold several — two route decorators, or a function that is both a task and a CLI + * command. `confidence` lets a consumer threshold on evidence quality rather than inheriting this + * analyzer's judgement. + */ +export interface TSEntrypoint { + framework: string; + confidence: "declared" | "certain" | "heuristic"; + rule: string; // rules file `id:`, or an engine name + ruleset: string; // "shipped" | "user:" + evidence?: string; + route?: string; + http_methods: string[]; + via?: string; // can:// id of the routed node dispatching here +} + +/** + * Coverage and failure record for the entrypoint pass (#72). The pass under-approximates by + * design, so silence is its failure mode — this is what makes a gap visible instead of + * indistinguishable from "this project has no entrypoints". + */ +export interface TSEntrypointReport { + frameworks_detected: string[]; + rulesets: string[]; + unresolved: Record; + errors: string[]; +} + export interface TSCallable { id: string; // can:// containment id — stamped per-run by assignIds kind: TSCallableKind; @@ -253,6 +282,10 @@ export interface TSCallable { signature: string; // e.g. src/user.UserService.getUser — the internal join key comments: TSComment[]; decorators: TSDecorator[]; + // Entrypoints (#72): stamped per-run by the entrypoint pass, like heritage — the cached tree lacks + // them, the wire always carries them. Empty until a rule matches (units 2-5). + entrypoints?: TSEntrypoint[]; + is_entrypoint?: boolean; parameters: TSCallableParameter[]; type_parameters: TSTypeParameter[]; return_type?: string; @@ -306,6 +339,10 @@ export interface TSType { functions?: Record; // namespace only // class decorators?: TSDecorator[]; + // Entrypoints (#72): class only, stamped per-run — python stamps PyClass; an interface, enum, + // alias or namespace cannot be an entrypoint and never carries these. + entrypoints?: TSEntrypoint[]; + is_entrypoint?: boolean; base_classes?: string[]; // spine: union of extends + implements (signature strings) implements_types?: string[]; // typed split: just the implemented interfaces is_abstract?: boolean; @@ -524,6 +561,8 @@ export interface TSApplication { /** config_use literal tier (#101 unit C2/C3) — empty until L2; CALL rules need the call graph. */ config_uses: TSConfigUse[]; config_reads: TSConfigRead[]; + /** Entrypoint coverage report (#72) — level-free, identical at every -a. */ + entrypoint_report: TSEntrypointReport; // TS-additive (parity): edge endpoints outside the containment tree need an id home. external_symbols?: Record; // L2 — library call targets, keyed by id // L2 — #92 compatibility index: the older anonymous-callable id → the tree id that replaced diff --git a/test/entrypoint-contract.test.ts b/test/entrypoint-contract.test.ts new file mode 100644 index 0000000..689d456 --- /dev/null +++ b/test/entrypoint-contract.test.ts @@ -0,0 +1,109 @@ +/** + * Entrypoint contract (#153, unit 1 of #72): the fields exist at every -a before any detector does. + * + * Three properties, none of which a "fields are present" check would catch on its own: they are + * stamped on CLASSES and callables but never on interfaces/enums (python stamps PyClass); they are + * identical across levels AND across a warm cache, because the pass is per-run like heritage and + * the cached tree deliberately lacks them; and the Neo4j report is sorted-key JSON so it diffs + * against python's. + */ +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 { project } from "../src/build/neo4j"; +import { NODE_LABELS } from "../src/build/neo4j/schema"; +import { forEachCallable, forEachType } from "../src/schema"; +import type { AnalysisOptions } from "../src/options"; +import type { TSApplication } from "../src/schema"; + +const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cants-ep-")); +fs.mkdirSync(path.join(dir, "src")); +fs.writeFileSync( + path.join(dir, "src", "a.ts"), + [ + "export interface Shape { area(): number; }", + "export enum Kind { A, B }", + "export class Circle implements Shape { area(): number { return helper(1); } }", + "export function helper(x: number): number { return x * 2; }", + "export const top = helper(3);", + ].join("\n"), +); +fs.writeFileSync(path.join(dir, "tsconfig.json"), JSON.stringify({ compilerOptions: { target: "ES2020" }, include: ["src/**/*.ts"] })); + +const opts = (analysisLevel: number, eager: boolean) => + ({ + input: dir, appName: "ep", analysisLevel, eager, 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; + +/** Every stamped entrypoint field, keyed by id — what must be invariant across levels. */ +function stamped(root: TSApplication): Record { + const out: Record = { __report: root.entrypoint_report }; + for (const mod of Object.values(root.symbol_table)) { + forEachCallable(mod, (c) => { out[c.id] = { entrypoints: c.entrypoints, is_entrypoint: c.is_entrypoint }; }); + forEachType(mod, (t) => { out[t.id] = { kind: t.kind, entrypoints: t.entrypoints, is_entrypoint: t.is_entrypoint }; }); + } + return out; +} + +describe("entrypoint contract (unit 1)", () => { + test("classes and callables carry the empty fields; interfaces and enums carry neither", async () => { + const root = rootOf(await analyze(opts(1, true))); + const s = stamped(root); + const byKind = (k: string) => Object.values(s).filter((v) => (v as { kind?: string }).kind === k) as Array>; + + for (const c of byKind("class")) expect(c).toMatchObject({ entrypoints: [], is_entrypoint: false }); + for (const i of [...byKind("interface"), ...byKind("enum")]) { + expect("is_entrypoint" in i && i.is_entrypoint !== undefined).toBe(false); + expect(i.entrypoints).toBeUndefined(); + } + // callables — including the module-scope and the implicit ones — all stamped + const callables = Object.entries(s).filter(([k, v]) => k !== "__report" && !("kind" in (v as object))); + expect(callables.length).toBeGreaterThan(1); + for (const [, v] of callables) expect(v).toEqual({ entrypoints: [], is_entrypoint: false }); + }); + + test("the report is present and empty at the root", async () => { + const root = rootOf(await analyze(opts(1, true))); + expect(root.entrypoint_report).toEqual({ frameworks_detected: [], rulesets: [], unresolved: {}, errors: [] }); + }); + + test("identical at every -a, including across a warm cache", async () => { + // L1 cold, then L2-L4 warm: the cached tree lacks these fields, so this is what proves the pass + // re-stamps per run rather than relying on the builder. + const l1 = stamped(rootOf(await analyze(opts(1, true)))); + for (const level of [2, 3, 4]) { + expect(stamped(rootOf(await analyze(opts(level, false))))).toEqual(l1); + } + }); + + test("Neo4j: report on the application node, flags on class and callable, nothing on interface", async () => { + const res = await analyze(opts(1, true)); + const rows = project(res.application); + const app = rows.nodes.find((n) => n.labels.includes("TSApplication")); + expect(app?.props.entrypoint_frameworks).toEqual([]); + expect(app?.props.entrypoint_report_json).toBe('{"errors":[],"frameworks_detected":[],"rulesets":[],"unresolved":{}}'); + + const cls = rows.nodes.find((n) => n.labels.includes("TSClass")); + expect(cls?.props).toMatchObject({ is_entrypoint: false, entrypoint_frameworks: [] }); + const fn = rows.nodes.find((n) => n.labels.includes("TSCallable")); + expect(fn?.props).toMatchObject({ is_entrypoint: false, entrypoint_frameworks: [] }); + const iface = rows.nodes.find((n) => n.labels.includes("TSInterface")); + expect(iface).toBeDefined(); + expect("is_entrypoint" in (iface?.props ?? {})).toBe(false); + }); + + test("declared in the schema contract on exactly the labels that carry them", () => { + const has = (label: string, prop: string) => prop in (NODE_LABELS.find((n) => n.label === label)?.properties ?? {}); + expect(has("TSApplication", "entrypoint_report_json")).toBe(true); + expect(has("TSApplication", "entrypoint_frameworks")).toBe(true); + expect(has("TSClass", "is_entrypoint")).toBe(true); + expect(has("TSCallable", "is_entrypoint")).toBe(true); + expect(has("TSInterface", "is_entrypoint")).toBe(false); + expect(has("TSEnum", "is_entrypoint")).toBe(false); + }); +}); diff --git a/test/schema-v2.test.ts b/test/schema-v2.test.ts index a158282..5a25c34 100644 --- a/test/schema-v2.test.ts +++ b/test/schema-v2.test.ts @@ -93,6 +93,7 @@ describe("schema v2 — L1 envelope", () => { "config_reads", "config_uses", "dependencies", + "entrypoint_report", // #72 unit 1: level-free coverage report "id", "kind", "param_in",