Skip to content

Commit 83fe4b3

Browse files
committed
chore: integrate 1.1.0 twin machinery with the v2 catalog after rebase
1 parent 9e9a0d4 commit 83fe4b3

6 files changed

Lines changed: 102 additions & 14 deletions

File tree

schema.neo4j.json

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -406,5 +406,20 @@
406406
"indexes": [
407407
"CREATE INDEX callable_name IF NOT EXISTS FOR (c:Callable) ON (c.name)",
408408
"CREATE INDEX cannode_kind IF NOT EXISTS FOR (n:CanNode) ON (n.kind)"
409-
]
409+
],
410+
"label_twins": {
411+
"Application": "TSApplication",
412+
"Module": "TSModule",
413+
"Class": "TSClass",
414+
"Interface": "TSInterface",
415+
"Enum": "TSEnum",
416+
"TypeAlias": "TSTypeAlias",
417+
"Namespace": "TSNamespace",
418+
"Callable": "TSCallable",
419+
"Field": "TSField",
420+
"BodyNode": "TSBodyNode",
421+
"External": "TSExternal",
422+
"AnonymousCallable": "TSAnonymousCallable",
423+
"Entrypoint": "TSEntrypoint"
424+
}
410425
}

src/build/neo4j/project.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
*/
1212

1313
import type { V2Application, V2BodyNode, V2Callable, V2External, V2Field, V2Module, V2Node, V2Root, V2Type } from "../../schema/v2";
14-
import { SCHEMA_VERSION } from "./schema";
14+
import { SCHEMA_VERSION, withTwins } from "./schema";
1515
import { type GraphRows, type NodeRef, type Prop, type Props, RowBuilder, prune } from "./rows";
1616

1717
/** The shared MERGE label + key every can://-id-keyed node is addressed by. */
@@ -42,7 +42,7 @@ const KIND_LABEL: Record<string, string> = {
4242
};
4343

4444
export function project(app: V2Application, _appName?: string): GraphRows {
45-
const b = new RowBuilder();
45+
const b = new RowBuilder(withTwins);
4646
const root: V2Root = app.application;
4747

4848
const appRef = b.node(["Application"], "id", root.id, prune({

src/build/neo4j/rows.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,11 +61,19 @@ export class RowBuilder {
6161
private readonly deferred: EdgeRow[] = []; // edges gated against node existence at finish()
6262
private readonly keys = new Set<string>(); // every node value seen, for resolved-gating
6363

64+
/**
65+
* @param expand optional label-set expander applied to every node's labels (the schema layer
66+
* injects its twin-label policy here; rows.ts itself stays schema-agnostic). Must keep the
67+
* merge label at index 0.
68+
*/
69+
constructor(private readonly expand: (labels: string[]) => string[] = (l) => l) {}
70+
6471
/**
6572
* Upsert a node. Re-seeing the same (mergeLabel, value) merges props (last write wins) and
6673
* unions labels — the in-memory analog of `MERGE (n:Label {key}) SET n += props`.
6774
*/
6875
node(labels: string[], keyProp: string, value: string, props: Props): NodeRef {
76+
labels = this.expand(labels);
6977
const id = `${labels[0]}\0${value}`;
7078
const existing = this.nodes.get(id);
7179
if (existing) {

src/build/neo4j/schema.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,35 @@ export interface RelType {
3838
/** Labels layered onto a node in addition to its primary/specific label. */
3939
export const MARKER_LABELS = ["Entrypoint"] as const;
4040

41+
/**
42+
* Language-namespace twins (transient dual-labeling, issue #65): every specific and marker label is
43+
* also stamped as `TS<Label>` so a shared multi-language database can attribute TS nodes (epic #64).
44+
* The shared MERGE labels (`Symbol`, `CanNode`) deliberately have NO twin — MERGE targets, keys and
45+
* constraints are unchanged. This dual-label state is transient; the vocabulary is finalized later.
46+
*/
47+
export const TS_PREFIX = "TS";
48+
49+
/** The TS-prefixed twin of a specific or marker label. */
50+
export const twinOf = (label: string): string => `${TS_PREFIX}${label}`;
51+
52+
/** Shared merge labels never get a twin — they carry the constraint; node identity is unchanged. */
53+
const UNTWINNED = new Set(["Symbol", "CanNode"]);
54+
55+
/**
56+
* Expand a projection label set with its twins: order preserved, shared merge labels skipped,
57+
* idempotent. Any label already starting with `TS` is treated as a twin and never re-prefixed — so
58+
* no bare label may legitimately begin with `TS`.
59+
*/
60+
export function withTwins(labels: string[]): string[] {
61+
const out = [...labels];
62+
for (const l of labels) {
63+
if (UNTWINNED.has(l) || l.startsWith(TS_PREFIX)) continue;
64+
const t = twinOf(l);
65+
if (!out.includes(t)) out.push(t);
66+
}
67+
return out;
68+
}
69+
4170
/** The shared MERGE label for every can://-id-keyed node (one constraint; uniform edge endpoints). */
4271
const CAN = "CanNode";
4372
const SPAN = { start_line: "integer", end_line: "integer" } as const;
@@ -200,6 +229,16 @@ export interface SchemaDocument {
200229
relationship_types: RelType[];
201230
constraints: readonly string[];
202231
indexes: readonly string[];
232+
/** Specific/marker label → its TS-prefixed twin (both are present on every emitted node). */
233+
label_twins: Record<string, string>;
234+
}
235+
236+
/** One twin per specific label + per marker label — derived from the catalogs, never drifts. */
237+
function labelTwins(): Record<string, string> {
238+
const out: Record<string, string> = {};
239+
for (const n of NODE_LABELS) out[n.label] = twinOf(n.label);
240+
for (const m of MARKER_LABELS) out[m] = twinOf(m);
241+
return out;
203242
}
204243

205244
/** Build the full machine-readable schema document emitted by `--emit schema`. */
@@ -212,5 +251,6 @@ export function buildSchemaDocument(): SchemaDocument {
212251
relationship_types: REL_TYPES,
213252
constraints: CONSTRAINTS,
214253
indexes: INDEXES,
254+
label_twins: labelTwins(),
215255
};
216256
}

test/neo4j-schema.test.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import {
1616
REL_TYPES,
1717
buildSchemaDocument,
1818
project,
19+
twinOf,
20+
withTwins,
1921
} from "../src/build/neo4j";
2022
import { analyze } from "../src/core";
2123
import type { AnalysisOptions } from "../src/options";
@@ -44,13 +46,17 @@ const byLabel = new Map(NODE_LABELS.map((n) => [n.label, n]));
4446
const mergeOf = new Map(NODE_LABELS.map((n) => [n.label, n.mergeLabel]));
4547
const relByType = new Map(REL_TYPES.map((r) => [r.type, r]));
4648
const markers = new Set<string>(MARKER_LABELS);
49+
const twins = new Set<string>([
50+
...NODE_LABELS.map((n) => twinOf(n.label)),
51+
...MARKER_LABELS.map((m) => twinOf(m)),
52+
]);
4753
const mergeLabelsFor = (specifics: string[]) => new Set(specifics.map((s) => mergeOf.get(s)));
4854

49-
/** The specific (schema) label for a node row: the non-merge, non-marker label (`Application`
50-
* has only its own label; every `CanNode` carries exactly one specific kind label). */
55+
/** The specific (schema) label for a node row: the non-merge, non-marker, non-twin label
56+
* (`Application` has only its own label; every `CanNode` carries exactly one specific kind label). */
5157
function specificLabel(labels: string[]): string {
5258
const merge = labels[0];
53-
return labels.find((l) => l !== merge && !markers.has(l)) ?? merge;
59+
return labels.find((l) => l !== merge && !markers.has(l) && !twins.has(l)) ?? merge;
5460
}
5561

5662
const rows = await fixtureRows();
@@ -65,7 +71,7 @@ describe("neo4j schema conformance", () => {
6571
expect(node.labels[0]).toBe(decl!.mergeLabel);
6672

6773
for (const label of node.labels) {
68-
const ok = label === decl!.mergeLabel || label === specific || markers.has(label);
74+
const ok = label === decl!.mergeLabel || label === specific || markers.has(label) || twins.has(label);
6975
expect(ok, `unexpected label '${label}' on ${specific}`).toBe(true);
7076
}
7177
for (const key of Object.keys(node.props)) {
@@ -91,6 +97,17 @@ describe("neo4j schema conformance", () => {
9197
const fresh = JSON.stringify(buildSchemaDocument(), null, 2).trim();
9298
expect(onDisk).toBe(fresh);
9399
});
100+
101+
test("every node carries exactly the TS twins of its base labels (transient dual-labeling)", () => {
102+
for (const node of rows.nodes) {
103+
const base = node.labels.filter((l) => !twins.has(l));
104+
expect(new Set(node.labels), `bad twin set on ${node.labels.join(":")} ${node.value}`).toEqual(
105+
new Set(withTwins(base)),
106+
);
107+
// the shared merge label (`CanNode` / `Application`) stays bare at index 0.
108+
expect(twins.has(node.labels[0]), `merge label must stay bare: ${node.labels[0]}`).toBe(false);
109+
}
110+
});
94111
});
95112

96113
// ---- :Application analyzer identity (issue #43) ------------------------------------------------

test/neo4j-twins.test.ts

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
/**
2-
* Twin-label vocabulary (graph schema 1.1.0, issue #65): every specific and marker label has a
3-
* TS-prefixed twin; the shared merge label `Symbol` deliberately has none (epic #64).
2+
* Twin-label vocabulary (transient dual-labeling on graph schema 2.0.0, issue #65): every specific
3+
* and marker label has a TS-prefixed twin; the shared merge labels (`Symbol`, `CanNode`)
4+
* deliberately have none (epic #64). The v2 catalog merges on `CanNode`, so it is the merge label
5+
* that must stay bare here.
46
*/
57
import { describe, expect, test } from "bun:test";
68
import {
@@ -20,25 +22,31 @@ describe("TS twin-label vocabulary", () => {
2022
expect(twinOf("Entrypoint")).toBe("TSEntrypoint");
2123
});
2224

23-
test("withTwins appends a twin per label, keeps order, and skips Symbol", () => {
25+
test("withTwins appends a twin per label, keeps order, and skips shared merge labels", () => {
2426
expect(withTwins(["Module"])).toEqual(["Module", "TSModule"]);
27+
// both shared merge labels are skipped (v2 merges on CanNode)
28+
expect(withTwins(["CanNode", "Callable"])).toEqual(["CanNode", "Callable", "TSCallable"]);
2529
expect(withTwins(["Symbol", "Class"])).toEqual(["Symbol", "Class", "TSClass"]);
26-
expect(withTwins(["Symbol", "Callable", "Entrypoint"])).toEqual([
27-
"Symbol", "Callable", "Entrypoint", "TSCallable", "TSEntrypoint",
30+
expect(withTwins(["CanNode", "Callable", "Entrypoint"])).toEqual([
31+
"CanNode", "Callable", "Entrypoint", "TSCallable", "TSEntrypoint",
2832
]);
2933
// idempotent: an already-expanded set gains nothing
3034
expect(withTwins(["Module", "TSModule"])).toEqual(["Module", "TSModule"]);
3135
});
3236

33-
test("schema version is 1.1.0 (additive MINOR)", () => {
34-
expect(SCHEMA_VERSION).toBe("1.1.0");
37+
test("schema version is 2.0.0 (v2 catalog)", () => {
38+
expect(SCHEMA_VERSION).toBe("2.0.0");
3539
});
3640

3741
test("schema document maps every specific + marker label to its twin", () => {
3842
const doc = buildSchemaDocument();
3943
for (const n of NODE_LABELS) expect(doc.label_twins[n.label]).toBe(twinOf(n.label));
4044
for (const m of MARKER_LABELS) expect(doc.label_twins[m]).toBe(twinOf(m));
45+
// shared merge labels are never specific/marker labels, so they map to nothing
46+
expect(doc.label_twins["CanNode"]).toBeUndefined();
4147
expect(doc.label_twins["Symbol"]).toBeUndefined();
48+
// 12 node labels + 1 marker (Entrypoint) = 13 twins
49+
expect(Object.keys(doc.label_twins).length).toBe(13);
4250
expect(Object.keys(doc.label_twins).length).toBe(NODE_LABELS.length + MARKER_LABELS.length);
4351
});
4452
});

0 commit comments

Comments
 (0)