Skip to content

TSNeo4jBackend.get_call_graph() omits synthesized anonymous-callback nodes — backend parity gap #176

Description

@rahlk

Summary

TSNeo4jBackend.get_call_graph() does not add the synthesized anonymous-callback nodes, so the two TypeScript backends are no longer behaviorally interchangeable for the call graph. The in-memory TSCodeanalyzer.get_call_graph() adds a node per synthesized callable with synthesized=True; the Neo4j backend does not, so graph.nodes[sig].get("synthesized") returns True on one backend and None on the other for the same signature.

Found while reviewing the tsc_only / synthesized-callable work on fix/issue-174 (#174), which introduced TSSynthesizedCallable and wired it through both backends.

Where

In-memory backend adds the nodes — cldk/analysis/typescript/codeanalyzer/codeanalyzer.py:323-325:

# Synthesized anonymous-callback nodes so Jelly's anonymous edges don't dangle.
for sig, syn in self.application.synthesized_callables.items():
    graph.add_node(sig, external=False, synthesized=True, name=syn.name, path=syn.path)

Neo4j backend omits the matching loop — cldk/analysis/typescript/neo4j/neo4j_backend.py:411-434. get_call_graph() adds only internal Callable nodes (via _all_callable_props()) and external phantom nodes (via get_external_symbols()), then runs the CALLS edge query. The get_synthesized_callables() helper already exists on this class (neo4j_backend.py:267-274) and is already used by get_application() (line 242) — it's just not used in get_call_graph().

Why it matters

The TSAnalysisBackend ABC and the TSNeo4jBackend docstring both promise the backends are drop-in interchangeable. When a CALLS edge targets a synthesized signature, nx.DiGraph.add_edge auto-creates the endpoint as a bare node with no attributes, so:

  • TSCodeanalyzer: graph.nodes[sig]{external: False, synthesized: True, name: ..., path: ...}
  • TSNeo4jBackend: graph.nodes[sig]{}

Any consumer iterating graph.nodes(data=True) and filtering on attrs.get("synthesized") silently gets different answers per backend.

(Note: this assumes :AnonymousCallable nodes are also labeled :Symbol so the MATCH (s:Symbol)-[:CALLS]->(t:Symbol) edge query reaches them. If they are not :Symbol, the edges to synthesized callables are dropped entirely on Neo4j and the signatures show up as orphan nodes on codeanalyzer — worth confirming against the emitted graph schema either way.)

Fix

Add the same loop to TSNeo4jBackend.get_call_graph() before the edge query, mirroring the in-memory backend:

for sig, syn in self.get_synthesized_callables().items():
    graph.add_node(sig, external=False, synthesized=True, name=syn.name, path=syn.path)

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions