diff --git a/schema.neo4j.json b/schema.neo4j.json index 70b3c65..a943322 100644 --- a/schema.neo4j.json +++ b/schema.neo4j.json @@ -43,6 +43,15 @@ "name": "string" } }, + { + "label": "TSDecorator", + "mergeLabel": "TSDecorator", + "key": "name", + "properties": { + "name": "string", + "qualified_name": "string" + } + }, { "label": "ConfigKey", "mergeLabel": "ConfigKey", @@ -392,6 +401,24 @@ ], "properties": {} }, + { + "type": "TS_DECORATED_BY", + "from": [ + "TSClass", + "TSInterface", + "TSEnum", + "TSNamespace", + "TSCallable", + "TSField" + ], + "to": [ + "TSDecorator" + ], + "properties": { + "positional_arguments": "string[]", + "keyword_arguments_json": "string" + } + }, { "type": "TS_HAS_BODY_NODE", "from": [ @@ -532,6 +559,7 @@ "CREATE CONSTRAINT application_id IF NOT EXISTS FOR (x:Application) REQUIRE x.id IS UNIQUE", "CREATE CONSTRAINT artifact_id IF NOT EXISTS FOR (x:Artifact) REQUIRE x.id IS UNIQUE", "CREATE CONSTRAINT package_id IF NOT EXISTS FOR (x:Package) REQUIRE x.id IS UNIQUE", + "CREATE CONSTRAINT tsdecorator_name IF NOT EXISTS FOR (x:TSDecorator) REQUIRE x.name IS UNIQUE", "CREATE CONSTRAINT configkey_id IF NOT EXISTS FOR (x:ConfigKey) REQUIRE x.id IS UNIQUE", "CREATE CONSTRAINT cannode_id IF NOT EXISTS FOR (x:CanNode) REQUIRE x.id IS UNIQUE" ], diff --git a/src/build/neo4j/project.ts b/src/build/neo4j/project.ts index 17eba3d..e3149a3 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, TSField, TSModule, TSType } from "../../schema"; +import type { TSAnalysis, TSApplication, TSBodyNode, TSCallable, TSDecorator, 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"; @@ -175,6 +175,7 @@ function projectType(b: RowBuilder, t: TSType, parent: NodeRef, fileKey: string, const label = KIND_LABEL[t.kind] ?? "TSClass"; const node = b.node([CAN, label], "id", t.id, typeProps(t, fileKey, source)); b.edge("TS_DECLARES", parent, node); + for (const d of t.decorators ?? []) projectDecorator(b, node, d); // Inheritance overlay — resolved-only (emit.ts already dropped unresolved/external supertypes); // the deferred gate is defense-in-depth against a resolved id that never materialized as a node. for (const eid of t.extends_ids ?? []) b.edgeToSymbol("TS_EXTENDS", node, eid); @@ -197,6 +198,7 @@ function projectCallable(b: RowBuilder, c: TSCallable, owner: NodeRef, ownerRel: const labels = ANON_SIG.test(c.signature) ? [CAN, "TSCallable", "TSAnonymousCallable"] : [CAN, "TSCallable"]; const node = b.node(labels, "id", c.id, callableProps(c, fileKey, source)); b.edge(ownerRel, owner, node); + for (const d of c.decorators ?? []) projectDecorator(b, node, d); // Body nodes (L1: call sites; L3+: statements + synthetic vertices) + their overlays. for (const [localKey, bn] of Object.entries(c.body ?? {})) { @@ -221,11 +223,37 @@ function projectCallable(b: RowBuilder, c: TSCallable, owner: NodeRef, ownerRel: for (const t of Object.values(c.types ?? {})) projectType(b, t, node, fileKey, source); } +/** + * One decorator application (#82, mirrors python's `_project_decorator`). + * + * The merge key is the checker-resolved `qualified_name` when there is one, so `@Get` and + * `@Get(':id')` collapse to a single node rather than two spellings of the same decorator. The + * arguments are per-application and therefore ride on the relationship: `:TSDecorator` is shared + * across modules and never pruned, so anything application-specific stored on it would accumulate. + */ +function projectDecorator(b: RowBuilder, on: NodeRef, d: TSDecorator): void { + const key = d.qualified_name || d.name; + const dec = b.node(["TSDecorator"], "name", key, { name: key, qualified_name: d.qualified_name ?? "" }); + b.edge("TS_DECORATED_BY", on, dec, { + positional_arguments: [...(d.positional_arguments ?? [])], + // Neo4j has no map property type; python encodes the same field as a sorted-key JSON string. + keyword_arguments_json: JSON.stringify(sortedKeys(d.keyword_arguments ?? {})), + }); +} + +/** Key-sorted shallow copy, so the encoded JSON is stable across runs (python sorts too). */ +function sortedKeys(o: Record): Record { + const out: Record = {}; + for (const k of Object.keys(o).sort()) out[k] = o[k] as string; + return out; +} + function projectField(b: RowBuilder, f: TSField, owner: NodeRef, fileKey: string): void { const node = b.node([CAN, "TSField"], "id", f.id, prune({ id: f.id, kind: "field", name: f.name, type: f.type ?? null, ...span(f), _module: fileKey, })); b.edge("TS_HAS_FIELD", owner, node); + for (const d of f.decorators ?? []) projectDecorator(b, node, d); } // ---------------------------------------------------------------------------------------------- diff --git a/src/build/neo4j/rows.ts b/src/build/neo4j/rows.ts index 35d2107..48bda37 100644 --- a/src/build/neo4j/rows.ts +++ b/src/build/neo4j/rows.ts @@ -16,8 +16,10 @@ const CAN_NODE = "CanNode"; /** How an edge addresses one of its endpoints: the label + key property to MATCH on, and value. */ export interface NodeRef { - label: string; // the label carrying the uniqueness constraint ("Application" | "CanNode") - keyProp: string; // always "id" at schema v2 — every node is keyed on its can:// id + label: string; // the label carrying the uniqueness constraint ("CanNode", "Application", ...) + // Usually "id" — every can://-keyed node. Shared, non-can:// nodes key on their own natural + // identity instead (:Package/:ConfigKey on "id", :TSDecorator on "name"). + keyProp: string; value: string; } diff --git a/src/build/neo4j/schema.ts b/src/build/neo4j/schema.ts index 85a5ebd..4be019d 100644 --- a/src/build/neo4j/schema.ts +++ b/src/build/neo4j/schema.ts @@ -85,6 +85,17 @@ export const NODE_LABELS: NodeLabel[] = [ key: "id", properties: { id: "string", ecosystem: "string", name: "string" }, }, + { + // A decorator APPLICATION's shared target (#82, python `:PyDecorator` parity). Merged on the + // resolved `qualified_name` when the checker supplies one, so `@Get` and `@Get(':id')` land on + // one node instead of two. Per-application facts (the arguments) ride on TS_DECORATED_BY, not + // here: this node is shared across modules, carries no `_module`, and is never pruned, so + // anything application-specific on it would accumulate across every project in the database. + label: "TSDecorator", + mergeLabel: "TSDecorator", + key: "name", + properties: { name: "string", qualified_name: "string" }, + }, { label: "ConfigKey", mergeLabel: "ConfigKey", @@ -192,6 +203,10 @@ export const REL_TYPES: RelType[] = [ }, { type: "TS_HAS_METHOD", from: ["TSClass", "TSInterface"], to: ["TSCallable"], properties: {} }, { type: "TS_HAS_FIELD", from: ["TSModule", "TSClass", "TSInterface", "TSEnum", "TSNamespace"], to: ["TSField"], properties: {} }, + // The decorated node -> the decorator it applies. Arguments are per-application, so they live on + // the relationship; `keyword_arguments_json` is a JSON object string because Neo4j has no map + // property type (python encodes it the same way, sorted keys, so the two are diffable). + { type: "TS_DECORATED_BY", from: ["TSClass", "TSInterface", "TSEnum", "TSNamespace", "TSCallable", "TSField"], to: ["TSDecorator"], properties: { positional_arguments: "string[]", keyword_arguments_json: "string" } }, { type: "TS_HAS_BODY_NODE", from: ["TSCallable", "TSAnonymousCallable"], to: ["TSBodyNode"], properties: {} }, { type: "TS_RESOLVES_TO", from: ["TSBodyNode"], to: ["TSCallable", "TSExternal", "TSAnonymousCallable"], properties: {} }, { diff --git a/test/neo4j-decorators.test.ts b/test/neo4j-decorators.test.ts new file mode 100644 index 0000000..842c3e5 --- /dev/null +++ b/test/neo4j-decorators.test.ts @@ -0,0 +1,84 @@ +/** + * Decorator projection (#82), mirroring python's `_project_decorator`. Decorators were captured in + * the JSON as structured `TSDecorator` from the start but never reached Neo4j, so a query could + * see `@Controller` in analysis.json and not in the graph. + * + * Two properties matter and neither is obvious from the row count: the decorator NODE is shared + * (`@Get("/")` and `@Get("/:id")` are one node, merged on the resolved name), and the ARGUMENTS + * are per-application, so they ride on the relationship rather than the node. + */ +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, renderCypher } from "../src/build/neo4j"; +import { NODE_LABELS, REL_TYPES } from "../src/build/neo4j/schema"; +import type { AnalysisOptions } from "../src/options"; + +const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cants-dec-")); +fs.mkdirSync(path.join(dir, "src")); +fs.writeFileSync( + path.join(dir, "src", "a.ts"), + [ + "function Controller(prefix: string): ClassDecorator { return () => undefined; }", + "function Get(path: string): MethodDecorator { return () => undefined; }", + "function Column(opts: { nullable: boolean }): PropertyDecorator { return () => undefined; }", + "@Controller('/users')", + "export class UserController {", + " @Column({ nullable: true }) name: string = '';", + " @Get('/:id') show(): string { return ''; }", + " @Get('/') list(): string { return ''; }", + "}", + ].join("\n"), +); +fs.writeFileSync(path.join(dir, "tsconfig.json"), JSON.stringify({ compilerOptions: { target: "ES2020", experimentalDecorators: true }, include: ["src/**/*.ts"] })); + +const opts = { + input: dir, appName: "d", analysisLevel: 1, noBuild: true, emit: "neo4j", +} as unknown as AnalysisOptions; + +describe("neo4j decorator projection", () => { + test("projects decorator nodes and applications, sharing the node across applications", async () => { + const res = await analyze(opts); + const rows = project(res.application); + + const decNodes = rows.nodes.filter((n) => n.labels.includes("TSDecorator")); + // @Get is applied twice but is ONE node -- that is the merge-key behaviour, not a row count. + expect(decNodes.map((n) => n.props.name).sort()).toEqual(["Column", "Controller", "Get"]); + + const apps = rows.edges.filter((e) => e.type === "TS_DECORATED_BY"); + expect(apps.length).toBe(4); // class + property + two methods + + // Arguments are per-application: the two @Get edges carry different positional arguments. + const getArgs = apps + .filter((e) => e.to.value === "Get") + .map((e) => (e.props.positional_arguments as string[]).join(",")) + .sort(); + // positional_arguments are RAW source fragments, so the written quoting is preserved. + expect(getArgs).toEqual(["'/'", "'/:id'"]); + + // Object-literal keyword args are flattened to a sorted-key JSON string (python encodes the + // same field the same way, so the two projections stay diffable). + const col = apps.find((e) => e.to.value === "Column"); + expect(col?.props.keyword_arguments_json).toBe('{"nullable":"true"}'); + + // A property decorator reaches the field, not just the class. + expect(apps.some((e) => e.from.value.endsWith("/UserController/name"))).toBe(true); + }); + + test("declared in the schema contract, so the conformance gate covers it", () => { + const node = NODE_LABELS.find((n) => n.label === "TSDecorator"); + expect(node?.mergeLabel).toBe("TSDecorator"); + expect(node?.key).toBe("name"); + const rel = REL_TYPES.find((r) => r.type === "TS_DECORATED_BY"); + expect(rel?.to).toEqual(["TSDecorator"]); + expect(rel?.from).toContain("TSField"); + }); + + test("renders a MERGE on name, not on a can:// id", async () => { + const cypher = renderCypher(project((await analyze(opts)).application), "d"); + expect(cypher).toContain("MERGE (n:TSDecorator {name: row.k})"); + expect(cypher).toContain("MATCH (b:TSDecorator {name: row.t})"); + }); +});