From ea361146660fae592bcadb5dd378359f9a50121a Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Wed, 2 Sep 2026 22:38:53 -0400 Subject: [PATCH] feat(neo4j): project decorators, matching python MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decorators were captured as structured `TSDecorator` in the JSON from the start — name, checker-resolved `qualified_name`, positional and keyword arguments, spans — on types, callables, fields and parameters. None of it reached Neo4j: the whole projection mentioned decorators once, in a comment. A query could see `@Controller` in analysis.json and not in the graph. Mirror python's `_project_decorator`: - `:TSDecorator` merged on the resolved `qualified_name` when there is one, so `@Get('/')` and `@Get('/:id')` collapse to a single node rather than two spellings of one decorator. - `TS_DECORATED_BY` from the decorated node to it, carrying the per-application facts: `positional_arguments` (raw source fragments) and `keyword_arguments_json` (sorted-key JSON, since Neo4j has no map property type — python encodes the same field the same way, so the two projections stay diffable). Like python's, the node carries no `_module` and is never pruned: it is shared across modules, so anything application-specific stored on it would accumulate across every project in the database. Attached at types, callables and fields. Python attaches at classes and callables only, but TS carries decorators on fields too and property decorators are a first-class TS idiom (Angular `@Input`, TypeORM `@Column`), so dropping them would lose the metadata most TS decorator queries are actually after. Parameters are not projected as nodes in either analyzer, so parameter decorators stay unprojected. SCHEMA_VERSION stays at 2.1.0 — every analyzer re-baselines together later. --- schema.neo4j.json | 28 ++++++++++++ src/build/neo4j/project.ts | 30 ++++++++++++- src/build/neo4j/rows.ts | 6 ++- src/build/neo4j/schema.ts | 15 +++++++ test/neo4j-decorators.test.ts | 84 +++++++++++++++++++++++++++++++++++ 5 files changed, 160 insertions(+), 3 deletions(-) create mode 100644 test/neo4j-decorators.test.ts 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})"); + }); +});