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
12 changes: 9 additions & 3 deletions schema.neo4j.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
},
{
Expand Down Expand Up @@ -97,7 +99,9 @@
"is_ambient": "boolean",
"code": "string",
"start_line": "integer",
"end_line": "integer"
"end_line": "integer",
"is_entrypoint": "boolean",
"entrypoint_frameworks": "string[]"
}
},
{
Expand Down Expand Up @@ -194,7 +198,9 @@
"is_implicit": "boolean",
"code": "string",
"start_line": "integer",
"end_line": "integer"
"end_line": "integer",
"is_entrypoint": "boolean",
"entrypoint_frameworks": "string[]"
}
},
{
Expand Down
22 changes: 21 additions & 1 deletion src/build/neo4j/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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<string, number> = {};
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),
});
Expand All @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions src/build/neo4j/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
},
},
{
Expand Down Expand Up @@ -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 } },
Expand Down
4 changes: 4 additions & 0 deletions src/schema/emit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand All @@ -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.
Expand Down
33 changes: 33 additions & 0 deletions src/schema/entrypoints.ts
Original file line number Diff line number Diff line change
@@ -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;
}
39 changes: 39 additions & 0 deletions src/schema/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:<path>"
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<string, number>;
errors: string[];
}

export interface TSCallable {
id: string; // can:// containment id — stamped per-run by assignIds
kind: TSCallableKind;
Expand All @@ -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;
Expand Down Expand Up @@ -306,6 +339,10 @@ export interface TSType {
functions?: Record<string, TSCallable>; // 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;
Expand Down Expand Up @@ -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<string, import("./homing").TSExternalNode>; // L2 — library call targets, keyed by id
// L2 — #92 compatibility index: the older anonymous-callable id → the tree id that replaced
Expand Down
109 changes: 109 additions & 0 deletions test/entrypoint-contract.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
const out: Record<string, unknown> = { __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<Record<string, unknown>>;

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 <anon> 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);
});
});
1 change: 1 addition & 0 deletions test/schema-v2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading