Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
fddc17c
test(signature): acceptance, transaction and MCP tests for change_sig…
vitali87 Sep 22, 2026
4d5f2a9
test(signature): mapping tests for change_signature (#1533, 7/9)
vitali87 Sep 22, 2026
0305c5c
test(signature): definition and renamed-parameter tests for change_si…
vitali87 Sep 22, 2026
3b31d9d
test(signature): hierarchy, receiver and default guards for change_si…
vitali87 Sep 22, 2026
26a4ddb
feat(move): move edit operation, rebuilt on the change_signature stac…
vitali87 Oct 3, 2026
738df69
fix(move): keep a move that leaves its old module holding only constants
vitali87 Oct 3, 2026
084e527
fix(move): refuse when a file changed between planning and staging
vitali87 Oct 4, 2026
427ed41
fix(move): keep imports and constants bound where the module bound them
vitali87 Oct 4, 2026
7738ae5
fix(move): refuse when a copied import would rebind a destination name
vitali87 Oct 4, 2026
1d3b7de
fix(move): keep JavaScript exports working at both paths
vitali87 Oct 4, 2026
b541051
fix(move): cut only the definition when other code shares its lines
vitali87 Oct 4, 2026
38b6b7a
fix(move): carry TYPE_CHECKING-only imports under the guard
vitali87 Oct 4, 2026
9342e98
fix(move): remove only the export statement when code shares its line
vitali87 Oct 4, 2026
04daff1
fix(move): respell relative imports inside the moved definition
vitali87 Oct 4, 2026
bc8981b
fix(move): refuse when the move would split module state in two
vitali87 Oct 4, 2026
660e820
fix(move): refuse when a wildcard importer would lose the moved name
vitali87 Oct 4, 2026
271438f
test(move): split assertions and single-call raises blocks SonarCloud…
vitali87 Oct 7, 2026
4ed4b48
refactor(move): extract planning phases and helpers to keep move.py u…
vitali87 Oct 7, 2026
6ce93a8
fix(move): refuse star-provided names and annotation-future mismatche…
vitali87 Oct 12, 2026
a552de2
fix(move): keep postponed annotations for exec'd code; flag a from-im…
vitali87 Oct 12, 2026
640e946
fix(move): only builtins.compile counts as dynamic code, not re.compile
vitali87 Oct 12, 2026
b9f403b
fix(move): resolve builtins aliases when detecting dynamic code
vitali87 Oct 12, 2026
9d9ed51
build(deps): bump pydantic-ai-slim to 2.53.0 (GHSA-6fqq-452j-qhrp, GH…
vitali87 Oct 12, 2026
8301041
fix(move): restore structural_delta.import_cycles, which move calls
vitali87 Oct 15, 2026
eda1e3c
build: keep the base's uv.lock, since move changes no dependency
vitali87 Oct 15, 2026
11ec792
fix(move): find a module-qualified call whose site starts at its name
claude Oct 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions codebase_rag/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2317,6 +2317,75 @@ def rename_command(
raise typer.Exit(code=1)


@app.command(
name=ch.CLICommandName.MOVE,
help=ch.CMD_MOVE,
short_help=ch.CMD_MOVE,
epilog=ch.EXAMPLES_MOVE,
rich_help_panel=ch.PANEL_USE,
)
def move_command(
qualified_name: str = typer.Argument(..., help=ch.HELP_MOVE_QN),
target_module: str = typer.Argument(..., help=ch.HELP_MOVE_TARGET),
repo_path: Path = typer.Option(
Path(cs.MCP_DEFAULT_DIRECTORY),
"--repo-path",
exists=True,
file_okay=False,
help=ch.HELP_GRAPH_REPO_PATH,
),
project: str | None = typer.Option(None, "--project", help=ch.HELP_GRAPH_PROJECT),
keep_alias: bool = typer.Option(
False, "--keep-alias", help=ch.HELP_MOVE_KEEP_ALIAS
),
dry_run: bool = typer.Option(False, "--dry-run", help=ch.HELP_MOVE_DRY_RUN),
) -> None:
from .editing.move import MoveRefused, move
from .graph_cli import _project_and_fetch
from .graph_query import source_root_for

name, fetch_all, ingestor = _project_and_fetch(project, repo_path)
with ingestor:
# An explicit --project may name a graph indexed from another
# checkout; its repo-relative paths must not be edited under this one.
if source_root_for(fetch_all, name, repo_path) is None:
typer.echo(
cs.MOVE_CLI_WRONG_ROOT.format(project=name, root=repo_path.resolve()),
err=True,
)
raise typer.Exit(code=1)
parsers, queries = load_parsers()
from .graph_updater import GraphUpdater

updater = GraphUpdater(
ingestor=ingestor,
repo_path=repo_path.resolve(),
parsers=parsers,
queries=queries,
project_name=name,
)
try:
report = move(
repo_path.resolve(),
fetch_all,
name,
qualified_name,
target_module,
keep_alias=keep_alias,
dry_run=dry_run,
reingest=updater.reingest,
Comment thread
vitali87 marked this conversation as resolved.
)
except MoveRefused as refused:
typer.echo(str(refused), err=True)
raise typer.Exit(code=1) from refused
payload = dict(report._asdict())
if report.verdict is not None:
payload[cs.KEY_VERDICT] = report.verdict._asdict()
typer.echo(json.dumps(payload, indent=cs.MCP_JSON_INDENT, sort_keys=True))
if not report.applied and not dry_run:
raise typer.Exit(code=1)


@app.command(
name=ch.CLICommandName.CHANGE_SIGNATURE,
help=ch.CMD_CHANGE_SIGNATURE,
Expand Down
18 changes: 18 additions & 0 deletions codebase_rag/cli_help.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ class CLICommandName(StrEnum):
CHECK = "check"
RENAME = "rename"
CHANGE_SIGNATURE = "change-signature"
MOVE = "move"
CONTEXT = "context"
PORT = "port"
STOP = "stop"
Expand Down Expand Up @@ -223,6 +224,22 @@ class CLICommandName(StrEnum):
"a trace as well, instead of listing them as unmapped."
)
HELP_CHANGE_SIGNATURE_DRY_RUN = "Print the plan and diff without writing anything."
CMD_MOVE = (
"Move a module-level definition to another module: importers rewritten, "
"needed imports carried, refused when it would create an import cycle."
)
EXAMPLES_MOVE = (
"Examples:\n cgr move myproj.pkg.util.helper pkg.core --dry-run\n"
" cgr move myproj.pkg.util.helper pkg/core.py --keep-alias"
)
HELP_MOVE_QN = "Qualified name of the module-level definition to move."
HELP_MOVE_DRY_RUN = (
"Print the plan (files, importers, copied imports) without writing anything."
)
HELP_MOVE_TARGET = "Destination module: dotted name (pkg.core) or repo-relative path."
HELP_MOVE_KEEP_ALIAS = (
"Leave a re-export at the old location so the old import path still works."
)
CMD_CONTEXT = (
"Print a graph-ranked context slice for a symbol, location or task within "
"a token budget: source, caller lines, callee signatures, types, tests, docs."
Expand Down Expand Up @@ -742,6 +759,7 @@ class CLICommandName(StrEnum):
CLICommandName.CHECK: CMD_CHECK,
CLICommandName.RENAME: CMD_RENAME,
CLICommandName.CHANGE_SIGNATURE: CMD_CHANGE_SIGNATURE,
CLICommandName.MOVE: CMD_MOVE,
CLICommandName.CONTEXT: CMD_CONTEXT,
CLICommandName.PORT: CMD_PORT,
CLICommandName.WORKSPACE: CMD_WORKSPACE,
Expand Down
1 change: 1 addition & 0 deletions codebase_rag/constants/ast_nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@
FIELD_PROPERTY = "property"
FIELD_NAME = "name"
FIELD_ALIAS = "alias"
FIELD_SOURCE = "source"
FIELD_MODULE_NAME = "module_name"
FIELD_ARGUMENTS = "arguments"
FIELD_BODY = "body"
Expand Down
66 changes: 66 additions & 0 deletions codebase_rag/constants/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1112,6 +1112,72 @@ class DiffMarker:
CONTEXT_WHY_TEST = "test reaching it at depth {depth} through {through}"
CONTEXT_WHY_DOC = "documentation section whose file links to it"
CONTEXT_UNRESOLVED = "Nothing in the graph matches {target}"
# move (issue #1534).
MOVE_UNKNOWN = "No definition named {qn} in the graph"
MOVE_METHOD = "{qn} is a method; move its class instead"
MOVE_SAME_MODULE = "{path} already holds the definition"
MOVE_NO_GRAMMAR = "No grammar for {path}; the definition cannot be moved"
MOVE_NO_DEFINITION_TOKEN = "Could not locate the definition of {qn} in {path}"
MOVE_CYCLE = "Refusing to move: it would create the import cycle {cycle}"
MOVE_IMPORT_COLLISION = (
"{path} already binds {name}; the import the moved definition needs would rebind it"
)
MOVE_MAYBE_UNBOUND = (
"{names} may be unbound in {path}: it is set under a module-level if, "
"try or loop that does not bind it on every path, so the moved "
"definition cannot import it safely"
)
MOVE_SPLIT_GLOBAL = (
"{names} is a variable of {path} rebound through a global statement; "
"the destination would import it as a separate copy, so the moved "
"definition and {path} would stop seeing each other's updates"
)
MOVE_ASSIGNS_IMPORT = (
"{names} is a variable of {path} that the moved definition assigns; "
"imported into the destination it is a read-only binding, so the "
"assignment would throw"
)
MOVE_WILDCARD_IMPORTER = (
"{importers} import(s) everything from {path} with *, and would no "
"longer get {name} from it; pass keep_alias to keep {path} exporting it"
)
MOVE_STAR_PROVIDED = (
"{names} reaches {path} only through a star import from {modules}; "
"the destination would not bind it, so the moved definition would "
"raise NameError; import it by name in {path} first"
)
MOVE_FUTURE_ANNOTATIONS = (
"{enabled} enables from __future__ import annotations and {disabled} "
"does not; the move would change how the annotations in {changed} "
"evaluate"
)
MOVE_RELATIVE_IMPORT_ESCAPES = (
"{statement} in {qn} climbs above the top-level package of {path}, so "
"the move cannot respell it for the destination"
)
MOVE_PLANNED = "{importers} importer(s) would be rewritten, {unchanged} left unchanged"
MOVE_PARSE_FAILED = "Move rolled back: {files} would no longer parse"
MOVE_SOURCE_CHANGED = (
"Cannot stage the move: {files} changed on disk after the move was "
"planned; retry once the tree is settled"
)
MOVE_CONTRACT_FAILED = "Move rolled back, postcondition failed: {reasons}"
MOVE_CONTRACT_UNMEASURED = (
"Move applied, but its postcondition could not be measured: {error}"
)
MOVE_ROLLBACK_UNMEASURED = (
"Move rolled back after its postcondition failed ({reasons}), but the "
"graph could not be re-ingested afterwards ({error}); rebuild the graph "
"before the next graph-backed operation"
)
MOVE_WRONG_ROOT = (
"Project {project} was not indexed from this server's repository; "
"move from the MCP server rooted at its source tree"
)
MOVE_CLI_WRONG_ROOT = (
"Project {project} was not indexed from {root}; "
"pass the --repo-path it was indexed from"
)
# Postcondition contract (issue #1531).
CONTRACT_OP_RENAME = "rename"
CONTRACT_OP_CHANGE_SIGNATURE = "change_signature"
Expand Down
1 change: 1 addition & 0 deletions codebase_rag/constants/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ class (and, uncertain, other calls in files naming the class or
KEY_UNMAPPED = "unmapped"
KEY_VERDICT = "verdict"
KEY_STRUCTURAL_DELTA = "structural_delta"
KEY_CYCLE = "cycle"
KEY_DISPATCH_LITERAL = "dispatch_literal"


Expand Down
3 changes: 3 additions & 0 deletions codebase_rag/constants/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ class MCPToolName(StrEnum):
# Graph-driven edit operations (issues #1532, #1533).
RENAME = "rename"
CHANGE_SIGNATURE = "change_signature"
MOVE = "move"
CONTEXT = "context"
QUERY_CODE_GRAPH = "query_code_graph"
GET_CODE_SNIPPET = "get_code_snippet"
Expand Down Expand Up @@ -126,6 +127,8 @@ class MCPParamName(StrEnum):
ALLOW_HEURISTIC = "allow_heuristic"
NEW_PARAMS = "new_params"
MAPPING = "mapping"
TARGET_MODULE = "target_module"
KEEP_ALIAS = "keep_alias"
BUDGET_TOKENS = "budget_tokens"


Expand Down
10 changes: 8 additions & 2 deletions codebase_rag/cypher_queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -1633,7 +1633,7 @@ def build_missing_rel_endpoints_query(
RETURN labels(n)[0] AS label, n.qualified_name AS qualified_name, n.name AS name,
n.path AS path, n.start_line AS start_line, n.end_line AS end_line,
n.decorators AS decorators"""
CYPHER_DELTA_MODULE_IMPORTS = """MATCH (m:Module)-[:IMPORTS]->(t:Module)
CYPHER_DELTA_MODULE_IMPORTS = """MATCH (m:Module)-[r:IMPORTS]->(t:Module)
WHERE m.qualified_name STARTS WITH $project_prefix
AND t.qualified_name STARTS WITH $project_prefix
AND ALL(longer_project IN $longer_project_prefixes
Expand All @@ -1643,7 +1643,7 @@ def build_missing_rel_endpoints_query(
WHERE t.qualified_name <> longer_project
AND NOT t.qualified_name STARTS WITH (longer_project + '.'))
RETURN DISTINCT m.qualified_name AS from_qn, m.path AS from_path,
t.qualified_name AS to_qn"""
t.qualified_name AS to_qn, r.imported_name AS imported_name"""
# The import statements that bind a symbol BY NAME into or out of the
# touched files (issue #2516), one row per bound name: an importer of a
# touched module still naming a symbol the edit removed, and a touched
Expand Down Expand Up @@ -1707,6 +1707,12 @@ def build_missing_rel_endpoints_query(
MATCH (doc)-[:CONTAINS_SECTION]->(s:Section)
RETURN doc.qualified_name AS from_qn, s.qualified_name AS qualified_name, s.name AS name,
s.path AS path, s.start_line AS start_line, s.end_line AS end_line"""
# What one module imports, with each statement's site (issue #1534).
CYPHER_GRAPH_IMPORTS_OF = """MATCH (m:Module)-[r:IMPORTS]->(target)
WHERE m.qualified_name = $qn AND m.qualified_name STARTS WITH $project_prefix
RETURN target.qualified_name AS to_qn, r.line AS line, r.col AS col,
r.end_line AS end_line, r.end_col AS end_col, r.alias AS alias,
r.imported_name AS imported_name"""
CYPHER_GRAPH_IMPORTERS = """MATCH (m:Module)-[r:IMPORTS]->(target)
WHERE target.qualified_name = $qn AND m.qualified_name STARTS WITH $project_prefix
RETURN m.qualified_name AS qualified_name, m.path AS path, r.line AS line,
Expand Down
4 changes: 4 additions & 0 deletions codebase_rag/editing/contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -382,13 +382,16 @@ def measure(
files: Iterable[str],
reingest: Reingest,
declared_renames: Iterable[tuple[str, str]] = (),
reshaped_renames: Iterable[tuple[str, str]] = (),
) -> StructuralDelta:
"""The delta of files an operation just wrote, through the re-ingest.

`declared_renames` are the pairs the operation APPLIED. The delta infers
renames from the snapshots, which cannot be done for an empty container
(no fingerprint, no members, and the name is what changed), so an
operation that knows says so rather than leaving it to a guess.
`reshaped_renames` are those whose body the operation also rewrote, so
the snapshots cannot pair them by shape either.
"""
paths = sorted(set(files))
return observe(
Expand All @@ -398,4 +401,5 @@ def measure(
lambda: reingest(paths),
repo_root=repo_root,
declared_renames=frozenset(declared_renames),
reshaped_renames=frozenset(reshaped_renames),
)
Loading
Loading