From 127d1691f400f2d4dfc8c5e9fe60e74599453d78 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Wed, 24 Jun 2026 16:37:11 -0400 Subject: [PATCH] refactor(neo4j): consolidate the graph schema into one source of truth (closes #15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The graph contract was split across catalog.ts (labels, relationships, marker labels, version) and schema.ts (the CONSTRAINTS/INDEXES DDL), and the constraint list hand-duplicated the (mergeLabel, key) pairs already in NODE_LABELS — a second place to update whenever a label was added. - Fold the DDL into the schema module and delete the separate schema.ts; rename catalog.ts -> schema.ts to mirror src/schema/schema.ts (the IR schema). - Derive CONSTRAINTS from NODE_LABELS (one per distinct mergeLabel/key), so a new label brings its own uniqueness constraint with no second list to maintain. - Keep the curated INDEXES list co-located; export MARKER_LABELS from the barrel so consumers stop deep-importing. Behavior-preserving: same 8 constraints + 3 indexes. The only delta is cosmetic constraint naming/ordering (derived names like symbol_signature, bind variable x); Neo4j's IF NOT EXISTS matches on schema+type, so existing databases are unaffected. Guarded by the schema-conformance test; schema.neo4j.json regenerated. Follow-up (out of scope): make project.ts emission schema-driven so label tuples and property names aren't restated in code. --- schema.neo4j.json | 16 +- src/build/neo4j/catalog.ts | 334 --------------------------------- src/build/neo4j/index.ts | 4 +- src/build/neo4j/project.ts | Bin 19789 -> 19788 bytes src/build/neo4j/schema.ts | 368 +++++++++++++++++++++++++++++++++++-- test/neo4j-schema.test.ts | 8 +- 6 files changed, 370 insertions(+), 360 deletions(-) delete mode 100644 src/build/neo4j/catalog.ts diff --git a/schema.neo4j.json b/schema.neo4j.json index 192146f..fcc80bb 100644 --- a/schema.neo4j.json +++ b/schema.neo4j.json @@ -443,14 +443,14 @@ } ], "constraints": [ - "CREATE CONSTRAINT symbol_sig IF NOT EXISTS FOR (s:Symbol) REQUIRE s.signature IS UNIQUE", - "CREATE CONSTRAINT app_name IF NOT EXISTS FOR (a:Application) REQUIRE a.name IS UNIQUE", - "CREATE CONSTRAINT module_key IF NOT EXISTS FOR (m:Module) REQUIRE m.file_key IS UNIQUE", - "CREATE CONSTRAINT package_name IF NOT EXISTS FOR (p:Package) REQUIRE p.name IS UNIQUE", - "CREATE CONSTRAINT decorator_qn IF NOT EXISTS FOR (d:Decorator) REQUIRE d.qualified_name IS UNIQUE", - "CREATE CONSTRAINT callsite_id IF NOT EXISTS FOR (c:CallSite) REQUIRE c.id IS UNIQUE", - "CREATE CONSTRAINT attribute_id IF NOT EXISTS FOR (a:Attribute) REQUIRE a.id IS UNIQUE", - "CREATE CONSTRAINT variable_id IF NOT EXISTS FOR (v:Variable) REQUIRE v.id IS UNIQUE" + "CREATE CONSTRAINT application_name IF NOT EXISTS FOR (x:Application) REQUIRE x.name IS UNIQUE", + "CREATE CONSTRAINT module_file_key IF NOT EXISTS FOR (x:Module) REQUIRE x.file_key IS UNIQUE", + "CREATE CONSTRAINT symbol_signature IF NOT EXISTS FOR (x:Symbol) REQUIRE x.signature IS UNIQUE", + "CREATE CONSTRAINT package_name IF NOT EXISTS FOR (x:Package) REQUIRE x.name IS UNIQUE", + "CREATE CONSTRAINT decorator_qualified_name IF NOT EXISTS FOR (x:Decorator) REQUIRE x.qualified_name IS UNIQUE", + "CREATE CONSTRAINT callsite_id IF NOT EXISTS FOR (x:CallSite) REQUIRE x.id IS UNIQUE", + "CREATE CONSTRAINT attribute_id IF NOT EXISTS FOR (x:Attribute) REQUIRE x.id IS UNIQUE", + "CREATE CONSTRAINT variable_id IF NOT EXISTS FOR (x:Variable) REQUIRE x.id IS UNIQUE" ], "indexes": [ "CREATE INDEX callable_name IF NOT EXISTS FOR (c:Callable) ON (c.name)", diff --git a/src/build/neo4j/catalog.ts b/src/build/neo4j/catalog.ts deleted file mode 100644 index ddb4ac0..0000000 --- a/src/build/neo4j/catalog.ts +++ /dev/null @@ -1,334 +0,0 @@ -/** - * The declarative Neo4j schema catalog — the single in-repo source of truth for the graph - * contract (node labels, their keys and typed properties, relationship types and their endpoints). - * `--emit schema` serializes this (with the DDL from ./schema) to a machine-readable schema.json, - * and the conformance test (test/neo4j-schema.test.ts) asserts the real emitter never produces a - * label / relationship / property that isn't declared here — so this file cannot silently drift - * from project.ts. - * - * SCHEMA_VERSION is the contract version: bump MAJOR on a breaking change (renamed/removed label, - * relationship or key), MINOR on an additive change (new label/rel/property). It is stamped onto - * the :Application node of every emitted graph so any consumer can detect a producer/consumer - * mismatch at runtime. - */ -import { CONSTRAINTS, INDEXES } from "./schema"; - -export const SCHEMA_VERSION = "1.0.0"; - -export type PropType = "string" | "integer" | "float" | "boolean" | "string[]" | "integer[]"; - -export interface NodeLabel { - /** The specific label (also the catalog key). */ - label: string; - /** The label the uniqueness constraint / MERGE is on (`Symbol` for signature-keyed nodes). */ - mergeLabel: string; - key: string; - properties: Record; -} - -export interface RelType { - type: string; - from: string[]; - to: string[]; - properties: Record; -} - -/** Labels layered onto a node in addition to its primary/specific label. */ -export const MARKER_LABELS = ["Entrypoint"] as const; - -const SPAN = { start_line: "integer", end_line: "integer" } as const; -const ENTRYPOINT = { - framework: "string", - detection_source: "string", - route_path: "string", - http_methods: "string[]", - entrypoint_count: "integer", -} as const; - -export const NODE_LABELS: NodeLabel[] = [ - { - label: "Application", - mergeLabel: "Application", - key: "name", - properties: { name: "string", schema_version: "string" }, - }, - { - label: "Module", - mergeLabel: "Module", - key: "file_key", - properties: { - file_key: "string", - module_name: "string", - is_tsx: "boolean", - is_declaration_file: "boolean", - content_hash: "string", - last_modified: "integer", - file_size: "integer", - _module: "string", - }, - }, - { - label: "Class", - mergeLabel: "Symbol", - key: "signature", - properties: { - signature: "string", - name: "string", - code: "string", - base_classes: "string[]", - implements_types: "string[]", - type_parameter_names: "string[]", - docstring: "string", - is_abstract: "boolean", - is_exported: "boolean", - is_ambient: "boolean", - ...SPAN, - ...ENTRYPOINT, - _module: "string", - }, - }, - { - label: "Interface", - mergeLabel: "Symbol", - key: "signature", - properties: { - signature: "string", - name: "string", - code: "string", - base_classes: "string[]", - type_parameter_names: "string[]", - call_signatures: "string[]", - index_signatures: "string[]", - docstring: "string", - is_exported: "boolean", - is_ambient: "boolean", - ...SPAN, - _module: "string", - }, - }, - { - label: "Enum", - mergeLabel: "Symbol", - key: "signature", - properties: { - signature: "string", - name: "string", - code: "string", - member_names: "string[]", - member_values: "string[]", - docstring: "string", - is_const: "boolean", - is_exported: "boolean", - is_ambient: "boolean", - ...SPAN, - _module: "string", - }, - }, - { - label: "TypeAlias", - mergeLabel: "Symbol", - key: "signature", - properties: { - signature: "string", - name: "string", - code: "string", - aliased_type: "string", - type_parameter_names: "string[]", - docstring: "string", - is_exported: "boolean", - is_ambient: "boolean", - ...SPAN, - _module: "string", - }, - }, - { - label: "Namespace", - mergeLabel: "Symbol", - key: "signature", - properties: { - signature: "string", - name: "string", - docstring: "string", - is_exported: "boolean", - is_ambient: "boolean", - ...SPAN, - _module: "string", - }, - }, - { - label: "Callable", - mergeLabel: "Symbol", - key: "signature", - properties: { - signature: "string", - name: "string", - path: "string", - kind: "string", - return_type: "string", - cyclomatic_complexity: "integer", - code: "string", - code_start_line: "integer", - ...SPAN, - accessibility: "string", - accessor_kind: "string", - docstring: "string", - type_parameter_names: "string[]", - parameters_json: "string", - accessed_symbols_json: "string", - is_static: "boolean", - is_abstract: "boolean", - is_async: "boolean", - is_generator: "boolean", - is_optional: "boolean", - is_readonly: "boolean", - is_exported: "boolean", - is_ambient: "boolean", - is_implicit: "boolean", - ...ENTRYPOINT, - _module: "string", - }, - }, - { - label: "External", - mergeLabel: "Symbol", - key: "signature", - properties: { signature: "string", name: "string", module: "string" }, - }, - { - // A first-party anonymous callback Jelly resolves as a call endpoint but the symbol table never - // names. Thin (no code/params) — the signature carries identity; DECLARES links it to its host. - label: "AnonymousCallable", - mergeLabel: "Symbol", - key: "signature", - properties: { - signature: "string", - name: "string", - path: "string", - start_line: "integer", - start_column: "integer", - _module: "string", - }, - }, - { label: "Package", mergeLabel: "Package", key: "name", properties: { name: "string" } }, - { - label: "Decorator", - mergeLabel: "Decorator", - key: "qualified_name", - properties: { qualified_name: "string", name: "string" }, - }, - { - label: "CallSite", - mergeLabel: "CallSite", - key: "id", - properties: { - id: "string", - method_name: "string", - receiver_expr: "string", - receiver_type: "string", - argument_types: "string[]", - type_arguments: "string[]", - return_type: "string", - callee_signature: "string", - is_constructor_call: "boolean", - is_optional_chain: "boolean", - start_line: "integer", - start_column: "integer", - end_line: "integer", - end_column: "integer", - _module: "string", - }, - }, - { - label: "Attribute", - mergeLabel: "Attribute", - key: "id", - properties: { - id: "string", - name: "string", - type: "string", - initializer: "string", - accessibility: "string", - docstring: "string", - is_static: "boolean", - is_readonly: "boolean", - is_optional: "boolean", - is_abstract: "boolean", - ...SPAN, - _module: "string", - }, - }, - { - label: "Variable", - mergeLabel: "Variable", - key: "id", - properties: { - id: "string", - name: "string", - type: "string", - initializer: "string", - scope: "string", - declaration_kind: "string", - is_readonly: "boolean", - is_exported: "boolean", - ...SPAN, - _module: "string", - }, - }, -]; - -const DECL_TARGETS = ["Class", "Interface", "Enum", "TypeAlias", "Namespace", "Callable"]; - -export const REL_TYPES: RelType[] = [ - { type: "HAS_MODULE", from: ["Application"], to: ["Module"], properties: {} }, - { type: "DECLARES", from: ["Module", "Namespace", "Class", "Callable"], to: DECL_TARGETS, properties: {} }, - { type: "HAS_METHOD", from: ["Class", "Interface"], to: ["Callable"], properties: {} }, - { type: "HAS_ATTRIBUTE", from: ["Class", "Interface"], to: ["Attribute"], properties: {} }, - { type: "DECLARES_VAR", from: ["Module", "Namespace", "Callable"], to: ["Variable"], properties: {} }, - { type: "HAS_CALLSITE", from: ["Callable"], to: ["CallSite"], properties: {} }, - { type: "RESOLVES_TO", from: ["CallSite"], to: ["Callable", "External"], properties: {} }, - { - type: "CALLS", - from: ["Callable"], - to: ["Callable", "External"], - properties: { weight: "integer", provenance: "string[]", dispatch: "string", external: "boolean", module: "string" }, - }, - { type: "EXTENDS", from: ["Class", "Interface"], to: ["Class", "Interface"], properties: {} }, - { type: "IMPLEMENTS", from: ["Class"], to: ["Interface"], properties: {} }, - { - type: "IMPORTS", - from: ["Module"], - to: ["Module", "Package"], - properties: { imported_names: "string[]", import_kinds: "string[]", is_type_only: "boolean" }, - }, - { type: "RE_EXPORTS", from: ["Module"], to: ["Module", "Package"], properties: {} }, - { type: "MEMBER_OF", from: ["External"], to: ["Package"], properties: {} }, - { - type: "DECORATED_BY", - from: ["Class", "Callable", "Attribute"], - to: ["Decorator"], - properties: { positional_arguments: "string[]", keyword_arguments_json: "string", start_line: "integer", end_line: "integer" }, - }, -]; - -export interface SchemaDocument { - schema_version: string; - generator: string; - marker_labels: readonly string[]; - node_labels: NodeLabel[]; - relationship_types: RelType[]; - constraints: readonly string[]; - indexes: readonly string[]; -} - -/** Build the full machine-readable schema document emitted by `--emit schema`. */ -export function buildSchemaDocument(): SchemaDocument { - return { - schema_version: SCHEMA_VERSION, - generator: "codeanalyzer-typescript", - marker_labels: MARKER_LABELS, - node_labels: NODE_LABELS, - relationship_types: REL_TYPES, - constraints: CONSTRAINTS, - indexes: INDEXES, - }; -} diff --git a/src/build/neo4j/index.ts b/src/build/neo4j/index.ts index a6beb87..3fadc2f 100644 --- a/src/build/neo4j/index.ts +++ b/src/build/neo4j/index.ts @@ -3,6 +3,6 @@ export { project } from "./project"; export { renderCypher } from "./cypher"; export { boltWriter, type BoltConfig } from "./bolt"; -export { SCHEMA_VERSION, buildSchemaDocument, NODE_LABELS, REL_TYPES } from "./catalog"; -export type { SchemaDocument } from "./catalog"; +export { SCHEMA_VERSION, buildSchemaDocument, NODE_LABELS, REL_TYPES, MARKER_LABELS } from "./schema"; +export type { SchemaDocument } from "./schema"; export type { GraphRows, NodeRow, EdgeRow } from "./rows"; diff --git a/src/build/neo4j/project.ts b/src/build/neo4j/project.ts index f0c29cb2279c6f47952220fe24c5197c45f89156..6cbbde0e0686216d9ff93df29e60feb344540e2f 100644 GIT binary patch delta 21 dcmX>*i}B1X#tmUCY{khLskw=pV_80U003bs2%P`` delta 22 ecmX>zi}CC%#tmUC?8%8Gi8=Y{n`2l$cmM!t!wClf diff --git a/src/build/neo4j/schema.ts b/src/build/neo4j/schema.ts index 88c381d..7c23064 100644 --- a/src/build/neo4j/schema.ts +++ b/src/build/neo4j/schema.ts @@ -1,21 +1,365 @@ /** - * The Cypher DDL — uniqueness constraints and indexes — shared by both writers. Run BEFORE any - * load so MERGE uses an index seek (not a label scan) and the identity invariant is enforced by - * the database. Every statement is idempotent (`IF NOT EXISTS`). + * The declarative Neo4j schema — the single in-repo source of truth for the graph contract: node + * labels with their keys and typed properties, relationship types and their endpoints, and the + * Cypher DDL (uniqueness constraints + indexes). The constraints are DERIVED from the node labels + * (one per distinct mergeLabel/key) so a new label brings its own constraint — there is no second + * list to keep in sync. `--emit schema` serializes all of this to a machine-readable schema.json, + * and the conformance test (test/neo4j-schema.test.ts) asserts the real emitter never produces a + * label / relationship / property that isn't declared here — so this file cannot silently drift + * from project.ts. + * + * SCHEMA_VERSION is the contract version: bump MAJOR on a breaking change (renamed/removed label, + * relationship or key), MINOR on an additive change (new label/rel/property). It is stamped onto + * the :Application node of every emitted graph so any consumer can detect a producer/consumer + * mismatch at runtime. */ -export const CONSTRAINTS: readonly string[] = [ - "CREATE CONSTRAINT symbol_sig IF NOT EXISTS FOR (s:Symbol) REQUIRE s.signature IS UNIQUE", - "CREATE CONSTRAINT app_name IF NOT EXISTS FOR (a:Application) REQUIRE a.name IS UNIQUE", - "CREATE CONSTRAINT module_key IF NOT EXISTS FOR (m:Module) REQUIRE m.file_key IS UNIQUE", - "CREATE CONSTRAINT package_name IF NOT EXISTS FOR (p:Package) REQUIRE p.name IS UNIQUE", - "CREATE CONSTRAINT decorator_qn IF NOT EXISTS FOR (d:Decorator) REQUIRE d.qualified_name IS UNIQUE", - "CREATE CONSTRAINT callsite_id IF NOT EXISTS FOR (c:CallSite) REQUIRE c.id IS UNIQUE", - "CREATE CONSTRAINT attribute_id IF NOT EXISTS FOR (a:Attribute) REQUIRE a.id IS UNIQUE", - "CREATE CONSTRAINT variable_id IF NOT EXISTS FOR (v:Variable) REQUIRE v.id IS UNIQUE", +export const SCHEMA_VERSION = "1.0.0"; + +export type PropType = "string" | "integer" | "float" | "boolean" | "string[]" | "integer[]"; + +export interface NodeLabel { + /** The specific label (also the catalog key). */ + label: string; + /** The label the uniqueness constraint / MERGE is on (`Symbol` for signature-keyed nodes). */ + mergeLabel: string; + key: string; + properties: Record; +} + +export interface RelType { + type: string; + from: string[]; + to: string[]; + properties: Record; +} + +/** Labels layered onto a node in addition to its primary/specific label. */ +export const MARKER_LABELS = ["Entrypoint"] as const; + +const SPAN = { start_line: "integer", end_line: "integer" } as const; +const ENTRYPOINT = { + framework: "string", + detection_source: "string", + route_path: "string", + http_methods: "string[]", + entrypoint_count: "integer", +} as const; + +export const NODE_LABELS: NodeLabel[] = [ + { + label: "Application", + mergeLabel: "Application", + key: "name", + properties: { name: "string", schema_version: "string" }, + }, + { + label: "Module", + mergeLabel: "Module", + key: "file_key", + properties: { + file_key: "string", + module_name: "string", + is_tsx: "boolean", + is_declaration_file: "boolean", + content_hash: "string", + last_modified: "integer", + file_size: "integer", + _module: "string", + }, + }, + { + label: "Class", + mergeLabel: "Symbol", + key: "signature", + properties: { + signature: "string", + name: "string", + code: "string", + base_classes: "string[]", + implements_types: "string[]", + type_parameter_names: "string[]", + docstring: "string", + is_abstract: "boolean", + is_exported: "boolean", + is_ambient: "boolean", + ...SPAN, + ...ENTRYPOINT, + _module: "string", + }, + }, + { + label: "Interface", + mergeLabel: "Symbol", + key: "signature", + properties: { + signature: "string", + name: "string", + code: "string", + base_classes: "string[]", + type_parameter_names: "string[]", + call_signatures: "string[]", + index_signatures: "string[]", + docstring: "string", + is_exported: "boolean", + is_ambient: "boolean", + ...SPAN, + _module: "string", + }, + }, + { + label: "Enum", + mergeLabel: "Symbol", + key: "signature", + properties: { + signature: "string", + name: "string", + code: "string", + member_names: "string[]", + member_values: "string[]", + docstring: "string", + is_const: "boolean", + is_exported: "boolean", + is_ambient: "boolean", + ...SPAN, + _module: "string", + }, + }, + { + label: "TypeAlias", + mergeLabel: "Symbol", + key: "signature", + properties: { + signature: "string", + name: "string", + code: "string", + aliased_type: "string", + type_parameter_names: "string[]", + docstring: "string", + is_exported: "boolean", + is_ambient: "boolean", + ...SPAN, + _module: "string", + }, + }, + { + label: "Namespace", + mergeLabel: "Symbol", + key: "signature", + properties: { + signature: "string", + name: "string", + docstring: "string", + is_exported: "boolean", + is_ambient: "boolean", + ...SPAN, + _module: "string", + }, + }, + { + label: "Callable", + mergeLabel: "Symbol", + key: "signature", + properties: { + signature: "string", + name: "string", + path: "string", + kind: "string", + return_type: "string", + cyclomatic_complexity: "integer", + code: "string", + code_start_line: "integer", + ...SPAN, + accessibility: "string", + accessor_kind: "string", + docstring: "string", + type_parameter_names: "string[]", + parameters_json: "string", + accessed_symbols_json: "string", + is_static: "boolean", + is_abstract: "boolean", + is_async: "boolean", + is_generator: "boolean", + is_optional: "boolean", + is_readonly: "boolean", + is_exported: "boolean", + is_ambient: "boolean", + is_implicit: "boolean", + ...ENTRYPOINT, + _module: "string", + }, + }, + { + label: "External", + mergeLabel: "Symbol", + key: "signature", + properties: { signature: "string", name: "string", module: "string" }, + }, + { + // A first-party anonymous callback Jelly resolves as a call endpoint but the symbol table never + // names. Thin (no code/params) — the signature carries identity; DECLARES links it to its host. + label: "AnonymousCallable", + mergeLabel: "Symbol", + key: "signature", + properties: { + signature: "string", + name: "string", + path: "string", + start_line: "integer", + start_column: "integer", + _module: "string", + }, + }, + { label: "Package", mergeLabel: "Package", key: "name", properties: { name: "string" } }, + { + label: "Decorator", + mergeLabel: "Decorator", + key: "qualified_name", + properties: { qualified_name: "string", name: "string" }, + }, + { + label: "CallSite", + mergeLabel: "CallSite", + key: "id", + properties: { + id: "string", + method_name: "string", + receiver_expr: "string", + receiver_type: "string", + argument_types: "string[]", + type_arguments: "string[]", + return_type: "string", + callee_signature: "string", + is_constructor_call: "boolean", + is_optional_chain: "boolean", + start_line: "integer", + start_column: "integer", + end_line: "integer", + end_column: "integer", + _module: "string", + }, + }, + { + label: "Attribute", + mergeLabel: "Attribute", + key: "id", + properties: { + id: "string", + name: "string", + type: "string", + initializer: "string", + accessibility: "string", + docstring: "string", + is_static: "boolean", + is_readonly: "boolean", + is_optional: "boolean", + is_abstract: "boolean", + ...SPAN, + _module: "string", + }, + }, + { + label: "Variable", + mergeLabel: "Variable", + key: "id", + properties: { + id: "string", + name: "string", + type: "string", + initializer: "string", + scope: "string", + declaration_kind: "string", + is_readonly: "boolean", + is_exported: "boolean", + ...SPAN, + _module: "string", + }, + }, +]; + +const DECL_TARGETS = ["Class", "Interface", "Enum", "TypeAlias", "Namespace", "Callable"]; + +export const REL_TYPES: RelType[] = [ + { type: "HAS_MODULE", from: ["Application"], to: ["Module"], properties: {} }, + { type: "DECLARES", from: ["Module", "Namespace", "Class", "Callable"], to: DECL_TARGETS, properties: {} }, + { type: "HAS_METHOD", from: ["Class", "Interface"], to: ["Callable"], properties: {} }, + { type: "HAS_ATTRIBUTE", from: ["Class", "Interface"], to: ["Attribute"], properties: {} }, + { type: "DECLARES_VAR", from: ["Module", "Namespace", "Callable"], to: ["Variable"], properties: {} }, + { type: "HAS_CALLSITE", from: ["Callable"], to: ["CallSite"], properties: {} }, + { type: "RESOLVES_TO", from: ["CallSite"], to: ["Callable", "External"], properties: {} }, + { + type: "CALLS", + from: ["Callable"], + to: ["Callable", "External"], + properties: { weight: "integer", provenance: "string[]", dispatch: "string", external: "boolean", module: "string" }, + }, + { type: "EXTENDS", from: ["Class", "Interface"], to: ["Class", "Interface"], properties: {} }, + { type: "IMPLEMENTS", from: ["Class"], to: ["Interface"], properties: {} }, + { + type: "IMPORTS", + from: ["Module"], + to: ["Module", "Package"], + properties: { imported_names: "string[]", import_kinds: "string[]", is_type_only: "boolean" }, + }, + { type: "RE_EXPORTS", from: ["Module"], to: ["Module", "Package"], properties: {} }, + { type: "MEMBER_OF", from: ["External"], to: ["Package"], properties: {} }, + { + type: "DECORATED_BY", + from: ["Class", "Callable", "Attribute"], + to: ["Decorator"], + properties: { positional_arguments: "string[]", keyword_arguments_json: "string", start_line: "integer", end_line: "integer" }, + }, ]; +// ---------------------------------------------------------------------------------------------- +// Cypher DDL — shared by both writers, run BEFORE any load so MERGE uses an index seek (not a label +// scan) and the identity invariant is enforced by the database. Every statement is idempotent +// (`IF NOT EXISTS`, which Neo4j matches on schema+type, so renames never duplicate a constraint). +// ---------------------------------------------------------------------------------------------- + +/** One uniqueness constraint per distinct (mergeLabel, key) in NODE_LABELS — derived, never drifts. */ +function uniquenessConstraints(): string[] { + const seen = new Set(); + const out: string[] = []; + for (const n of NODE_LABELS) { + const id = `${n.mergeLabel}.${n.key}`; + if (seen.has(id)) continue; + seen.add(id); + out.push( + `CREATE CONSTRAINT ${n.mergeLabel.toLowerCase()}_${n.key} IF NOT EXISTS ` + + `FOR (x:${n.mergeLabel}) REQUIRE x.${n.key} IS UNIQUE`, + ); + } + return out; +} + +export const CONSTRAINTS: readonly string[] = uniquenessConstraints(); + +/** Curated performance indexes (not 1:1 with labels, so declared explicitly). */ export const INDEXES: readonly string[] = [ "CREATE INDEX callable_name IF NOT EXISTS FOR (c:Callable) ON (c.name)", "CREATE INDEX decorator_name IF NOT EXISTS FOR (d:Decorator) ON (d.name)", "CREATE FULLTEXT INDEX code_fts IF NOT EXISTS FOR (c:Callable) ON EACH [c.code, c.docstring]", ]; + +export interface SchemaDocument { + schema_version: string; + generator: string; + marker_labels: readonly string[]; + node_labels: NodeLabel[]; + relationship_types: RelType[]; + constraints: readonly string[]; + indexes: readonly string[]; +} + +/** Build the full machine-readable schema document emitted by `--emit schema`. */ +export function buildSchemaDocument(): SchemaDocument { + return { + schema_version: SCHEMA_VERSION, + generator: "codeanalyzer-typescript", + marker_labels: MARKER_LABELS, + node_labels: NODE_LABELS, + relationship_types: REL_TYPES, + constraints: CONSTRAINTS, + indexes: INDEXES, + }; +} diff --git a/test/neo4j-schema.test.ts b/test/neo4j-schema.test.ts index edafd6e..bd22299 100644 --- a/test/neo4j-schema.test.ts +++ b/test/neo4j-schema.test.ts @@ -1,8 +1,8 @@ /** * Schema conformance test (no container needed). Projects the sample fixture and asserts that the - * real emitter only ever produces node labels, relationship types and properties that the catalog - * (src/build/neo4j/catalog.ts) declares. This is the anti-drift guard: if project.ts grows a label - * or property that catalog.ts doesn't declare, this fails — keeping the published schema.json + * real emitter only ever produces node labels, relationship types and properties that the schema + * (src/build/neo4j/schema.ts) declares. This is the anti-drift guard: if project.ts grows a label + * or property that schema.ts doesn't declare, this fails — keeping the published schema.json * honest. It also checks the checked-in schema.neo4j.json is regenerated (run `bun gen:schema`). */ import { describe, expect, test } from "bun:test"; @@ -10,12 +10,12 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { + MARKER_LABELS, NODE_LABELS, REL_TYPES, buildSchemaDocument, project, } from "../src/build/neo4j"; -import { MARKER_LABELS } from "../src/build/neo4j/catalog"; import { analyze } from "../src/core"; import type { AnalysisOptions } from "../src/options";