From d044ccb2e092845c5bee8e10874c662e2470cfaf Mon Sep 17 00:00:00 2001 From: Ed_ Date: Wed, 13 May 2026 21:42:19 -0400 Subject: [PATCH] feat(python-tools): Implement core logic for structural MCP tools --- .../plan.md | 12 +- scripts/py_struct_tools.py | 182 ++++++++++++++++++ 2 files changed, 188 insertions(+), 6 deletions(-) create mode 100644 scripts/py_struct_tools.py diff --git a/conductor/tracks/python_structural_mcp_tools_20260513/plan.md b/conductor/tracks/python_structural_mcp_tools_20260513/plan.md index dd30cef..30b53cf 100644 --- a/conductor/tracks/python_structural_mcp_tools_20260513/plan.md +++ b/conductor/tracks/python_structural_mcp_tools_20260513/plan.md @@ -1,12 +1,12 @@ # Implementation Plan: Python Structural MCP Tools ## Phase 1: Core Logic Implementation -- [ ] Task: Create `scripts/py_struct_tools.py` to house shared AST and regex manipulation logic. -- [ ] Task: Implement the "Surgical Indentation Shifter" utility to enforce 1-space indentation context without destructive full-file formatting. -- [ ] Task: Implement `py_remove_def` core logic. -- [ ] Task: Implement `py_add_def` core logic. -- [ ] Task: Implement `py_move_def` core logic. -- [ ] Task: Implement `py_region_wrap` core logic. +- [x] Task: Create `scripts/py_struct_tools.py` to house shared AST and regex manipulation logic. [fa82c1a] +- [x] Task: Implement the "Surgical Indentation Shifter" utility to enforce 1-space indentation context without destructive full-file formatting. [fa82c1a] +- [x] Task: Implement `py_remove_def` core logic. [fa82c1a] +- [x] Task: Implement `py_add_def` core logic. [fa82c1a] +- [x] Task: Implement `py_move_def` core logic. [fa82c1a] +- [x] Task: Implement `py_region_wrap` core logic. [fa82c1a] - [ ] Task: Conductor - User Manual Verification 'Phase 1: Core Logic Implementation' (Protocol in workflow.md) ## Phase 2: MCP Client Integration diff --git a/scripts/py_struct_tools.py b/scripts/py_struct_tools.py new file mode 100644 index 0000000..caa9ac1 --- /dev/null +++ b/scripts/py_struct_tools.py @@ -0,0 +1,182 @@ +import ast +import os +import sys +from pathlib import Path + +def find_definition_range(source: str, symbol_path: str) -> tuple[int, int] | None: + """ + Finds the start and end line numbers for a given symbol path (e.g., 'ClassName', 'ClassName.method', 'function_name'). + [C: scripts/py_struct_tools.py:py_remove_def, scripts/py_struct_tools.py:py_move_def] + """ + try: + tree = ast.parse(source) + except SyntaxError: + return None + parts = symbol_path.split('.') + current_nodes = tree.body + target_node = None + for i, part in enumerate(parts): + found = False + for node in current_nodes: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) and node.name == part: + if i == len(parts) - 1: + target_node = node + found = True + break + else: + if isinstance(node, ast.ClassDef): + current_nodes = node.body + found = True + break + if not found: + return None + if target_node: + start = target_node.lineno + if target_node.decorator_list: + start = target_node.decorator_list[0].lineno + return (start, target_node.end_lineno) + return None + +def shift_indentation(content: str, target_depth: int) -> str: + """ + Shifts the indentation of a code block to the target depth using 1-space units. + [C: scripts/py_struct_tools.py:py_add_def] + """ + lines = content.splitlines() + if not lines: + return "" + min_indent = sys.maxsize + for line in lines: + if line.strip(): + indent = len(line) - len(line.lstrip()) + if indent < min_indent: + min_indent = indent + if min_indent == sys.maxsize: + min_indent = 0 + shifted_lines = [] + for line in lines: + if line.strip(): + shifted_lines.append(" " * target_depth + line[min_indent:]) + else: + shifted_lines.append("") + return "\n".join(shifted_lines) + ("\n" if content.endswith("\n") else "") + +def py_remove_def(filepath: str, symbol_path: str) -> str: + """ + Removes a definition from a file. Returns error message or empty string on success. + """ + if not os.path.exists(filepath): + return f"File not found: {filepath}" + with open(filepath, 'r', encoding='utf-8') as f: + content = f.read() + range_info = find_definition_range(content, symbol_path) + if not range_info: + return f"Symbol '{symbol_path}' not found in {filepath}" + start, end = range_info + lines = content.splitlines(True) + del lines[start-1:end] + with open(filepath, 'w', encoding='utf-8', newline='') as f: + f.writelines(lines) + return "" + +def py_add_def(filepath: str, symbol_path: str, new_content: str, anchor_type: str, anchor_symbol: str = None) -> str: + """ + Adds a definition to a file. + anchor_type: 'before', 'after', 'top', 'bottom' + anchor_symbol: if 'before' or 'after', the symbol to anchor to. + symbol_path: context path (e.g. 'ClassName' or '' for module level) + """ + if not os.path.exists(filepath): + return f"File not found: {filepath}" + with open(filepath, 'r', encoding='utf-8') as f: + content = f.read() + lines = content.splitlines(True) + target_depth = 0 + insert_idx = -1 + if symbol_path: + range_info = find_definition_range(content, symbol_path) + if not range_info: + return f"Context symbol '{symbol_path}' not found" + c_start, c_end = range_info + target_depth = 1 + if anchor_type == 'top': + insert_idx = c_start + for i in range(c_start, c_end): + if '"""' in lines[i] or "'''" in lines[i]: + for j in range(i, c_end): + if lines[j].count('"""') + lines[j].count("'''") == 2 or (j > i and ('"""' in lines[j] or "'''" in lines[j])): + insert_idx = j + 1 + break + break + elif anchor_type == 'bottom': + insert_idx = c_end + else: + if anchor_type == 'top': + insert_idx = 0 + elif anchor_type == 'bottom': + insert_idx = len(lines) + if anchor_type in ('before', 'after') and anchor_symbol: + full_anchor_path = f"{symbol_path}.{anchor_symbol}" if symbol_path else anchor_symbol + range_info = find_definition_range(content, full_anchor_path) + if not range_info: + return f"Anchor symbol '{anchor_symbol}' not found" + a_start, a_end = range_info + insert_idx = a_start - 1 if anchor_type == 'before' else a_end + if insert_idx == -1: + return f"Invalid anchor_type '{anchor_type}'" + formatted_content = shift_indentation(new_content, target_depth) + if not formatted_content.endswith('\n'): + formatted_content += '\n' + # Ensure at least one blank line between definitions + if insert_idx > 0 and lines[insert_idx-1].strip(): + formatted_content = '\n' + formatted_content + if insert_idx < len(lines) and lines[insert_idx].strip(): + formatted_content = formatted_content + '\n' + lines.insert(insert_idx, formatted_content) + with open(filepath, 'w', encoding='utf-8', newline='') as f: + f.writelines(lines) + return "" + +def py_move_def(src_file: str, dest_file: str, symbol_path: str, dest_symbol_path: str, anchor_type: str, anchor_symbol: str = None) -> str: + """ + Moves a definition from one location to another. + """ + if not os.path.exists(src_file): + return f"Source file not found: {src_file}" + with open(src_file, 'r', encoding='utf-8') as f: + src_content = f.read() + range_info = find_definition_range(src_content, symbol_path) + if not range_info: + return f"Symbol '{symbol_path}' not found in {src_file}" + start, end = range_info + src_lines = src_content.splitlines(True) + moved_content = "".join(src_lines[start-1:end]) + err = py_add_def(dest_file, dest_symbol_path, moved_content, anchor_type, anchor_symbol) + if err: + return err + return py_remove_def(src_file, symbol_path) + +def py_region_wrap(filepath: str, start_line: int, end_line: int, region_name: str) -> str: + """ + Wraps a line range in region tags. + """ + if not os.path.exists(filepath): + return f"File not found: {filepath}" + with open(filepath, 'r', encoding='utf-8') as f: + lines = f.readlines() + if start_line < 1 or end_line > len(lines) or start_line > end_line: + return f"Invalid line range: {start_line}-{end_line}" + indent = "" + first_line = lines[start_line-1] + if first_line.strip(): + indent = first_line[:len(first_line) - len(first_line.lstrip())] + # Insert bottom first to avoid shifting top + lines.insert(end_line, f"{indent}#endregion: {region_name}\n") + lines.insert(start_line-1, f"{indent}#region: {region_name}\n") + with open(filepath, 'w', encoding='utf-8', newline='') as f: + f.writelines(lines) + return "" + +if __name__ == "__main__": + # Simple CLI for internal use + pass