Skip to content

TS bulk accessors: getter/setter signature collisions break dual-backend parity (latent) #300

Description

@rahlk

Mechanism

The TS analyzer disambiguates a get x() / set x() accessor pair only in the dict key it
uses inside a class/interface's methods map -- sig#get / sig#set (see
TSNeo4jBackend._method_key, cldk/analysis/typescript/neo4j/neo4j_backend.py) -- but the
underlying TSCallable.signature field on each accessor is the same string for both (e.g.
src/models.User.isAdmin), because it's derived from the property name, not the accessor kind.
accessor_kind ("getter"/"setter") is the only field that tells them apart.

This is invisible on the current sample-app fixture because it only has an unpaired getter
(src/models.User.isAdmin, no matching setter). It will surface the moment any app under test
declares a paired get/set on the same property.

In-memory backend (cldk/analysis/typescript/codeanalyzer/codeanalyzer.py)

_add_class iterates cl.methods.values() (a dict keyed by name#get/name#set) and calls
_add_callable for each, which does:

self._callables[c.signature] = c

Since both accessors share c.signature, the setter (or whichever is inserted second) silently
overwrites the getter in _callables. Every accessor consumer built on _iter_callables() /
_callables -- get_callables_overview, get_method_bodies, get_decorated_callables,
get_callsites_for, get_method, etc. -- sees exactly one row for the pair (last-writer-wins),
with whichever accessor lost silently dropped, including any of its own decorators, call sites, or
body text.

Neo4j backend (cldk/analysis/typescript/neo4j/neo4j_backend.py)

Each accessor is emitted as its own :Callable node, so MATCH (c:Callable) WHERE c._module IN $mods (used by get_callables_overview, get_callsites_for, etc.) returns two rows sharing
the same signature property -- one per accessor -- with no collapsing.

This means the two backends diverge on any app with a paired accessor:

  • Overview row count -- in-memory: 1 row for the pair; Neo4j: 2 rows.
  • Bodies / call sites -- in-memory only ever reflects the surviving accessor's code and call
    sites; Neo4j reflects both, correctly separated per accessor.
  • Decorators -- folds into the same bug family: TSNeo4jBackend._OVERVIEW_RETURN aggregates
    with collect(DISTINCT d.name), so duplicate decorator names on one node dedupe in Neo4j, while
    the in-memory TSCallableOverview.from_callable keeps c.decorators as-is (duplicates
    preserved) -- another silent divergence source for any callable carrying the same decorator name
    twice.

Why this wasn't caught

tests/analysis/typescript/test_typescript_bulk_parity_live.py (the live dual-backend parity
suite added for #298) runs against the same sample-app fixture, which has no paired accessor and
no duplicate-decorator-name case, so it can't see either divergence today. The suite would catch
this the moment the fixture (or any app it's pointed at) has a real get x()/set x() pair on the
same class, or a callable decorated twice with the same decorator name.

Suggested fix (not done here)

Give getter/setter callables (and the in-memory _callables map, _methods_by_class values, etc.)
a signature that folds in accessor_kind so the pair no longer collides -- mirroring the #get/
#set disambiguation the Neo4j backend's _method_key already does for its own internal dict key,
but at the TSCallable.signature level so every consumer (both backends) sees it consistently.
Decorator dedup semantics (collect(DISTINCT ...) vs list-with-duplicates) should be reconciled at
the same time, one way or the other.

Filed as a latent-bug follow-up while closing out #298 (TS bulk accessors) per final-review
finding B; not blocking that merge since today's fixture can't exercise it.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions