Skip to content

feat: parse move spans and imports - #2070

Closed
vitali87 wants to merge 1 commit into
pr-split/split-pr1549-source/pr-1from
pr-split/split-pr1549-source/pr-2
Closed

vitali87 wants to merge 1 commit into
pr-split/split-pr1549-source/pr-1from
pr-split/split-pr1549-source/pr-2

Conversation

@vitali87

Copy link
Copy Markdown
Owner

Add tree-sitter span selection, module binding, and import-statement narrowing helpers for move.

Files changed

  • codebase_rag/editing/move_scope.py

Diff stats

+185 additions, -0 deletions (185 LOC)

Dependencies

This PR depends on: pr-1

Dependency graph

Merge in this order:

pr-1: feat: add move public API and constants
├── pr-2: feat: parse move spans and imports  <-- this PR
│   └── pr-5: feat: retarget move imports safely
│       └── pr-6: feat: implement move refactoring operation
│           ├── pr-7: feat: expose move through CLI
│           │   └── pr-11: docs: document move operation
│           ├── pr-8: feat: expose move through MCP
│           │   ├── pr-10: test: cover move MCP wiring
│           │   └── pr-11: docs: document move operation
│           └── pr-9: test: cover move operation behavior
│               └── pr-10: test: cover move MCP wiring
├── pr-3: feat: expose import graph sites for move
│   └── pr-5: feat: retarget move imports safely
│       └── pr-6: feat: implement move refactoring operation
│           ├── pr-7: feat: expose move through CLI
│           │   └── pr-11: docs: document move operation
│           ├── pr-8: feat: expose move through MCP
│           │   ├── pr-10: test: cover move MCP wiring
│           │   └── pr-11: docs: document move operation
│           └── pr-9: test: cover move operation behavior
│               └── pr-10: test: cover move MCP wiring
└── pr-4: feat: detect move contract deltas
    └── pr-5: feat: retarget move imports safely
        └── pr-6: feat: implement move refactoring operation
            ├── pr-7: feat: expose move through CLI
            │   └── pr-11: docs: document move operation
            ├── pr-8: feat: expose move through MCP
            │   ├── pr-10: test: cover move MCP wiring
            │   └── pr-11: docs: document move operation
            └── pr-9: test: cover move operation behavior
                └── pr-10: test: cover move MCP wiring

@coderabbitai

coderabbitai Bot commented Sep 20, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 25 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 8 included reviews currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Repository: vitali87/code-graph-rag/.coderabbit.yaml

Review profile: CHILL

Plan: Advanced

Run ID: 533e9b55-e567-4c1b-abcf-bbdb3c9370a4

📥 Commits

Reviewing files that changed from the base of the PR and between 065f512 and f57c632.

📒 Files selected for processing (1)
  • codebase_rag/editing/move_scope.py

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@vitali87
vitali87 added this pull request to stack #2083 September 20, 2026 22:57
@greptile-apps

greptile-apps Bot commented Sep 20, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 2/5

Not safe to merge until the reproduced import-preservation failures are fixed.

Findings

  1. P1 Nested imports omit bindings ▶
  2. P1 Relative imports target destination ▶
  3. P1 Default mixed imports disappear ▶
  4. P2 Plain imports are not narrowed ▶

Summary

This change adds move-planning helpers for selecting definitions and narrowing imports. It currently produces broken moved code in several common cases: nested Python imports can suppress a needed top-level import, relative Python imports can resolve to a different package after relocation, and mixed JavaScript imports can lose their default binding. These failures must be fixed before merging. Plain multi-entry Python imports are also copied without narrowing; that concern is non-blocking.

Reviews (1) · Last reviewed commit: "feat: parse move spans and imports"

Comment on lines +62 to +68
for node in ast.walk(tree):
if not isinstance(node, ast.Import):
continue
for alias in node.names:
# `import x as y` binds only `y`, never x's root.
if alias.asname is None and alias.name.split(".")[: len(wanted)] == wanted:
return True

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.

P1 Nested imports omit bindings

_module_bound walks every AST scope, so an unaliased import inside a function, class, or conditional is treated as a module-level binding. Move planning can then skip adding the required top-level import even though rewritten module-level references cannot access that nested binding. The moved code subsequently fails with NameError when it evaluates the rewritten reference.

Artifacts

Evidence from the check

  • Self-contained harness that AST-extracts and executes the exact changed helper, then runs the broken and repaired rewritten-consumer paths; it provides a reproducible proof of the finding.

Command output from the check

  • Executed broken planning scenario showing all three nested scopes return True, the planner does not add an import, and the rewritten consumer raises NameError; the claim holds.

Command output from the check

  • Executed counterpart with the required module-level import, where the same rewritten consumer completes successfully; the missing top-level binding is the failure cause.

Command output from the check

  • Shows `_module_bound` is imported by the move entry module and records that the requested virtualenv interpreter was absent; the helper is reachable from move planning.

Command output from the check

  • Captured combined command output for compilation plus both executed scenarios; the broken path fails at runtime and the repaired path succeeds.

View artifacts

T-Rex Ran code and verified through T-Rex

Comment on lines +174 to +182
if parsed := _match_py_from(statement):
# main replaced the _PY_FROM regex with a token parser returning
# (lead, module, mid, names); only those two fields are needed here.
_lead, module, _mid, raw_names = parsed
entries, _open, _close = _split_names(raw_names)
kept = [e for e in entries if _local_name(e) == alias]
if not kept:
return None
return f"from {module} import {kept[0]}"

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.

P1 Relative imports target destination

When moved code crosses Python package boundaries, this branch copies its relative import spelling unchanged. For example, moving from .alpha import value from app.src to app.dst makes Python resolve app.dst.alpha rather than the original app.src.alpha, so the moved definition can import the wrong dependency or fail when the destination module does not exist.

Artifacts

Evidence from the check

  • Authored and executed script that loads the checked-out helper and imports temporary source and destination packages, demonstrating the relocation semantics.

Command output from the check

  • Executed control run uses `from ..src.alpha import value` after relocation and imports the source-package sentinel successfully, establishing the expected behavior.

Error trace from the failing run

  • Executed candidate run shows the helper preserving `from .alpha import value`, importing the destination-package sentinel, and failing the source-package assertion.

View artifacts

T-Rex Ran code and verified through T-Rex

Comment on lines +164 to +170
named = _JS_NAMED.search(text)
if named is None:
return text.strip()
entries = [e.strip() for e in named.group("names").split(",") if e.strip()]
kept = [e for e in entries if _local_name(e) == alias]
if not kept:
return None

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.

P1 Default mixed imports disappear

When a moved definition needs client from import client, { helper } from "./api", this code searches only the named-import group and returns None. The destination therefore receives no import for the required default binding, leaving the moved JavaScript or TypeScript definition unresolved.

Artifacts

Evidence from the check

  • This authored harness loads the exact on-disk helper and invokes it for `client` in a mixed import, providing the executable reproduction.

Command output from the check

  • The captured command output shows the exact helper path, mixed import input, selected `client` alias, and `result=None`, proving the binding is dropped.

Evidence from the check

  • This authored control harness invokes the same exact helper for the named `helper` binding in the same mixed import.

Command output from the check

  • The captured control output returns a rewritten import for `helper`, isolating the defect to selecting the default binding.

View artifacts

T-Rex Ran code and verified through T-Rex

Comment on lines +183 to +185
if _PY_IMPORT.match(statement):
return statement.strip()
return statement.strip()

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.

P2 Plain imports are not narrowed

Non-blocking concern: selecting a from import a, b returns the entire statement instead of retaining only a. The destination unnecessarily imports b, which can add avoidable import-time work or bindings and defeats the helper's intended import reduction.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Artifacts

Evidence from the check

  • Authored harness loads the exact checked-out helper and compares its control and candidate behavior, proving the candidate path is exercised.

Command output from the check

  • Ran the harness control case against the exact helper; it narrowed `from pkg import a, b` to `from pkg import a`, establishing the intended comparison behavior.

Error trace from the failing run

  • Ran the harness candidate case against the exact helper; it returned `import a, b` instead of `import a` and raised the expected assertion, proving the defect.

Command output from the check

  • Attempted the required repository virtual-environment interpreter; its dangling target caused a not-found error, so the exact-source harness used the available interpreter instead.

View artifacts

T-Rex Ran code and verified through T-Rex

@greptile-apps

greptile-apps Bot commented Sep 20, 2026

Copy link
Copy Markdown
Contributor

Comments Outside Diff

These findings sit on lines the diff does not cover, so they could not be posted inline. Each one leaves this list once its file changes.

  • P1 Nested imports are mistaken for module bindings during move planning ▶

    • Bug
      • _module_bound returns True for import pkg.new inside a function, class, or conditional. The move path imports this helper from move.py; when rewritten call sites need pkg.new.helper(...), that True can suppress the required module-level import. The rewritten consumer then fails at runtime because pkg was only local to the nested scope.
    • Cause
      • ast.walk(tree) visits every ast.Import without limiting the search to module-body statements. The helper therefore conflates lexical imports with names bound at module scope.
    • Fix
      • Inspect only top-level ast.Import statements (for example, iterate tree.body), or otherwise track scope and accept imports only when they bind the module namespace at module level. Add regression coverage for function-, class-, and conditional-nested imports.
  • P1 Python relative imports are copied without being respelled for the destination package ▶

    • Bug
      • When a definition using from .alpha import value is moved from app/src/worker.py to app/dst/worker.py, _narrow_statement returns the unchanged statement. At the destination, Python resolves it to app.dst.alpha; the original dependency was app.src.alpha. The executed reproduction observed the destination sentinel instead of the source sentinel.
    • Cause
      • The Python branch at codebase_rag/editing/move_scope.py:174-182 parses and narrows the imported name but does not use old_path or new_path to translate relative import level/module semantics. Unlike the JS branch immediately above, it has no source-to-destination rewriting step.
    • Fix
      • For parsed Python from imports that are relative, resolve the original target module using the source module/package path and emit an equivalent import relative to the destination package (or emit a valid absolute project import). Preserve the import level and module only when source and destination package resolution makes that spelling equivalent. Add relocation tests across sibling and parent package boundaries.
  • P1 Mixed JavaScript imports cannot be narrowed to their default binding ▶

    • Bug
      • _narrow_statement returns None when asked to retain client from import client, { helper } from './api'. A move that needs this import cannot copy the required default binding into the destination.
    • Cause
      • After locating the named-import braces, lines 167-170 only compare the requested alias against entries inside { ... }. The default clause (client) is outside that match and is therefore treated as absent.
    • Fix
      • Parse and preserve the default import clause separately. When alias equals the default binding, emit a valid default-only import (for example import client from '../api'); when retaining a named binding, remove an unrelated default clause if the helper is intended to narrow to one binding.
  • P2 Plain Python multi-imports are copied without narrowing ▶

    • Bug
      • When a moved scope needs only alias a from import a, b, _narrow_statement returns import a, b. The copied destination import therefore includes unrelated binding b, contrary to the helper's stated reduced-to-entry-binding behavior.
    • Cause
      • The _PY_IMPORT regex accepts only one module (optionally aliased), so import a, b does not match. It falls through to the final unconditional return statement.strip() instead of parsing comma-separated import entries and selecting the requested alias.
    • Fix
      • Parse plain Python import lists (including aliases), select entries whose local name equals alias, and return only the selected entry or None when no entry binds the alias. Add direct regression coverage for import a, b and alias cases such as import a as target, b.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot review overview

🟡 Changes recommended

Critical and moderate import-handling issues must be addressed before approval.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 3 High severity · 3 Medium severity

Open (6)
What changed in this PR

Adds parsing and import-narrowing helpers for the move refactoring workflow.

Changes:

  • Selects definitions and cut spans with tree-sitter.
  • Adds module binding and import insertion-point helpers.
  • Narrows Python, JavaScript, and TypeScript imports.
File Summary Status
codebase_rag/​editing/​move_scope.py Implements move span, binding, and import helpers. Unresolved critical and moderate findings remain.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.


def _import_block_end(source: bytes, root: Node) -> int:
"""Byte offset just after the last top-level import (0 when none)."""
end = 0
kept = [e for e in entries if _local_name(e) == alias]
if not kept:
return None
return f"from {module} import {kept[0]}"
Comment on lines +183 to +185
if _PY_IMPORT.match(statement):
return statement.strip()
return statement.strip()
from .move_types import _Cut

_IDENTIFIER = r"(?<![\w.])%s(?!\w)"
_JS_LANGUAGES = frozenset({cs.SupportedLanguage.JS, cs.SupportedLanguage.TS})
if named is None:
return text.strip()
entries = [e.strip() for e in named.group("names").split(",") if e.strip()]
kept = [e for e in entries if _local_name(e) == alias]
Comment on lines +165 to +169
if named is None:
return text.strip()
entries = [e.strip() for e in named.group("names").split(",") if e.strip()]
kept = [e for e in entries if _local_name(e) == alias]
if not kept:

vitali87 commented Oct 9, 2026

Copy link
Copy Markdown
Owner Author

Closing as superseded: this is part of the split of #1549, which was closed without merging, and it targets the stale feat/change-signature branch rather than main. The change_signature base was rebuilt on main as #1912 (now stacked as #2161–#2169). The branch is kept, so the move-op work can be rebased onto main if it's picked up again.


Generated by Claude Code

@vitali87 vitali87 closed this Oct 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants