From daafcb3cb292465ccddf46562f649449f59a28bc Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Tue, 1 Sep 2026 23:26:19 -0400 Subject: [PATCH] fix(neo4j): write content_hash, so the incremental push actually diffs bolt.ts reads each module's stored `content_hash` to find what changed, but nothing wrote it: finalizeAnalysis stripped the field from the wire envelope, and the Neo4j projection -- built from that envelope -- omitted it, with a comment in project.ts noting the strip as the reason. So the diff compared against NULL for every module and concluded everything had changed. Every push was a full re-upsert. Three parts of the system disagreed: schema.ts DECLARED `content_hash` on :TSModule, bolt.ts READ it, project.ts never WROTE it. The contract needed no change -- the property was already declared, only the writer was missing. codeanalyzer-python is the reference and does this correctly: content_hash is wire payload (schema/py_schema.py:450), projected (neo4j/project.py:689), and read back (neo4j/bolt.py:105). Verified on a live Neo4j against superset-frontend: push 1 1841 modules (1841 changed) 55s push 2, unchanged 1841 modules ( 0 changed) 25s content_hash stored 1841 / 1841 (was 0) The regression test asserts every projected :TSModule carries a non-null hash, and is break-checked. That shape matters here: the existing bolt test seeded `content_hash:'stale'` into a fixture node by hand, so it exercised the diff against data the projection could not produce, which is how this survived. Note this enables a path that has never run in anger. A permanent full re-upsert was masking any latent bug in the incremental route; the superset double-push above is the evidence that it holds at scale. Closes #118. --- src/build/neo4j/project.ts | 6 +++--- src/schema/emit.ts | 5 ++++- test/neo4j-schema.test.ts | 16 ++++++++++++++++ 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/build/neo4j/project.ts b/src/build/neo4j/project.ts index a779c6d..ac36f77 100644 --- a/src/build/neo4j/project.ts +++ b/src/build/neo4j/project.ts @@ -233,10 +233,10 @@ function projectField(b: RowBuilder, f: TSField, owner: NodeRef, fileKey: string // ---------------------------------------------------------------------------------------------- function moduleProps(mod: TSModule, fileKey: string): Props { - // The wire strips the internal fields (module_name, content_hash) — the graph name is the - // file key, exactly as the historical projection of the stripped tree produced. + // `name` is the file key. `content_hash` is what the incremental push diffs against on the next + // run (bolt.ts) -- schema.ts has always declared it on :TSModule, but nothing wrote it (#118). return prune({ - id: mod.id, kind: "module", name: fileKey, + id: mod.id, kind: "module", name: fileKey, content_hash: mod.content_hash ?? null, is_tsx: mod.is_tsx, is_declaration_file: mod.is_declaration_file, ...span(mod), _module: fileKey, }); diff --git a/src/schema/emit.ts b/src/schema/emit.ts index 6a38883..dc35091 100644 --- a/src/schema/emit.ts +++ b/src/schema/emit.ts @@ -58,7 +58,10 @@ function stripInternal(root: TSApplication): void { for (const nt of Object.values((t["types"] as Record>) ?? {})) stripType(nt); }; for (const mod of Object.values(root.symbol_table) as unknown as Record[]) { - delete mod["content_hash"]; + // `content_hash` STAYS on the wire (#118). The Neo4j projection is built from this envelope + // and writes it onto :TSModule, where the incremental push reads it back to find changed + // modules -- stripping it here made that diff compare against NULL forever, so every push was + // a full re-upsert. codeanalyzer-python keeps it for the same reason (schema/py_schema.py). delete mod["last_modified"]; delete mod["file_size"]; for (const fn of Object.values((mod["functions"] as Record>) ?? {})) stripCallable(fn); diff --git a/test/neo4j-schema.test.ts b/test/neo4j-schema.test.ts index 8872c94..5aed008 100644 --- a/test/neo4j-schema.test.ts +++ b/test/neo4j-schema.test.ts @@ -122,6 +122,22 @@ describe("neo4j schema conformance", () => { // The JSON envelope advertises `analyzer{name,version}` (#29); the Neo4j :Application node is the // co-primary projection of the same envelope and must not diverge on analyzer identity. +// #118: the incremental push diffs each module's stored `content_hash` to find what changed. The +// field was stripped from the wire and never projected, so the diff compared against NULL forever +// and every push was a full re-upsert. schema.ts declared the property, bolt.ts read it, and +// project.ts never wrote it -- three parts of the system disagreeing. The old bolt test seeded the +// value by hand, so it exercised the diff against data the projection could not produce. +describe("module content_hash reaches the graph (#118)", () => { + test("every projected :TSModule carries a non-null content_hash", () => { + const modules = rows.nodes.filter((n) => n.labels.includes("TSModule")); + expect(modules.length).toBeGreaterThan(0); + for (const m of modules) { + expect(m.props["content_hash"], `no content_hash on ${m.value}`).toBeDefined(); + expect(typeof m.props["content_hash"]).toBe("string"); + } + }); +}); + describe(":Application node carries analyzer identity (issue #43)", () => { test("version matches package.json (the same source the JSON envelope's analyzer.version uses)", () => { const appNode = rows.nodes.find((n) => n.labels.includes("Application"));