diff --git a/codebase_rag/mcp/tools.py b/codebase_rag/mcp/tools.py index 5889bf6a4b..305b7fe136 100644 --- a/codebase_rag/mcp/tools.py +++ b/codebase_rag/mcp/tools.py @@ -125,10 +125,10 @@ def _read_file_slice(full_path: Path, start: int, limit: int | None) -> str: # result to the postcondition contract, so refusing up front on a # partial graph would block the operation that repairs it. cs.MCPToolName.CHANGE_SIGNATURE, - # Same shape again: `move` is a graph-driven EDIT. The enum member - # lands on this layer ahead of its MCP handler, so nothing dispatches - # it yet; classifying it here keeps the fail-closed inventory honest - # rather than leaving it to read as an unguarded reader. + # An EDIT like RENAME and CHANGE_SIGNATURE above: it runs its own + # re-ingest behind the incomplete-run marker and holds the result to + # the postcondition contract, so refusing up front on a partial graph + # would block the operation that repairs it (issue #1534). cs.MCPToolName.MOVE, cs.MCPToolName.SURGICAL_REPLACE_CODE, cs.MCPToolName.READ_FILE, @@ -497,6 +497,7 @@ def __init__( ), cs.MCPToolName.RENAME: self._rename_tool(), cs.MCPToolName.CHANGE_SIGNATURE: self._change_signature_tool(), + cs.MCPToolName.MOVE: self._move_tool(), cs.MCPToolName.QUERY_CODE_GRAPH: ToolMetadata( name=cs.MCPToolName.QUERY_CODE_GRAPH, description=td.MCP_TOOLS[cs.MCPToolName.QUERY_CODE_GRAPH], @@ -2667,10 +2668,89 @@ def _run_change_signature( payload[cs.KEY_VERDICT] = report.verdict._asdict() return payload + def _move_tool(self) -> ToolMetadata: + def prop(kind: cs.MCPSchemaType, description: str) -> MCPInputSchemaProperty: + return MCPInputSchemaProperty(type=kind, description=description) + + return ToolMetadata( + name=cs.MCPToolName.MOVE, + description=td.MCP_TOOLS[cs.MCPToolName.MOVE], + input_schema=MCPInputSchema( + type=cs.MCPSchemaType.OBJECT, + properties={ + cs.MCPParamName.QUALIFIED_NAME: prop( + cs.MCPSchemaType.STRING, td.MCP_PARAM_QUALIFIED_NAME + ), + cs.MCPParamName.TARGET_MODULE: prop( + cs.MCPSchemaType.STRING, td.MCP_PARAM_TARGET_MODULE + ), + cs.MCPParamName.KEEP_ALIAS: prop( + cs.MCPSchemaType.BOOLEAN, td.MCP_PARAM_KEEP_ALIAS + ), + cs.MCPParamName.DRY_RUN: prop( + cs.MCPSchemaType.BOOLEAN, td.MCP_PARAM_RENAME_DRY_RUN + ), + cs.MCPParamName.PROJECT: prop( + cs.MCPSchemaType.STRING, td.MCP_PARAM_PROJECT + ), + }, + required=[ + cs.MCPParamName.QUALIFIED_NAME, + cs.MCPParamName.TARGET_MODULE, + ], + ), + handler=self.move, + returns_json=True, + ) + + async def move( + self, + qualified_name: str, + target_module: str, + keep_alias: bool = False, + dry_run: bool = False, + project: str | None = None, + ) -> object: + return await self._graph_query( + cs.MCPToolName.MOVE, + project, + lambda name: self._run_move( + name, qualified_name, target_module, keep_alias, dry_run + ), + ) + + def _run_move( + self, + project_name: str, + qualified_name: str, + target_module: str, + keep_alias: bool, + dry_run: bool, + ) -> object: + from codebase_rag.editing.move import MoveRefused, move + + try: + report = move( + Path(self.project_root), + self.ingestor.fetch_all, + project_name, + qualified_name, + target_module, + keep_alias=keep_alias, + dry_run=dry_run, + reingest=self._reingest_for_contract(project_name), + ) + except MoveRefused as refused: + return {cs.DICT_KEY_ERROR: str(refused), cs.KEY_CYCLE: list(refused.cycle)} + payload = dict(report._asdict()) + if report.verdict is not None: + payload[cs.KEY_VERDICT] = report.verdict._asdict() + return payload + def _reingest_for_contract( self, project_name: str ) -> Callable[[list[str]], ReingestReport] | None: - """The re-ingest an edit's postcondition contract measures against. + """The live updater's re-ingest for an edit's postcondition contract. Delegates to `_guarded_rename_reingest`, which returns None for a project with no graph to measure (issue #1531) and wraps the callback diff --git a/codebase_rag/tools/tool_descriptions.py b/codebase_rag/tools/tool_descriptions.py index 616d50a953..be4193af9b 100644 --- a/codebase_rag/tools/tool_descriptions.py +++ b/codebase_rag/tools/tool_descriptions.py @@ -303,6 +303,24 @@ class AgenticToolName(StrEnum): "one transaction and held to the postcondition contract; set `dry_run` to " "see the plan." ) +MCP_MOVE = ( + "Move a module-level function or class to another module. The definition " + "(decorators, docstring, adjacent comments) is cut and pasted into " + "`target_module` (a dotted module name or a repo-relative path; created " + "when missing) with the imports it needs, every importer is rewritten, " + "uses through a module import (`pkg.util.helper`) follow, and the old " + "module keeps an import of the name when it still uses it. `keep_alias` " + "leaves a re-export at the old location so the old import path keeps " + "working. Refused before any file is touched when the move would create " + "an import cycle, naming the cycle. One transaction, held to the " + "postcondition contract; set `dry_run` to see the plan." +) +MCP_PARAM_TARGET_MODULE = ( + "Destination module: a dotted module name (`pkg.core`) or a repo-relative path." +) +MCP_PARAM_KEEP_ALIAS = ( + "Leave a re-export at the old location so the old import path still works." +) MCP_PARAM_NEW_PARAMS = ( "The new parameter list, in order, as `name[:annotation][=default][@source]` specs." ) @@ -493,6 +511,7 @@ class AgenticToolName(StrEnum): MCPToolName.GLOSSES: MCP_GLOSSES, MCPToolName.RENAME: MCP_RENAME, MCPToolName.CHANGE_SIGNATURE: MCP_CHANGE_SIGNATURE, + MCPToolName.MOVE: MCP_MOVE, MCPToolName.QUERY_CODE_GRAPH: MCP_QUERY_CODE_GRAPH, MCPToolName.GET_CODE_SNIPPET: MCP_GET_CODE_SNIPPET, MCPToolName.SURGICAL_REPLACE_CODE: MCP_SURGICAL_REPLACE_CODE, diff --git a/docs/guide/mcp-server.md b/docs/guide/mcp-server.md index 8dfcfc4bc8..310ecfe60d 100644 --- a/docs/guide/mcp-server.md +++ b/docs/guide/mcp-server.md @@ -76,6 +76,7 @@ cgr daemon up | `glosses` | Notes (Glosses) about one definition: `annotating` are filed on it, `mentioning` are filed on other definitions and refer to it. `target` is resolved as `annotate` resolves it. Each note's `anchor_state` is graded after each sync: EXACT when its definition's content hash still equals the one recorded at writing, STALE when it differs (EXACT again on revert), MOVED when the name is gone and exactly one definition in the project carries the recorded hash (the note follows it; `moved_from` keeps the old name), AMBIGUOUS when several do (`candidate_qns` lists them) and LOST when none does. A note on a class or module, or one written before hashes were recorded, is not graded while attached (there is no hash to compare) and is LOST if its name disappears (there is no hash to follow). A target that no longer resolves returns the error plus `orphaned`: the unattached notes written against that name. Deterministic: fixed graph queries, no LLM, same graph gives the same JSON. Use this instead of query_code_graph whenever you know the exact name or location. | | `rename` | Rename a function, method, class or other definition everywhere the graph knows it is referenced: the definition, every call, reference and instantiation site, import statements (aliases kept), overrides in both directions, and Python `__all__` entries. Refuses when any site was resolved heuristically, by overload fan-out, or only by a trace, unless `allow_heuristic` is true. A class referenced by an inheritance or type annotation edge that carries no rewrite location refuses regardless of `allow_heuristic`. Every rewritten file must still parse; otherwise nothing is written. Set `dry_run` to see the plan and diff without touching the tree. Applied edits are recorded for `cgr edits undo`, and Markdown mentions of the old name are listed for a human to review. | | `change_signature` | Change a function's or method's parameter list and rewrite every call site the graph knows per an explicit mapping. `new_params` lists the new parameters in order as `name[:annotation][=default][@source]`: `@2` takes the value from old positional index 2, `@old` from the old parameter `old`, `=literal` without a source inserts that literal where a site passes nothing (and becomes the definition's default), and a bare name is unmapped: sites passing no value for it are left untouched and listed. Overrides in the hierarchy are rewritten too; sites resolved by guesswork are listed as unmapped unless `allow_heuristic`. A default literal that does not fit the declared type is refused. Applied through one transaction and held to the postcondition contract; set `dry_run` to see the plan. | +| `move` | Move a module-level function or class to another module. The definition (decorators, docstring, adjacent comments) is cut and pasted into `target_module` (a dotted module name or a repo-relative path; created when missing) with the imports it needs, every importer is rewritten, uses through a module import (`pkg.util.helper`) follow, and the old module keeps an import of the name when it still uses it. `keep_alias` leaves a re-export at the old location so the old import path keeps working. Refused before any file is touched when the move would create an import cycle, naming the cycle. One transaction, held to the postcondition contract; set `dry_run` to see the plan. | | `query_code_graph` | Prefer the deterministic tools (resolve, definition, callers, callees, implementors, overrides, importers, tests_reaching) when you know the exact name or location: they run fixed queries with no LLM. Use this for open-ended questions. Query the codebase knowledge graph using natural language. Ask questions like 'What functions call UserService.create_user?' or 'Show me all classes that implement the Repository interface'. Pass `project` to restrict results to one indexed project; use list_projects for the available names. Omit it to search them all. The scope is enforced on the results before they are capped, so it holds regardless of the query generated and the result limit is spent on rows from the requested project. | | `get_code_snippet` | Retrieve source code for a function, class, or method by its qualified name. Returns the source code, file path, line numbers, and docstring. | | `surgical_replace_code` | Surgically replace an exact code block in a file using diff-match-patch. Only modifies the exact target block, leaving the rest unchanged. After a successful write on an indexed project, the touched files are re-ingested and a structural delta is appended to the result as JSON: symbols added, removed and renamed, callers left dangling, call sites passing too many arguments, signature changes with a verdict per call site, new duplicates of existing functions, new import cycles, and the tests reaching the edited symbols. A project that is not indexed appends nothing, and a failed analysis appends an error note instead of a delta. Read it before the next edit. |