Skip to content

refactor(extractor): decompose PythonExtractor into focused handlers (#116)#191

Merged
zaebee merged 2 commits into
mainfrom
worktree-issue-116-extractor-decompose
Jun 12, 2026
Merged

refactor(extractor): decompose PythonExtractor into focused handlers (#116)#191
zaebee merged 2 commits into
mainfrom
worktree-issue-116-extractor-decompose

Conversation

@zaebee

@zaebee zaebee commented Jun 12, 2026

Copy link
Copy Markdown
Owner

Summary

Closes #116. PythonExtractor was a God Object (30+ methods, efferent coupling 48, flagged by cgis analyze on cgis itself). This splits the single 960-line class into a thin orchestrator plus focused collaborators.

Module Role
python_extractor.py Thin orchestrator__init__, parse, _walk (dispatch), _handle_decorated_definition (4 methods)
_python_ast.py Pure leaf: name/path helpers, file_path_to_module_fqn, is_method, dispatch predicates
_python_imports.py ImportHandler — import walking + import_map building
_python_functions.py FunctionHandler — function/method extraction, CALLS/DI edges, local types
_python_classes.py ClassHandler — class extraction + inheritance/abstract metadata
_python_types.py TypeResolver — type-annotation cleanup + FQN resolution

Design notes

  • No import cycles: dependencies form a one-directional DAG onto the _python_ast leaf; handlers receive _pick_source_root by injection (the composition root is the extractor).
  • Public API preserved: file_path_to_module_fqn stays a real def in python_extractor.py (thin delegate), keeping both the cgis.extractors.python_extractor import path and the self-parse FQN node intact.

Acceptance criteria

  • PythonExtractor drops below God Object threshold (cgis confirms: 4 methods)
  • All existing tests/unit/test_python_extractor.py pass unchanged (58)
  • test_god_object_baseline_not_exceeded passes with PythonExtractor removed from _KNOWN_GOD_OBJECTS
  • mypy strict + ruff clean

Reviewer attention: drift re-baseline

The decomposition legitimately changes cgis.extractors's topology from a pure_utility shape into a layered DAG (orchestrator → handlers → _python_ast core). This breaches the self-drift gate (test_drift.py), so the extraction domain binding in docs/ontology/patterns.yaml is re-negotiated:

  • Measured against all patterns: layered_dag 0.32 (best fit, same as cgis.query) vs orchestrator 0.40 vs pure_utility 0.51
  • expected_pattern: pure_utility → layered_dag, drift_tolerance: 0.30 → 0.35, with an explanatory comment per the gate's documented re-negotiation policy.

Verification

make format && make lint && make type-check && make pytest && make doc-coverage — all green: 781 tests pass, mypy strict clean (51 files), doc-coverage 99.5%.

🤖 Generated with Claude Code

…116)

PythonExtractor was a God Object (30+ methods, efferent coupling 48). Split
the single 960-line class into a thin orchestrator plus focused collaborators:

  PythonExtractor   — thin entry point: parse() + _walk() dispatch (4 methods)
  ImportHandler     — import node walking + import_map building
  FunctionHandler   — function/method extraction, CALLS/DI edges, local types
  ClassHandler      — class extraction + inheritance/abstract metadata
  TypeResolver      — type-annotation cleanup + FQN resolution
  _python_ast       — pure leaf: name/path helpers, file_path_to_module_fqn

Dependencies form a one-directional DAG onto the _python_ast leaf (no import
cycles); handlers receive _pick_source_root by injection. file_path_to_module_fqn
stays a real def in python_extractor.py, preserving the public import path and
the self-parse FQN node.

PythonExtractor is removed from _KNOWN_GOD_OBJECTS; the god-object baseline now
passes without it. The decomposition changes cgis.extractors' topology from
pure_utility to a layered DAG, so the extraction domain's drift binding is
re-baselined to layered_dag (measured ~0.32, vs pure_utility 0.51) with a
documented comment, per the self-drift gate's re-negotiation policy.

All 58 python_extractor tests pass unchanged; full suite 781 passing,
mypy strict clean, doc-coverage 99.5%.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@zaebee zaebee self-assigned this Jun 12, 2026
@zaebee zaebee added enhancement New feature or request sprint Current sprint labels Jun 12, 2026
@zaebee

zaebee commented Jun 12, 2026

Copy link
Copy Markdown
Owner Author

/guardian review

@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 refactors the monolithic PythonExtractor by decomposing it into several focused helper modules (_python_ast.py, _python_classes.py, _python_functions.py, _python_imports.py, and _python_types.py), transforming it into a clean orchestrator. Accordingly, the architectural tests and ontology configurations are updated to reflect this transition from a God Object to a layered DAG pattern. The review feedback correctly identifies a latent bug in the relative module resolution helper where slicing with [:-0] would incorrectly return an empty list when leading_dots is zero, and provides a robust code suggestion to handle this case.

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/extractors/_python_ast.py Outdated

@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 5 major type safety violations: missing annotations for module-level constants, class methods, and function return types in new extractor modules. No logic, ontology, or contract defects detected.


🤖 mistral-medium-latest · 29,632 prompt + 934 completion = 30,566 tokens · graph 6/7 files (86%)

Comment thread src/cgis/extractors/_python_ast.py
Comment thread src/cgis/extractors/_python_imports.py
Comment thread src/cgis/extractors/_python_classes.py
Comment thread src/cgis/extractors/_python_functions.py
github-actions Bot added a commit that referenced this pull request Jun 12, 2026
gemini review on #191: `segments[:-trim]` with trim==0 evaluates to
`segments[:0]` == [], silently dropping the whole FQN. Unreachable today
(call site guards with `leading_dots > 0`) but a latent trap for tests /
future callers. Branch explicitly on trim==0 → return the module unchanged.
Locked with two regression tests.

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

Copy link
Copy Markdown

@zaebee

zaebee commented Jun 12, 2026

Copy link
Copy Markdown
Owner Author

Review triage

gemini — resolve_relative_module [:-0] latent bug (medium): ✅ accepted, fixed in 66a916b.
Real latent trap (unreachable today — call site guards leading_dots > 0), branched explicitly on trim == 0 to return the module unchanged, locked with two regression tests.

guardian/mistral — 4× "Type Safety: missing annotations": ❌ declined as false positives.
make type-check (mypy --strict, 51 source files) is green on this branch — strict mode fails if any function/method/return-type annotation is missing, so by construction none are. The finder did not run mypy; it inferred from seeing new files. Specifics:

  • PYTHON_LANG = "python" — mypy strict does not require annotations on module-level constants with inferred literal types (it passes). Final is a style nicety, not a strict-mode violation; the claim "violates the strict mypy requirement" is demonstrably false since the gate is green.
  • ImportHandler / ClassHandler / FunctionHandler methods — all already carry full parameter and return-type annotations (again, mypy strict would reject otherwise).

No code change for the guardian findings.

@zaebee

zaebee commented Jun 12, 2026

Copy link
Copy Markdown
Owner Author

/gemini review

@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 refactors the PythonExtractor by decomposing it from a single large class into a layered package structure with dedicated handlers for AST operations, classes, functions, imports, and types. The architectural pattern for the extraction domain is updated to layered_dag in the ontology documentation, and PythonExtractor is removed from the list of known god objects. The code review feedback identifies several critical issues: relative imports inside __init__.py files are incorrectly resolved due to over-trimming, which can be fixed by introducing an is_package flag; typed_default_parameter nodes are silently ignored during parameter type collection and require unwrapping; and Annotated should be added to the generic wrappers to support modern FastAPI dependency injection types.

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 on lines +13 to +24
def resolve_relative_module(module_fqn: str, leading_dots: int, relative_path: str) -> str:
"""Resolve a relative import to an absolute FQN.

Args:
module_fqn: FQN of the current file, e.g. "src.cgis.extractors.python_extractor"
leading_dots: number of leading dots in the import statement
relative_path: module path after the dots, e.g. "core" for "from ..core import x"

Returns "src.cgis.core" for leading_dots=2, relative_path="core" in the example above.
"""
segments = module_fqn.split(".")
trim = min(leading_dots, len(segments))

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.

high

When resolving relative imports, file_path_to_module_fqn strips the __init__ suffix from package files, making their FQN look like standard modules. As a result, resolve_relative_module trims one segment too many for relative imports inside __init__.py files (e.g., from . import pipeline inside src/cgis/__init__.py incorrectly resolves to src.pipeline instead of src.cgis.pipeline). Adding an is_package flag allows us to correctly adjust the number of segments to trim.

def resolve_relative_module(
    module_fqn: str, leading_dots: int, relative_path: str, is_package: bool = False
) -> str:
    """Resolve a relative import to an absolute FQN.

    Args:
        module_fqn: FQN of the current file, e.g. "src.cgis.extractors.python_extractor"
        leading_dots: number of leading dots in the import statement
        relative_path: module path after the dots, e.g. "core" for "from ..core import x"
        is_package: whether the current file is an __init__.py package file

    Returns "src.cgis.core" for leading_dots=2, relative_path="core" in the example above.
    """
    segments = module_fqn.split(".")
    dots_to_trim = leading_dots - 1 if is_package and leading_dots > 0 else leading_dots
    trim = min(dots_to_trim, len(segments))

Comment on lines +152 to +156
base_module = (
resolve_relative_module(module_fqn, leading_dots, raw_module_str)
if leading_dots > 0
else raw_module_str
)

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.

high

Pass the is_package flag to resolve_relative_module to ensure relative imports inside __init__.py files are resolved correctly without trimming the package name segment.

Suggested change
base_module = (
resolve_relative_module(module_fqn, leading_dots, raw_module_str)
if leading_dots > 0
else raw_module_str
)
is_package = file_path.replace("\\\\", "/").endswith("/__init__.py") or file_path.endswith("__init__.py")
base_module = (
resolve_relative_module(module_fqn, leading_dots, raw_module_str, is_package=is_package)
if leading_dots > 0
else raw_module_str
)

Comment on lines +282 to +283
if not node.named_children:
return

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.

high

When a parameter has both a type annotation and a default value (e.g., db: Session = Depends(get_db)), tree-sitter-python parses it as a typed_default_parameter node. In this case, the type field is located inside the nested typed_parameter node (accessible via the name field of the typed_default_parameter). Currently, collect_param_type directly calls node.child_by_field_name("type") on the typed_default_parameter node, which returns None. This causes all typed default parameters to be silently ignored for local type collection and speculative dependency edges. Unwrapping the typed_default_parameter to its inner typed_parameter resolves this issue.

Suggested change
if not node.named_children:
return
if node.type == "typed_default_parameter":
inner = node.child_by_field_name("name")
if inner:
node = inner
if not node.named_children:
return

import-map lookup that turns a bare or module-prefixed type name into a FQN.
"""

_GENERIC_WRAPPERS: frozenset[str] = frozenset({"Optional", "Union"})

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.

high

Add Annotated to _GENERIC_WRAPPERS. In modern Python and FastAPI, dependency injection is heavily declared using Annotated[DependencyType, Depends(...)]. Without Annotated in _GENERIC_WRAPPERS, clean_python_type_string returns "Annotated" instead of the actual dependency type, causing speculative dependency edges to target raw_dep:Annotated instead of the correct type.

Suggested change
_GENERIC_WRAPPERS: frozenset[str] = frozenset({"Optional", "Union"})
_GENERIC_WRAPPERS: frozenset[str] = frozenset({"Optional", "Union", "Annotated"})

@zaebee

zaebee commented Jun 12, 2026

Copy link
Copy Markdown
Owner Author

Review triage — gemini round 2

All four findings are in code moved verbatim by this refactor (no logic changed), so I verified each empirically against the installed tree-sitter-python grammar before acting.

#3typed_default_parameter type "silently ignored": ❌ declined (disproven).
The grammar gives typed_default_parameter a direct type field; its name field is the bare identifier, not a nested typed_parameter. Verified:

typed_default_parameter  fields: type=<Node type=type>  name=<Node type=identifier 'db'>
def f(db: Session = Depends(get_db))  → emits raw_dep:Session   (identical to `db: Session`)

The current code already handles this correctly. The suggested fix reassigns node = node.child_by_field_name("name") (the db identifier), after which child_by_field_name("type") returns None — i.e. it would introduce the very bug it claims to fix. No change.

#1 & #2 — relative imports inside __init__.py over-trim: ✅ confirmed real, but pre-existing.
Verified: from . import pipeline in src/cgis/__init__.pyimport_map={'pipeline': 'src.pipeline'} (should be src.cgis.pipeline). This is unchanged behavior from before #116 (verbatim move), and fixing it changes resolution semantics for real repos — out of scope for a behavior-preserving decompose. Filed as #194 with the repro and gemini's (sound) is_package fix sketch.

#4Annotated[T, ...] not unwrapped: ✅ confirmed real, but pre-existing + #161-adjacent.
Verified: clean("Annotated[Session, Depends(get_db)]")"Annotated". Also unchanged by this PR. It's a DI-semantics enhancement (the second Annotated arg is the Depends provider, not a generic type), not a one-line frozenset add — folded into #194 to design alongside #161.

Net: no code change to this PR — it stays a clean, behavior-preserving decomposition. The two genuine pre-existing gaps are tracked in #194 (same policy as #183 for the resolver split).

@zaebee zaebee merged commit b7a1b4f into main Jun 12, 2026
2 checks passed
zaebee added a commit that referenced this pull request Jun 12, 2026
…194) (#195)

* fix(extractor): correct relative imports in __init__.py + unwrap Annotated[] (#194)

Two pre-existing extractor correctness gaps surfaced by the #191 review.

1. Relative imports inside a package __init__.py over-trimmed by one segment.
   file_path_to_module_fqn strips the /__init__ suffix, so a package's FQN
   already IS the package — one leading dot refers to the package itself.
   resolve_relative_module gains an is_package flag (trim leading_dots-1 for
   packages); ImportHandler detects __init__.py and passes it.
   `from . import pipeline` in src/cgis/__init__.py now resolves to
   src.cgis.pipeline (was src.pipeline); `from .sub import X` → src.cgis.sub.X.

2. Annotated[T, ...] type annotations resolved to "Annotated" instead of T.
   Added Annotated to TypeResolver._GENERIC_WRAPPERS so it unwraps to its first
   arg. The DI provider in the metadata (e.g. Depends(get_db)) is unaffected —
   it is already captured separately as a DEPENDS_ON edge via the call-node
   path (verified), so this only fixes the local-type / raw_dep candidate.

A third #191 finding (typed_default_parameter type allegedly dropped) was
empirically disproven and not changed.

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

* refactor(extractor): bracket-depth-aware first-arg split in type cleaning

#195 review (gemini): adding Annotated to the generic wrappers routes
nested-generic first args (e.g. Annotated[dict[str, int], ...]) through the
`inner.split(",")[0]` path. Empirically the final output was already correct
("dict") — the truncated "dict[str" self-heals because clean re-partitions on
the first "[" — and this split predates this PR (Optional/Union used it too).
So no live bug, but the correctness was accidental.

Harden it: `_first_top_level_arg` splits on the first comma at bracket depth 0,
so the first argument is correct by design rather than by self-healing.
Behavior-preserving (verified identical on all nested cases); locked with a
nested-generic regression test.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
@zaebee zaebee deleted the worktree-issue-116-extractor-decompose branch June 12, 2026 20:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request sprint Current sprint

Projects

None yet

Development

Successfully merging this pull request may close these issues.

refactor(extractor): decompose PythonExtractor visitor into focused handlers

1 participant