Skip to content

fix(parser): resolve generic inheritance targets - #939

Draft
merlincat11 wants to merge 2 commits into
tirth8205:stagingfrom
merlincat11:fix/935-generic-inheritance
Draft

merlincat11 wants to merge 2 commits into
tirth8205:stagingfrom
merlincat11:fix/935-generic-inheritance

Conversation

@merlincat11

@merlincat11 merlincat11 commented Aug 30, 2026 •

Copy link
Copy Markdown

Summary

Resolve Java and C# generic inheritance against the base type's declaration name instead
of the constructed spelling, so class Child : GenericBase<string> links to
GenericBase. Every type argument is retained on the edge in extra.constructed_types.

Fixes #935. Closes #941.

How the target is derived

The declaration name comes from the syntax tree, skipping the type-argument and comment
subtrees. It is deliberately not derived by scanning for < and >: those characters
also occur in trivia the grammar ignores, so class C : I</* < */ string> {} — legal C#
— reads as unbalanced to a character counter and would keep the constructed spelling as
its target, leaving the original bug in place.

_get_base_type_nodes is split out of _get_bases so the source spelling and the target
come from a single traversal.

Repeated generic bases

C# permits one class to implement several closed constructions of the same generic
interface (class C : I<int>, I<string> {}). These erase to one target and share the
class declaration line, so upsert_edge's identity — (kind, source, target, file_path, line), which excludes extra — would treat one-edge-per-base as the same edge and keep
only the last. Bases are grouped by target and emitted as one edge recording every
construction.

Existing graphs

Changing a persisted spelling leaves old graphs stale, since nothing reparses an
unchanged file. This uses the mechanism already in the project for that situation,
CPP_IDENTITY_VERSION in incremental.py: an INHERITS_IDENTITY_VERSION records the
spelling a graph was built with, and incremental_update rebuilds once when it is
missing or stale and the graph holds Java or C# nodes.

The rebuild runs the real parser, so there is no second implementation of the
normalization to keep in step. An earlier revision of this PR carried a v10 SQL migration
that rewrote rows from stored text; it was dropped because it duplicated the parser's
logic — including comment handling — and both review findings against it landed on that
seam.

cpp_errors becomes failed_languages, since only its emptiness was ever used and the
same guard is needed per language: a file that failed to parse keeps its old edges, so
the version must not be marked current for that language. C++ behaviour is unchanged.

Verified on a graph built with pre-PR code, then updated with no file changes: the
rebuild triggers, inheritors_of GenericBase returns FromGeneric, and a second update
does not rebuild again.

What the target is (and is not)

The target is a lookup name, not a language-level declaration identity. It drops
generic arity (G<T> and G<T, U> both yield G) and carries no namespace or package.

inheritors_of already resolves non-generic bases the same way: class nodes are keyed by
bare name, so G<T> and G<T, U> collapse to one node regardless of this PR, and the
bare-name fallback already unions same-named classes. This brings generic bases to parity
with that path rather than introducing a new resolution model. The constructed spelling is
preserved losslessly in extra.constructed_types, so a namespace-aware resolver later has
the evidence it needs — tracked in #940, deliberately not attempted here since it
reproduces on main for non-generic bases.

Safety

  • only Java/C# INHERITS targets containing type arguments change
  • malformed or unreadable type text is left unchanged
  • no schema change and no SQL data migration
  • the identity rebuild runs once, then records the version

Testing

  • uv run pytest --tb=short -q --cov=code_review_graph --cov-report=term-missing --cov-fail-under=65 (2993 passed, 9 skipped, 2 xpassed; 85.07% coverage)
  • uv run ruff check code_review_graph/ tests/test_multilang.py
  • uv run mypy code_review_graph/ --ignore-missing-imports --no-strict-optional

Regression tests cover nested generic arguments (Java and C#), angle brackets inside
trivia, repeated closed constructions, unbalanced type text, and the upgrade rebuild.

@github-actions

github-actions Bot commented Aug 30, 2026 •

Copy link
Copy Markdown

code-review-graph review

Overall risk: 0.40 (MEDIUM) — 20 changed function(s)/class(es), 0 affected flow(s), 9 test gap(s)

Risk-scored changes

Risk Level Symbol Location Tested
0.40 medium code_review_graph/parser.py::CodeParser._get_base_type_nodes code_review_graph/parser.py:15370 no
0.35 low code_review_graph/parser.py::_erase_type_arguments code_review_graph/parser.py:2405 yes
0.35 low code_review_graph/parser.py::_declaration_name code_review_graph/parser.py:2438 no
0.35 low code_review_graph/parser.py::_group_bases_by_target code_review_graph/parser.py:2465 no
0.35 low code_review_graph/parser.py::_constructed_type_extra code_review_graph/parser.py:2481 no
0.30 low code_review_graph/parser.py::collect code_review_graph/parser.py:2452 no
0.30 low code_review_graph/parser.py::CodeParser._get_bases code_review_graph/parser.py:15439 no
0.30 low tests/test_multilang.py::TestCSharpParsing.test_finds_inheritance tests/test_multilang.py:719 (test)
0.30 low tests/test_multilang.py::TestCSharpParsing.test_inheritance_hard_cases tests/test_multilang.py:732 (test)
0.25 low tests/test_multilang.py::test_generic_identity_preserves_unbalanced_type_arguments tests/test_multilang.py:467 (test)

Test gaps

  • code_review_graph/parser.py::_declaration_name (code_review_graph/parser.py:2438)
  • code_review_graph/parser.py::collect (code_review_graph/parser.py:2452)
  • code_review_graph/parser.py::_group_bases_by_target (code_review_graph/parser.py:2465)
  • code_review_graph/parser.py::_constructed_type_extra (code_review_graph/parser.py:2481)
  • code_review_graph/parser.py::CodeParser._extract_classes (code_review_graph/parser.py:10239)
  • ...and 4 more without direct tests

Token savings: this graph-backed report used ~234,374 fewer tokens (~98%) than reading every changed file in full (estimated, chars/4 approximation).


Powered by code-review-graph — local-first analysis; no code leaves the CI runner.

INHERITS edges stored the base type as raw source text, so a generic base
became the target "GenericBase<string>", which never matched the class node
"GenericBase". The relationship was invisible, and inheritors_of reported
zero results with a confidence string asserting no such edge existed.

Target the base type's declaration name instead, derived from the syntax
tree with the type-argument and comment subtrees skipped. It is deliberately
not derived by scanning for angle brackets: those characters also appear in
trivia the grammar ignores, so "class C : I</* < */ string> {}" -- legal C#
-- reads as unbalanced to a character counter and would keep the constructed
spelling, leaving the bug in place. _get_base_type_nodes is split out of
_get_bases so the spelling and the target come from one traversal.

C# allows several closed constructions of one generic interface
("class C : I<int>, I<string> {}"). They erase to a single target and share
the class declaration line, so one edge per base would collide on
upsert_edge's identity of (kind, source, target, file_path, line), which
excludes extra, and the last would overwrite the rest. Bases are grouped by
target and emitted as one edge listing every construction in
extra.constructed_types, always as a list.

The target is a lookup name, not a language-level declaration identity: it
drops generic arity and carries no namespace. That matches how non-generic
bases already resolve, since class nodes are keyed by bare name. Retaining
the constructed spelling keeps the evidence a namespace-aware resolver would
need; see tirth8205#940 for that resolver and tirth8205#941 for existing graphs, which pick up
correct targets on reparse or a full build.

Malformed type text is left unchanged, so an error-tolerant parse of a
half-edited file never rewrites a target into a bogus name.

Fixes tirth8205#935
@merlincat11
merlincat11 force-pushed the fix/935-generic-inheritance branch from 6507904 to 433ab4c Compare August 30, 2026 17:37
@merlincat11
merlincat11 marked this pull request as ready for review August 30, 2026 17:37
…e targets

Changing how a Java/C# INHERITS target is spelled leaves existing graphs
stale: nothing reparses an unchanged file, so their GenericBase<string>
targets survive the upgrade and tirth8205#935 persists until a manual rebuild.

Use the mechanism already in place for exactly this, CPP_IDENTITY_VERSION.
An INHERITS_IDENTITY_VERSION marks the spelling a graph was built with, and
incremental_update rebuilds once when it is missing or stale and the graph
holds Java or C# nodes. The rebuild runs the real parser, so unlike a SQL
data migration there is no second implementation of the transformation to
keep in step.

cpp_errors becomes failed_languages, since only its emptiness was ever used
and the same guard is needed per language: a file that failed to parse keeps
its old edges, so the version must not be marked current for that language.
C++ behaviour is unchanged.
@merlincat11

Copy link
Copy Markdown
Author

Parking this as a draft rather than merging. The review findings hold up, and the last round showed the approach itself is the problem, not the details.

What I verified

All three findings reproduce, and all three are introduced by this branch. Each was built with the same source under main and under this branch:

Spring resolution picks the wrong implementation. spring_resolver.py builds its implementation map straight from INHERITS.target_qualified. INJECTS already reduces Store<Integer> to Store, so once INHERITS erases to Store too, they meet:

main : CALLS Consumer.run -> Store.get
here : CALLS Consumer.run -> StringStore.get     # implements Store<String>

This rewrites a CALLS edge, so it propagates into impact radius, flows and review context.

C# arity. With interface I {} and interface I<T> {} in one namespace, querying the non-generic I:

main : 0 results
here : ['Child']        # Child implements I<int>

Distinct from #940, which is namespace ambiguity already present on main.

Identity-version rebuild loop. One Java file that always fails to parse makes every incremental update a full rebuild, permanently. Filed as #944, since the same loop is reachable on main for C++.

Why parking rather than patching

The root cause is that this branch makes INHERITS.target_qualified lossy, and that field has more than one consumer. I audited inheritors_of and never checked spring_resolver.py, which was my mistake — it is exactly the "audit all consumers before changing a shared identity" point raised earlier in review, which I treated as architectural preference rather than concrete risk.

Fixing both regressions inside the current design means changing Spring resolution and the arity handling as well, which grows this PR into three subsystems. The better move is the separation asked for repeatedly in review: keep the raw spelling, derive a canonical declaration reference that carries arity, and have resolution consume that — rather than reshaping stored data so a bare-name fallback happens to match.

Captured in #943 with the reproductions above as constraints and #935 as the motivating test case.

State of the branch

The parser work here is still useful input to #943: base names are derived from the Tree-sitter nodes rather than by scanning angle brackets, which is what a character counter gets wrong on class C : I</* < */ string> {}. History also records a v10 SQL migration that was dropped because it reimplemented parser normalization against stored text.

Not closing, so the branch and discussion stay available for #943.

@tirth8205

Copy link
Copy Markdown
Owner

Changes required: replacing a constructed inheritance target with its bare name loses arity and permits incorrect binding. Build I.cs containing interface I {}, interface I<T> {} and class Child : I<int> {} with full_build(root, store); this PR changes the INHERITS target from I<int> to I, which also names the non-generic declaration. Preserve construction and declaration identity together, including the downstream generic-injection case described in the discussion.

@tirth8205
tirth8205 changed the base branch from main to staging September 15, 2026 13:10
@tirth8205

Copy link
Copy Markdown
Owner

This no longer merges into staging. The one conflict is code_review_graph/incremental.py, and the branch is 224 commits behind.

Worth knowing before you resolve:

  • code_review_graph/incremental.py: staging added a build-state checkpoint at the tail of full_build and moved the Git timeout into constants.py.
git fetch origin
git merge origin/staging
# resolve, then
uv run pytest tests/ -q
uv run ruff check code_review_graph/
uv run mypy code_review_graph/ --ignore-missing-imports --no-strict-optional
git push

I have not reviewed the change itself yet. That comes once it merges and the checks run against the merged state, since staging has moved a long way and the result is what matters.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants