Skip to content

refactor(resolver): split ResolverEngine into IndexBuilder + SymbolResolver (#115)#180

Merged
zaebee merged 7 commits into
mainfrom
feat/issue-115-resolver-split
Jun 11, 2026
Merged

refactor(resolver): split ResolverEngine into IndexBuilder + SymbolResolver (#115)#180
zaebee merged 7 commits into
mainfrom
feat/issue-115-resolver-split

Conversation

@zaebee

@zaebee zaebee commented Jun 11, 2026

Copy link
Copy Markdown
Owner

Summary

Pure structural refactor splitting the ResolverEngine God Object (23 methods, Ce 46) into three focused units per the design spec:

  • SymbolIndex (frozen dataclass, resolver/indices.py) — 10 index fields + 4 lookup methods
  • IndexBuilder (resolver/indices.py) — nodes in, frozen index out (3 methods; no edges — the inheritance tree is a resolution product and lives in the resolver)
  • SymbolResolver (resolver/symbols.py) — 9 resolution-strategy methods; builds the inheritance tree in __init__
  • ResolverEngine (resolver/engine.py) — 7-method facade retaining only edge finalization (confidence policy, virtual nodes); public API and import path unchanged

Behavior preserved bit-for-bit: the full self-parse produces an identical edge signature on main and this branch — SHA-256 over every (source, target, confidence, type) tuple matches (2536 edges, 566 virtual nodes, same confidence distribution, zero raw_dep: leaks). tests/unit/test_resolver.py passes unchanged.

The drift story

The resolver domain's IMPORTS layer was previously empty (excluded from scoring); the split materializes it. The naive structure (engine→indices + engine→symbols + symbols→indices) formed a 030T triangle scoring 0.60 — blowing the 0.40 ratchet. Per spec §3.3, the facade's needs are re-routed through symbols.py re-exports (as-form, mypy no_implicit_reexport), collapsing the layer into the 021C chain engine→symbols→indices that pipeline_stage rewards: measured drift dropped to ≈0.25 (tv_imp 0.00; residual is CALLS-layer). The ratchet tolerance was never raised.

Acceptance criteria (#115)

  • ResolverEngine below God-Object threshold (7 methods; SymbolResolver 9, SymbolIndex 4, IndexBuilder 3 — all < 10)
  • tests/unit/test_resolver.py passes unchanged (git diff main -- tests/unit/test_resolver.py is empty)
  • test_god_object_baseline_not_exceeded passes with ResolverEngine removed from _KNOWN_GOD_OBJECTS
  • mypy strict + ruff clean, interrogate 99.5%
  • Drift ratchets untouched and green (766 tests incl. 45 self-parsing)

Test Plan

  • Full gates: make format && make lint && make type-check && make pytest && make doc-coverage
  • SDD: 5 tasks, each through implementer → spec review → quality review; final holistic review READY FOR PR
  • gemini + guardian review

Closes #115

🤖 Generated with Claude Code

zaebee and others added 6 commits June 11, 2026 12:15
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…sk 3)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…imports (#115 task 4)

The split initially created a 030T imports triangle (engine->indices direct
import) pushing resolution domain drift to 0.60 > 0.40 ratchet. Re-exporting
the facade's needs through symbols.py restores the 021C chain per spec §3.3.
Ratchet untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@zaebee

zaebee commented Jun 11, 2026

Copy link
Copy Markdown
Owner Author

/gemini review

@zaebee

zaebee commented Jun 11, 2026

Copy link
Copy Markdown
Owner Author

/guardian review

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary: Found 4 real defects: (1) unnecessary re-exports with noqa in symbols.py violate mypy strict, (2) unused EdgeType import in symbols.py, (3) missing EdgeType import in engine.py causes runtime error, (4) inheritance tree in symbols.py stores unresolved raw_class: names. All are high-confidence issues.


🤖 mistral-medium-latest · 28,409 prompt + 763 completion = 29,172 tokens · graph 3/7 files (43%)

Comment thread src/cgis/resolver/symbols.py Outdated
Comment thread src/cgis/resolver/symbols.py Outdated
Comment thread src/cgis/resolver/engine.py Outdated
Comment thread src/cgis/resolver/symbols.py
github-actions Bot added a commit that referenced this pull request Jun 11, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request successfully implements the Resolver Split plan by refactoring the large ResolverEngine class into SymbolIndex, IndexBuilder, and SymbolResolver, leaving ResolverEngine as a clean 7-method facade. The changes also update the self-parsing and architecture tests to remove ResolverEngine from the God-Object baseline. The review feedback highlights important defensive programming improvements to prevent potential AttributeErrors when accessing metadata fields that might be None, and suggests a more robust check in resolve_dep_candidate to ensure authoritative resolution when a name is present in the import map.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread src/cgis/resolver/symbols.py Outdated
Comment thread src/cgis/resolver/indices.py
Comment thread src/cgis/resolver/symbols.py

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request successfully implements the Resolver Split plan by refactoring the ResolverEngine God Object into modular components: SymbolIndex and IndexBuilder for indexing, SymbolResolver for resolution strategies, and a simplified ResolverEngine facade. It also updates architectural guardrails, removes ResolverEngine from the God-Object baseline, and adds comprehensive unit tests. The review feedback suggests using __all__ in src/cgis/resolver/symbols.py for explicit re-exports instead of the import X as X pattern to align with conventional Python practices.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread src/cgis/resolver/symbols.py Outdated
@zaebee

zaebee commented Jun 11, 2026

Copy link
Copy Markdown
Owner Author

Code Review — split ResolverEngine → IndexBuilder + SymbolResolver

Verdict: ⚠️ Request changes (soft). The split itself is mechanically faithful — resolution logic moved 1:1, the public contract (ResolverEngine(nodes, edges).resolve() -> tuple) is preserved, the sole production caller (pipeline.py) is unaffected, and there are no efficiency regressions (the index is built once). But there's one architectural issue that bites the product directly, and the split surfaced several correctness bugs that now live in the new files.


🔴 Headline (introduced by this PR)

1. The split didn't decouple — it gamed the drift metric. engine.py:4 imports IndexBuilder / _RAW_CLASS_PREFIX / _SELF_PREFIX from symbols.py, which re-exports them from indices.py. The comment is candid: this exists so the domain's IMPORTS layer reads as a 021C chain (engine→symbols→indices) instead of a 030T triangle (§3.3). But the real dependency graph is a triangle: engine uses both IndexBuilder (from indices) and SymbolResolver (from symbols); symbols uses indices. Coupling didn't drop — it was hidden.

  • Product impact: cgis is an architecture-measurement tool. Its flagship self-parse now demonstrates that the recommended fix for a triangle is a cosmetic re-export — every user will learn to game drift the same way. And the patterns.yaml comment now reports the resolver domain as ≈0.25 "clean" when it structurally isn't.
  • Fragility: symbols.py must forever re-export IndexBuilder, which it never uses. A routine "remove unused import" cleanup in symbols.py breaks engine.py's import. (noqa: PLC0414 + mypy no_implicit_reexport guard against autofix, but not against a human.)
  • Suggestion: either import honestly from indices.py and accept the resolver is a layered_dag/triangle (it is one), or genuinely extract the shared layer so no triangle exists. Don't shape the module graph to flatter the dashboard number.

🟠 Correctness (pre-existing, but moved into the new files — where they'll now be maintained)

2. symbols.py:158_resolve_local_type_call fabricates an FQN. return self._index.map_to_node_fqn(candidate) or candidate: if the node doesn't exist (None), it returns the fabricated pkg.Service.nonexistent_method, which is then treated as a successful resolve (+0.5 confidence) and mints a virtual node for a method that doesn't exist.

3. indices.py:64 — strip-loop has no uniqueness check. Right above it, the suffix_map branch has a len(candidates) == 1 guard, but the strip-leading-segments loop returns the first candidate in self.nodes with no ambiguity check → on a prefix collision it resolves to an arbitrary (wrong) symbol at confidence 1.0.

4. symbols.py:35append(resolved or raw) drops inherited methods. When a parent doesn't resolve, the bare raw name is stored in _inheritance_tree; later _resolve_method_on_class_hierarchy does class_methods.get(raw) → miss → CALLS edges to inherited methods silently vanish from the graph. This PR moved it into __init__ (so it now fires at construction time).

5. symbols.py:50endswith(f".{name}") is unanchored. For a dotted name like util.Base, the validator accepts pkg.sub.util.Base even if the real import was a different util module — it checks only trailing segments, not that it's the imported module.

🟡 Altitude / structure (introduced)

6. Implicit ordering invariant. SymbolResolver.__init__ builds the inheritance tree via resolve_class_ref, which requires a fully built index before the resolver is constructed. It's correct today only because engine.py calls IndexBuilder().build() strictly first. The "index complete before resolver" invariant is no longer expressed anywhere (and SymbolIndex is frozen by convention only).

7. The God Object just relocated. SymbolResolver = 9 methods, one below the detector's threshold (10). The deleted allowlist line in test_architecture.py removed the documented home for this logic. The next resolution strategy (#161 Slice 2, named in the plan) can't be added as a method without tripping test_god_object_baseline_not_exceeded. Zero growth headroom in the class most likely to grow.

8. indices.py:15frozen=True, but the 10 contained dicts/sets are mutable ("frozen by convention"). The read-only contract isn't enforced; a future cache that writes into class_methods/suffix_map would silently corrupt the shared index — and now that it spans two files, such a mutation is harder to spot.

🟢 Cleanup (minor)

9. engine.py:100 reaches into self._index.nodes directly (membership test) while resolution goes through self._resolver. SymbolIndex is consumed at two abstraction levels — any change to its internals is a two-site change. A has_node(fqn) method would close the gap.

10. engine.py _make_virtual_node duplicates the virtual-node shape (VIRTUAL_FILE_PATH, line 0/0, confidence 0.8) also built in uplift.py. A contract change must be synced in both places → extract a shared make_virtual_node helper in core/models.py.


What's good: resolution logic moved verbatim, public API stable, new direct tests added (test_resolver_indices.py, test_resolver_symbols.py), no efficiency regression, thorough plan doc.

🤖 Generated with Claude Code

…struction (#115)

PR #180 review + #182: the symbols.py re-export block laundered the
engine->indices dependency to flatter the drift metric. Now SymbolResolver
builds the index itself, raw-prefix constants live in core.models, and
engine imports only symbols — the 021C chain is the true dependency graph.
Spec amended (Amendment 1). Behavior bit-for-bit; test_resolver.py untouched.
@zaebee

zaebee commented Jun 11, 2026

Copy link
Copy Markdown
Owner Author

Re: the self-review — point-by-point disposition, all addressed:

#1 (headline, re-export laundering)Accepted and fixed in 1071096: SymbolResolver.__init__(nodes, edges) now owns index construction (public index attribute), raw-prefix constants moved to core/models.py (Edge-target encoding family, next to VIRTUAL_FILE_PATH), and engine.py imports only cgis.resolver.symbols — the 021C chain is the real dependency graph. Re-export block + noqa: PLC0414 deleted. Spec got Amendment 1 superseding §3.3's re-export prescription. Re-measured: resolution 0.25, tv_imp 0.00, ratchet untouched, 766 tests green, test_resolver.py still zero-edit. Metric hardening against passthrough re-exports in general → #182 (analysis posted there).

#6 (implicit ordering invariant) — fixed structurally by the same change: the index is built as the first statement of the resolver's own __init__, so "index complete before resolution" can no longer be violated by a caller.

#2-#5 (correctness: fabricated FQN, unguarded strip-loop, resolved or raw dropping inherited methods, unanchored endswith) — confirmed real, confirmed pre-existing on main (this PR is bit-for-bit by mandate) → filed as #183 with each item and its confidence-semantics implications.

#8 (frozen-by-convention), #10 (virtual-node shape duplicated with uplift.py), #9 (has_node) — folded into #183 as structural-hardening items.

#7 (SymbolResolver at 9 = zero headroom) — acknowledged; the #161-slice-2 strategy addition will force the next decomposition decision (strategy extraction, not a baseline re-entry). Noted for that spec.

🤖 Generated with Claude Code

@sonarqubecloud

Copy link
Copy Markdown

@zaebee

zaebee commented Jun 11, 2026

Copy link
Copy Markdown
Owner Author

Re-review — after the honest-refactor commit (1071096)

Re-reviewed against my earlier comment. The structural findings are resolved properly:

  • docs: translate and rethink specifications from RU to EN #1 (re-export gaming the drift metric) — fixed at the root. engine.py now imports only from cgis.resolver.symbols import SymbolResolver plus the prefix constants from cgis.core.models import RAW_CLASS_PREFIX, SELF_PREFIX. The re-export block and the three noqa: PLC0414 are gone. symbols.py imports IndexBuilder/SymbolIndex honestly from indices. The engine → symbols → indices chain is now real — engine no longer touches indices directly, and the constants live in core.models (a legitimate shared base both import from). The shape was made real instead of made to measure differently. 👏
  • Feat/pipeline #6 (implicit ordering invariant) — fixed structurally. SymbolResolver.__init__(nodes, edges) builds the index itself (self.index = IndexBuilder().build(nodes)) before the inheritance tree. There is no longer any way to construct a resolver against a half-built index — the "index complete before resolver" invariant is enforced by construction, not by call ordering in engine.
  • Feat/database #7 (god object) — largely dissolved. ResolverEngine is down to 7 methods (was 23) and exits the _KNOWN_GOD_OBJECTS baseline. One residual: SymbolResolver sits at 9 methods, one under the detector's threshold of 10 — so the growth-headroom concern from my original Feat/database #7 moved here rather than disappearing. The next resolution strategy (FastAPI DI not modeled: analyze_impact/trace_flow miss Depends() consumers and Annotated type-aliases #161 Slice 2) will need a sub-split or it trips test_god_object_baseline_not_exceeded. Worth a note in the plan's "out of scope / follow-up".

Still open (pre-existing correctness, correctly out of scope for this structural PR — track separately):

These were verbatim-moved, not introduced here; suggest a separate "resolver resolution-correctness hardening" issue.

Verdict: the architectural objection is resolved. 50 resolver unit tests pass; design doc records the "make the shape real" rationale. The remaining items are pre-existing correctness nits + the SymbolResolver headroom note — none block this PR. 🟢

@zaebee

zaebee commented Jun 11, 2026

Copy link
Copy Markdown
Owner Author

Re: the still-open items — already tracked: #183 covers #2-#5 verbatim (plus the frozen-index/virtual-node-shape/has_node hardening from the first round). The SymbolResolver-headroom note for #161 slice 2 is recorded there too — the next strategy addition forces a sub-split decision, not a baseline re-entry. Thanks for the two-round review — the laundering catch materially improved both this PR and the product (#182).

🤖 Generated with Claude Code

@zaebee zaebee merged commit 706c922 into main Jun 11, 2026
3 checks passed
@zaebee zaebee deleted the feat/issue-115-resolver-split branch June 11, 2026 16:53
zaebee added a commit that referenced this pull request Jun 12, 2026
…itance names (#183) (#199)

Two pre-existing correctness gaps from the #115/#180 review.

1. `_resolve_local_type_call` fabricated an FQN: `map_to_node_fqn(candidate) or
   candidate` returned `Class.method` even when no such node existed, minting a
   phantom virtual node at +0.5 conf. Now it returns the resolved node, keeps
   external/stdlib targets (real library calls → EXTERNAL/STDLIB virtual nodes),
   and drops phantom internals. This also stops fabricating fake intra-domain
   edges like `module.list.append` for builtin-method calls (`edges.append`
   where `edges: list[Edge]`).

2. Inheritance tree stored `resolved or raw`: an unresolved EXTENDS parent left
   a bare name in `_inheritance_tree`, which could false-match an unrelated
   class sharing that bare name. Now only resolved (FQN) parents are stored.

## Self-drift re-baseline (cross-lane heads-up)
Fix 1 removes the phantom `module.builtin.method` self-edges that were
artificially deflating the `extraction` domain's drift, so its honest score
moves ≈0.32 → 0.37. Re-baselined `extraction` tolerance 0.35 → 0.40 in
docs/ontology/patterns.yaml (extraction block — distinct region from #178's
header note). Verified by edge-diff that NO real edges were lost; the fix
actually raised resolved-edge count (256 → 263) by letting calls fall through
to the correct global-symbol resolution instead of the wrong fabrication.

Deferred to a follow-up (need a mini-spec, per the issue): map_to_node_fqn
strip-loop uniqueness guard, unanchored dotted resolve_class_ref validator,
SymbolIndex MappingProxyType, shared virtual-node helper.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
zaebee added a commit that referenced this pull request Jun 13, 2026
…del's line (#181) (#243)

* fix(guardian): anchor inline comments to a verbatim quote, not the model's line (#181)

Guardian posted inline comments at whatever line the finder emitted; for new/
rewritten files every line is "commentable", so hallucinated coordinates passed
the diff-index filter and landed comments on the wrong line (repro: PR #180,
symbols.py:13 vs the real import at :3).

Content-anchoring (the issue's fix #2) + numbered/quote prompt (#1), composed:
- Finding gains an optional `anchor` — the exact source line, copied verbatim.
- `diff_line_content` exposes RIGHT-side `{lineno: text}`; `diff_line_index` is
  now derived from it so the two never diverge.
- `build_review` re-derives each finding's line from its `anchor` (falling back
  to `evidence`) located in the changed lines: model line trusted if it matches,
  else nearest match; a quote found nowhere demotes to a body comment instead of
  a confidently-wrong inline anchor. Nothing is lost.
- Finder prompt now asks for the verbatim `anchor` field.

Deterministic and backward-compatible: with no `diff_content` the model line is
used as before. 8 new tests (content map, line correction, hallucinated-anchor
demotion, legacy path). 1008 tests, mypy strict, ruff, doc 99.6% — all green.

Refs #181

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(guardian): exact-match-first anchoring, drop reverse-substring (#181 review)

Review caught that the bidirectional loose match (`needle in s or s in needle`)
re-created a #181-class bug: a trivial changed line (`)`, `else:`) is a substring
of a longer anchor, so `s in needle` matched it, and with no exact-match
preference a hallucinated model line could pull the comment onto that false match
(exact line 4 losing to a spurious `)` at 20 because 20 is nearer the model's 18).

Fix on the same structure:
- prefer an EXACT stripped-line equality;
- fall back to substring (`needle in line`) only when there's no exact hit AND
  the quote is >= 5 chars, so `)`/`else:` can't substring-match anything;
- drop the `s in needle` direction entirely (a model quoting MORE than the real
  line safely demotes to a body comment — nothing is mis-anchored).

2 regression tests: exact match beats a nearer spurious `)` substring; a 1-char
anchor demotes instead of latching onto `qux()`. 1010 tests, mypy strict, ruff,
doc 99.6% — all green.

Refs #181

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(guardian): clearer None-line fallback + explicit-None diff_content (#243 review)

gemini re-review (medium, readability): replace the convoluted
`(finding.line or matches[0])` lambda fallback with an explicit
`if finding.line is None: return matches[0]`, and use
`diff_content if diff_content is not None else {}` instead of `or {}`.

Both behaviour-preserving. Bound the narrowed line to a local (`model_line`) so
the min() closure type-checks under mypy strict (the closure doesn't narrow
`finding.line` on its own). 1010 tests, mypy strict, ruff, doc 99.6% — green.

Refs #181

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(guardian): keep file-level findings file-level when anchoring (#243 review)

Colleague review note 2: a finding the model marked file-level (line=None) with
no explicit `anchor` was promoted to an inline comment whenever its `evidence`
text happened to appear in the diff. `evidence` is supporting text, not a
positional signal — only an explicit `anchor` (or a model line) should drive
placement. Now `_anchored_line` returns None early for line=None + no-anchor, so
the note stays in the body; an explicit anchor still positions a lineless
finding.

2 tests (file-level stays file-level; explicit anchor still places a lineless
finding). Notes 1 (over-demotion) and 3 (keyword substring) addressed in the PR
discussion — strict demotion is deliberate: on a new/rewritten file every line is
commentable, so falling back to the model line there would re-open #181.

1012 tests, mypy strict, ruff, doc 99.6% — all green.

Refs #181

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(guardian): raise substring-anchor threshold to 10 (#243 review)

gemini caught an off-by-one: _MIN_SUBSTRING_ANCHOR=5 with `len >= 5` actually
ALLOWED 5-char keywords (`else:`, `self.`, `break`, `print`) to substring-match —
contradicting the docstring that listed `else:` as excluded. `else:` collides
with `something_else:`, `self.` with `myself.foo`. Bumped to 10 (a real anchor is
a full statement, comfortably longer) and corrected the docstring. Exact-match is
unaffected, so full short lines still anchor; only the partial-substring fallback
tightens.

+1 test: a 5-char `else:` anchor no longer latches onto `something_else:`.
1013 tests, mypy strict, ruff, doc 99.6% — green.

Refs #181

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

refactor(resolver): split ResolverEngine into IndexBuilder + SymbolResolver

1 participant