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
28 changes: 28 additions & 0 deletions schema.neo4j.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,15 @@
"name": "string"
}
},
{
"label": "TSDecorator",
"mergeLabel": "TSDecorator",
"key": "name",
"properties": {
"name": "string",
"qualified_name": "string"
}
},
{
"label": "ConfigKey",
"mergeLabel": "ConfigKey",
Expand Down Expand Up @@ -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": [
Expand Down Expand Up @@ -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"
],
Expand Down
30 changes: 29 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, 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";
Expand Down Expand Up @@ -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);
Expand All @@ -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 ?? {})) {
Expand All @@ -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<string, string>): Record<string, string> {
const out: Record<string, string> = {};
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);
}

// ----------------------------------------------------------------------------------------------
Expand Down
6 changes: 4 additions & 2 deletions src/build/neo4j/rows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
15 changes: 15 additions & 0 deletions src/build/neo4j/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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: {} },
{
Expand Down
84 changes: 84 additions & 0 deletions test/neo4j-decorators.test.ts
Original file line number Diff line number Diff line change
@@ -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})");
});
});
Loading