From 84922c206791dadb5194ccfc12a1997dae290400 Mon Sep 17 00:00:00 2001 From: JRussas <159085336+JMRussas@users.noreply.github.com> Date: Thu, 5 Mar 2026 22:51:02 -0500 Subject: [PATCH 1/2] Add C# reflection-based decomposition strategy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three-phase feature for function-level C# code generation: Phase 1 - Reflection Scanner: - .NET console app (tools/dotnet-reflector/) using MetadataLoadContext to extract type metadata from assemblies without execution - Python tool wrapper (backend/tools/dotnet_reflection.py) with build, reflect, and format_type_map functions - Registered in ToolRegistry (conditional on dotnet CLI availability) Phase 2 - C# Planner Prompt: - New decomposition_strategy "csharp_reflection" in project config - Planner runs reflection, injects type map into planning prompt - Claude generates method-level tasks with typed contracts (target_signature, available_methods, constructor_params) - Falls back to generic planner if reflection fails Phase 3 - Worker Prompt + Assembly: - WorkerInstruction XML prompt in claude_agent.py for csharp_method tasks — constrains AI to output only the method body - Decomposer injects C# context fields and auto-creates assembly tasks (one per class, depends on all method tasks) - Build verification via dotnet build in task_lifecycle.py 40 new tests across 4 test files. 779 total passing. Co-Authored-By: Claude Opus 4.6 --- backend/container.py | 2 +- backend/services/claude_agent.py | 57 ++++- backend/services/decomposer.py | 99 +++++++ backend/services/planner.py | 146 ++++++++++- backend/services/task_lifecycle.py | 26 ++ backend/tools/dotnet_reflection.py | 207 +++++++++++++++ backend/tools/registry.py | 7 + tests/unit/test_csharp_decomposer.py | 202 +++++++++++++++ tests/unit/test_csharp_planner.py | 145 +++++++++++ tests/unit/test_csharp_worker.py | 96 +++++++ tests/unit/test_dotnet_reflection.py | 228 +++++++++++++++++ tools/dotnet-reflector/Program.cs | 241 ++++++++++++++++++ .../dotnet-reflector/dotnet-reflector.csproj | 11 + 13 files changed, 1462 insertions(+), 5 deletions(-) create mode 100644 backend/tools/dotnet_reflection.py create mode 100644 tests/unit/test_csharp_decomposer.py create mode 100644 tests/unit/test_csharp_planner.py create mode 100644 tests/unit/test_csharp_worker.py create mode 100644 tests/unit/test_dotnet_reflection.py create mode 100644 tools/dotnet-reflector/Program.cs create mode 100644 tools/dotnet-reflector/dotnet-reflector.csproj diff --git a/backend/container.py b/backend/container.py index abbf792..34294b2 100644 --- a/backend/container.py +++ b/backend/container.py @@ -76,7 +76,7 @@ class Container(containers.DeclarativeContainer): git_service = providers.Factory(GitService, db=db) # --- Planning & Decomposition --- - planner = providers.Factory(PlannerService, db=db, budget=budget) + planner = providers.Factory(PlannerService, db=db, budget=budget, tool_registry=tool_registry) decomposer = providers.Factory(DecomposerService, db=db) # --- Executor (depends on all services) --- diff --git a/backend/services/claude_agent.py b/backend/services/claude_agent.py index ca93bba..105124c 100644 --- a/backend/services/claude_agent.py +++ b/backend/services/claude_agent.py @@ -17,6 +17,52 @@ logger = logging.getLogger("orchestration.executor") +def _extract_csharp_context(context: list[dict]) -> dict | None: + """Extract C# method task context entries if present. + + Returns a dict with target_signature, available_methods, constructor_params, + or None if this is not a C# method task. + """ + result = {} + for ctx in context: + ctx_type = ctx.get("type", "") + if ctx_type == "target_signature": + result["target_signature"] = ctx.get("content", "") + elif ctx_type == "available_methods": + result["available_methods"] = ctx.get("content", "") + elif ctx_type == "constructor_params": + result["constructor_params"] = ctx.get("content", "") + return result if "target_signature" in result else None + + +def _build_csharp_worker_prompt(csharp_ctx: dict, task_row: dict) -> str: + """Build a structured WorkerInstruction prompt for C# method tasks.""" + signature = csharp_ctx.get("target_signature", "") + available = csharp_ctx.get("available_methods", "None") + ctor_params = csharp_ctx.get("constructor_params", "None") + description = task_row.get("description", "") + + return ( + "\n" + " \n" + f" {signature}\n" + f" {ctor_params}\n" + f" \n{available}\n \n" + " \n\n" + f" \n{description}\n \n\n" + " \n" + " Output ONLY the method body. Do not wrap in a class or add using statements.\n" + " Use the AvailableMethods strictly. Do not hallucinate helper methods that don't exist.\n" + " If the LogicGoal requires a method that doesn't exist in AvailableMethods, " + "note it clearly in your output so the orchestrator can create it.\n" + " Keep the implementation under 50 lines. If more is needed, " + "split logic into private helpers and note them.\n" + " \n\n" + " Return raw C# code only. No markdown formatting blocks.\n" + "" + ) + + async def run_claude_task( *, task_row, @@ -48,7 +94,14 @@ async def run_claude_task( # Build context context = json.loads(task_row["context_json"]) if task_row["context_json"] else [] - system_parts = [task_row["system_prompt"] or "You are a focused task executor."] + + # Check for C# method task — uses structured WorkerInstruction prompt + csharp_context = _extract_csharp_context(context) + if csharp_context: + system_parts = [_build_csharp_worker_prompt(csharp_context, task_row)] + else: + system_parts = [task_row["system_prompt"] or "You are a focused task executor."] + system_parts.append( "\n\n" "If you discover any constraints, gotchas, API quirks, or architectural " @@ -58,6 +111,8 @@ async def run_claude_task( ) for ctx in context: ctx_type = ctx.get("type", "context") + if ctx_type in ("target_signature", "available_methods", "constructor_params"): + continue # Already injected into WorkerInstruction system_parts.append(f"\n<{ctx_type}>\n{ctx.get('content', '')}\n") # Inject project knowledge from earlier tasks diff --git a/backend/services/decomposer.py b/backend/services/decomposer.py index e277677..830b47d 100644 --- a/backend/services/decomposer.py +++ b/backend/services/decomposer.py @@ -131,6 +131,19 @@ async def decompose(self, project_id: str, plan_id: str) -> dict: if files: context.append({"type": "affected_files", "content": ", ".join(files)}) + # C# reflection context: inject typed contracts for method-level tasks + target_sig = task_def.get("target_signature") + if target_sig: + context.append({"type": "target_signature", "content": target_sig}) + available_methods = task_def.get("available_methods") + if available_methods: + content = "\n".join(available_methods) if isinstance(available_methods, list) else str(available_methods) + context.append({"type": "available_methods", "content": content}) + ctor_params = task_def.get("constructor_params") + if ctor_params: + content = ", ".join(ctor_params) if isinstance(ctor_params, list) else str(ctor_params) + context.append({"type": "constructor_params", "content": content}) + # Requirement traceability requirement_ids = task_def.get("requirement_ids", []) @@ -181,6 +194,13 @@ async def decompose(self, project_id: str, plan_id: str) -> dict: (task_ids[i], task_ids[dep_idx]), )) + # Auto-create assembly tasks for C# method phases + # Groups csharp_method tasks by target_class, creates one assembly task per class + _create_csharp_assembly_tasks( + tasks_data, task_ids, waves, phase_names, + project_id, plan_id, now, write_statements, + ) + # Mark plan as approved write_statements.append(( "UPDATE plans SET status = ? WHERE id = ?", @@ -213,6 +233,85 @@ async def decompose_plan(project_id: str, plan_id: str, *, db) -> dict: return await DecomposerService(db=db).decompose(project_id, plan_id) +def _create_csharp_assembly_tasks( + tasks_data, task_ids, waves, phase_names, + project_id, plan_id, now, write_statements, +): + """Auto-create assembly tasks for C# method phases. + + Groups csharp_method tasks by target_class. For each class, creates + an assembly task that depends on all its method tasks. The assembly + task stitches method bodies into the class file and runs dotnet build. + """ + from collections import defaultdict + + # Group task indices by target_class + class_tasks: dict[str, list[int]] = defaultdict(list) + for i, task_def in enumerate(tasks_data): + if task_def.get("task_type") == "csharp_method" and task_def.get("target_class"): + class_tasks[task_def["target_class"]].append(i) + + if not class_tasks: + return + + for target_class, method_indices in class_tasks.items(): + assembly_id = uuid.uuid4().hex[:12] + class_name = target_class.split(".")[-1] + + # Collect affected files from method tasks + affected = set() + for idx in method_indices: + for f in tasks_data[idx].get("affected_files", []): + affected.add(f) + + # Assembly wave = max wave of its methods + 1 + assembly_wave = max(waves[i] for i in method_indices) + 1 + + description = ( + f"Assemble method implementations for {target_class} into the class file. " + f"Stitch the method bodies from {len(method_indices)} completed tasks into " + f"the class shell, then run 'dotnet build' to verify compilation." + ) + + context = [ + {"type": "task_description", "content": description}, + {"type": "target_class", "content": target_class}, + ] + if affected: + context.append({"type": "affected_files", "content": ", ".join(sorted(affected))}) + + # Get the phase name from the first method task + phase = phase_names[method_indices[0]] if method_indices else None + + write_statements.append(( + "INSERT INTO tasks (id, project_id, plan_id, title, description, task_type, " + "priority, status, model_tier, context_json, tools_json, " + "max_tokens, wave, phase, requirement_ids_json, created_at, updated_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + (assembly_id, project_id, plan_id, + f"Assemble {class_name}", description, "csharp_assembly", + 9999, TaskStatus.PENDING, "sonnet", # high priority number = runs last + json.dumps(context), json.dumps(["read_file", "write_file"]), + 4096, assembly_wave, phase, + json.dumps([]), now, now), + )) + + # Add dependency edges: assembly depends on all its method tasks + for method_idx in method_indices: + write_statements.append(( + "INSERT INTO task_deps (task_id, depends_on) VALUES (?, ?)", + (assembly_id, task_ids[method_idx]), + )) + + task_ids.append(assembly_id) + waves.append(assembly_wave) + + logger.info( + "Created %d assembly tasks for C# classes: %s", + len(class_tasks), ", ".join(class_tasks.keys()), + ) + + def _check_for_cycles(tasks_data: list[dict]) -> None: """Detect dependency cycles in the task graph before creating rows. diff --git a/backend/services/planner.py b/backend/services/planner.py index d4d88ff..4588346 100644 --- a/backend/services/planner.py +++ b/backend/services/planner.py @@ -196,12 +196,142 @@ def _build_system_prompt(rigor: PlanningRigor) -> str: return _PLANNING_PREAMBLE + _RIGOR_SUFFIXES[rigor] +# --------------------------------------------------------------------------- +# C# Reflection-based decomposition strategy +# --------------------------------------------------------------------------- + +_CSHARP_PLANNING_PREAMBLE = """You are a C# code architect for an AI orchestration engine. Your job is to decompose a feature request into method-level implementation tasks using reflected type metadata from the target assembly. + +You will receive: +1. The feature requirements (numbered [R1], [R2], etc.) +2. A reflected type map showing existing classes, methods, properties, and constructors from the .NET assembly. + + +- Each task implements exactly ONE method body. The method signature is already defined. +- Tasks are organized into phases, one phase per class being modified or created. +- Each task receives: the target method signature, injected dependencies (constructor params), and available sibling methods. +- The AI worker will output ONLY the method body — no class wrapper, no using statements. +- A final assembly task per class stitches method bodies into the class file and runs dotnet build. +- Keep each method under 50 lines of logic. If a method needs more, split into private helpers and add those as separate tasks. + + + +- Use the reflected type map strictly. Do not invent classes or interfaces that don't exist. +- If a new class is needed, create a "scaffold" task that generates the class shell first. +- Map depends_on to the task indices (0-based, global across phases) of methods that must complete before this one. +- For new methods on existing classes, include the existing method signatures in available_methods. +- For methods that modify shared state, note potential concurrency concerns in the description. + + +""" + +_CSHARP_TASK_SCHEMA = """{ + "title": "ClassName.MethodName", + "description": "What this method does, including behavioral contract and edge cases", + "task_type": "csharp_method", + "complexity": "simple|medium|complex", + "depends_on": [], + "target_class": "Namespace.ClassName", + "target_signature": "public async Task MethodName(ParamType param)", + "available_methods": ["signatures of other methods in the same class or injected services"], + "constructor_params": ["IDbContext db", "ILogger logger"], + "requirement_ids": ["R1"], + "verification_criteria": "How to verify this method works correctly", + "affected_files": ["src/Services/MyService.cs"] + }""" + +_CSHARP_RIGOR_SUFFIX = f"""Produce a JSON plan organized into phases. Each phase corresponds to one class being modified or created. + +{{ + "summary": "Brief summary of the feature being implemented", + "phases": [ + {{ + "name": "ClassName (e.g. 'UserService', 'OrderValidator')", + "description": "What this class does and why these methods are needed", + "tasks": [ + {_CSHARP_TASK_SCHEMA} + ] + }} + ], + "open_questions": [ + {{ + "question": "An ambiguity or decision in the requirements", + "proposed_answer": "How you propose to handle it", + "impact": "What changes if the answer differs" + }} + ], + "assembly_config": {{ + "new_files": ["Paths to new .cs files that need to be created"], + "modified_files": ["Paths to existing .cs files that will be modified"] + }} +}} + +Phase guidelines: +- One phase per class. Phase name = class name. +- Within a phase, order tasks so independent methods come first. +- depends_on indices are GLOBAL across all phases (0-based from the first task in the first phase). +- After all method tasks in a phase, the system will auto-create an assembly task to stitch and build. + +Open questions: +- Surface 1-5 ambiguities about the requirements or existing code structure. + +Respond with ONLY the JSON plan, no markdown fences or explanation.""" + + +def _build_csharp_system_prompt(type_map: str) -> str: + """Build the system prompt for C# reflection-based planning.""" + return ( + _CSHARP_PLANNING_PREAMBLE + + f"\n{type_map}\n\n\n" + + _CSHARP_RIGOR_SUFFIX + ) + + class PlannerService: """Injectable service that generates plans from project requirements.""" - def __init__(self, *, db, budget): + def __init__(self, *, db, budget, tool_registry=None): self._db = db self._budget = budget + self._tool_registry = tool_registry + + async def _get_csharp_type_map(self, config: dict) -> str | None: + """Run .NET reflection to get the type map for C# planning. + + Reads assembly_path or csproj_path from project config. + Returns formatted type map string, or None if reflection fails/unavailable. + """ + import logging + logger = logging.getLogger("orchestration.planner") + + assembly_path = config.get("assembly_path") + csproj_path = config.get("csproj_path") + + if not assembly_path and not csproj_path: + logger.warning("csharp_reflection strategy requires assembly_path or csproj_path in config") + return None + + try: + from backend.tools.dotnet_reflection import ( + build_project, + format_type_map, + reflect_assembly, + ) + + # Build from csproj if needed + if csproj_path and not assembly_path: + success, result = await build_project(csproj_path) + if not success: + logger.warning("C# build failed: %s", result) + return None + assembly_path = result + + ns_filter = config.get("namespace_filter") + data = await reflect_assembly(assembly_path, ns_filter) + return format_type_map(data) + except Exception as e: + logger.warning("C# reflection failed, falling back to generic planner: %s", e) + return None async def generate( self, @@ -235,8 +365,18 @@ async def generate( except ValueError: rigor = PlanningRigor.L2 - system_prompt = _build_system_prompt(rigor) - max_tokens = _MAX_TOKENS_BY_RIGOR[rigor] + # Check for C# reflection decomposition strategy + decomposition_strategy = config.get("decomposition_strategy") + csharp_type_map = None + if decomposition_strategy == "csharp_reflection": + csharp_type_map = await self._get_csharp_type_map(config) + + if csharp_type_map is not None: + system_prompt = _build_csharp_system_prompt(csharp_type_map) + max_tokens = 8192 # C# plans are detailed + else: + system_prompt = _build_system_prompt(rigor) + max_tokens = _MAX_TOKENS_BY_RIGOR[rigor] # Reserve budget before making the API call (prevents TOCTOU race) estimated_cost = calculate_cost(PLANNING_MODEL, _EST_PLANNING_INPUT_TOKENS, _EST_PLANNING_OUTPUT_TOKENS) diff --git a/backend/services/task_lifecycle.py b/backend/services/task_lifecycle.py index 9c15541..809ea87 100644 --- a/backend/services/task_lifecycle.py +++ b/backend/services/task_lifecycle.py @@ -599,3 +599,29 @@ async def complete_task_external( logger.warning("Knowledge extraction failed for external task %s: %s", task_id, e) return {"status": TaskStatus.COMPLETED} + + +async def verify_csharp_build(csproj_path: str) -> tuple[bool, str]: + """Run dotnet build as a verification step for C# tasks. + + Returns (success, output). On failure, output contains compiler errors + suitable for injection as retry feedback. + """ + from backend.tools.dotnet_reflection import _run_subprocess + + code, stdout, stderr = await _run_subprocess( + ["dotnet", "build", csproj_path, "-c", "Release", "--nologo", "-v", "q"], + timeout=120, + ) + if code == 0: + return True, "Build succeeded" + + # Extract just the error lines for concise feedback + output = stderr or stdout + error_lines = [ + line for line in output.splitlines() + if "error CS" in line or "error :" in line + ] + if error_lines: + return False, "Build errors:\n" + "\n".join(error_lines[:20]) + return False, f"Build failed:\n{output[:2000]}" diff --git a/backend/tools/dotnet_reflection.py b/backend/tools/dotnet_reflection.py new file mode 100644 index 0000000..274700a --- /dev/null +++ b/backend/tools/dotnet_reflection.py @@ -0,0 +1,207 @@ +# Orchestration Engine - .NET Reflection Tool +# +# Wraps the dotnet-reflector console app to extract type metadata +# from .NET assemblies. Used by the C# planner prompt to generate +# function-level decomposition with typed contracts. +# +# Depends on: tools/base.py, tools/dotnet-reflector/ (.NET console app) +# Used by: tools/registry.py, services/planner.py + +import asyncio +import json +import logging +import shutil +from pathlib import Path + +from backend.tools.base import Tool + +logger = logging.getLogger("orchestration.tools.dotnet_reflection") + +# Path to the reflector project +_REFLECTOR_DIR = Path(__file__).resolve().parent.parent.parent / "tools" / "dotnet-reflector" + + +def is_dotnet_available() -> bool: + """Check if the dotnet CLI is available on this machine.""" + return shutil.which("dotnet") is not None + + +async def _run_subprocess(cmd: list[str], cwd: str | None = None, timeout: float = 60) -> tuple[int, str, str]: + """Run a subprocess asynchronously and return (returncode, stdout, stderr).""" + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=cwd, + ) + try: + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout) + except asyncio.TimeoutError: + proc.kill() + await proc.communicate() + return -1, "", "Process timed out" + return proc.returncode or 0, stdout.decode("utf-8", errors="replace"), stderr.decode("utf-8", errors="replace") + + +async def build_project(csproj_path: str) -> tuple[bool, str]: + """Build a .NET project and return (success, output_dll_path_or_error).""" + csproj = Path(csproj_path) + if not csproj.exists(): + return False, f"Project file not found: {csproj_path}" + + code, stdout, stderr = await _run_subprocess( + ["dotnet", "build", str(csproj), "-c", "Release", "--nologo", "-v", "q"], + timeout=120, + ) + if code != 0: + return False, f"Build failed:\n{stderr or stdout}" + + # Find the output DLL + project_dir = csproj.parent + project_name = csproj.stem + # Check common output paths + for pattern in [ + project_dir / "bin" / "Release" / "**" / f"{project_name}.dll", + ]: + matches = list(project_dir.glob(f"bin/Release/**/{project_name}.dll")) + if matches: + return True, str(matches[0]) + + return False, "Build succeeded but could not find output DLL" + + +async def reflect_assembly( + assembly_path: str, + namespace_filter: str | None = None, +) -> dict: + """Run the reflector against an assembly and return parsed JSON. + + Args: + assembly_path: Path to the .dll to reflect. + namespace_filter: Optional namespace prefix to filter types. + + Returns: + Parsed reflection metadata dict. + + Raises: + RuntimeError: If the reflector fails. + """ + reflector_dll = _REFLECTOR_DIR / "bin" / "Release" / "net8.0" / "dotnet-reflector.dll" + + # Build the reflector if not already built + if not reflector_dll.exists(): + code, _, stderr = await _run_subprocess( + ["dotnet", "build", str(_REFLECTOR_DIR / "dotnet-reflector.csproj"), "-c", "Release", "--nologo", "-v", "q"], + timeout=60, + ) + if code != 0: + raise RuntimeError(f"Failed to build reflector: {stderr}") + + cmd = ["dotnet", str(reflector_dll), assembly_path] + if namespace_filter: + cmd.extend(["--namespace", namespace_filter]) + + code, stdout, stderr = await _run_subprocess(cmd, timeout=30) + if code != 0: + raise RuntimeError(f"Reflection failed: {stderr}") + + return json.loads(stdout) + + +def format_type_map(reflection_data: dict) -> str: + """Format reflection data as a human-readable type map for LLM prompts. + + Returns a structured text representation suitable for injection into + system prompts or context blocks. + """ + lines = [] + assembly_name = reflection_data.get("assembly_name", "Unknown") + lines.append(f"Assembly: {assembly_name}") + lines.append("") + + for cls in reflection_data.get("classes", []): + kind = cls.get("kind", "class") + name = cls.get("name", "?") + ns = cls.get("namespace", "") + full_name = f"{ns}.{name}" if ns else name + + header = f"{kind} {full_name}" + if cls.get("base_class"): + header += f" : {cls['base_class']}" + if cls.get("interfaces"): + ifaces = ", ".join(cls["interfaces"]) + header += f", {ifaces}" if cls.get("base_class") else f" : {ifaces}" + + lines.append(header) + + # Constructors + for ctor in cls.get("constructors") or []: + params = ", ".join(f"{p['type']} {p['name']}" for p in ctor.get("parameters", [])) + lines.append(f" ctor({params})") + + # Properties + for prop in cls.get("properties") or []: + accessors = "" + if prop.get("has_getter"): + accessors += "get; " + if prop.get("has_setter"): + accessors += "set; " + lines.append(f" {prop['type']} {prop['name']} {{ {accessors.strip()} }}") + + # Methods + for method in cls.get("methods", []): + lines.append(f" {method['signature']}") + + lines.append("") + + return "\n".join(lines) + + +class DotNetReflectionTool(Tool): + """Tool for extracting .NET assembly type metadata via reflection.""" + + name = "dotnet_reflection" + description = ( + "Reflect on a .NET assembly or project to extract type metadata " + "(classes, methods, signatures, dependencies). Useful for understanding " + "existing code structure before generating implementations." + ) + parameters = { + "type": "object", + "properties": { + "assembly_path": { + "type": "string", + "description": "Path to a .dll assembly file to reflect on.", + }, + "csproj_path": { + "type": "string", + "description": "Path to a .csproj file. Will build first, then reflect on the output.", + }, + "namespace_filter": { + "type": "string", + "description": "Optional namespace prefix to filter types (e.g., 'MyApp.Services').", + }, + }, + "required": [], + } + + async def execute(self, params: dict) -> str: + assembly_path = params.get("assembly_path") + csproj_path = params.get("csproj_path") + ns_filter = params.get("namespace_filter") + + if not assembly_path and not csproj_path: + return "Error: Provide either assembly_path or csproj_path." + + # Build from csproj if needed + if csproj_path and not assembly_path: + success, result = await build_project(csproj_path) + if not success: + return f"Error: {result}" + assembly_path = result + + try: + data = await reflect_assembly(assembly_path, ns_filter) + return format_type_map(data) + except Exception as e: + return f"Error: {e}" diff --git a/backend/tools/registry.py b/backend/tools/registry.py index 8b07466..3c18429 100644 --- a/backend/tools/registry.py +++ b/backend/tools/registry.py @@ -75,6 +75,12 @@ def _write_file(): from backend.tools.file import WriteFileTool return WriteFileTool() + def _dotnet_reflection(): + from backend.tools.dotnet_reflection import DotNetReflectionTool, is_dotnet_available + if not is_dotnet_available(): + raise RuntimeError("dotnet CLI not found — skipping DotNetReflectionTool") + return DotNetReflectionTool() + tool_factories = [ ("SearchKnowledgeTool", _search_knowledge), ("LookupTypeTool", _lookup_type), @@ -82,6 +88,7 @@ def _write_file(): ("GenerateImageTool", _generate_image), ("ReadFileTool", _read_file), ("WriteFileTool", _write_file), + ("DotNetReflectionTool", _dotnet_reflection), ] for name, factory in tool_factories: diff --git a/tests/unit/test_csharp_decomposer.py b/tests/unit/test_csharp_decomposer.py new file mode 100644 index 0000000..49b8e55 --- /dev/null +++ b/tests/unit/test_csharp_decomposer.py @@ -0,0 +1,202 @@ +# Orchestration Engine - C# Decomposer Tests +# +# Tests for C# method task handling and assembly task auto-creation. +# +# Depends on: backend/services/decomposer.py +# Used by: CI + +import json +from unittest.mock import AsyncMock, patch + +from backend.services.decomposer import _create_csharp_assembly_tasks + + +class TestCsharpContextInjection: + """Test that target_signature, available_methods, constructor_params + are injected into task context_json during decomposition.""" + + async def test_csharp_method_context_injected(self, tmp_db): + """Verify C# fields appear in context_json after decomposition.""" + from backend.services.decomposer import DecomposerService + + # Set up project + plan with C# method tasks + now = 1000.0 + await tmp_db.execute_write( + "INSERT INTO projects (id, name, requirements, status, config_json, created_at, updated_at) " + "VALUES (?, ?, ?, 'ready', '{}', ?, ?)", + ("proj1", "Test", "Build stuff", now, now), + ) + + plan_data = { + "summary": "Implement UserService", + "phases": [{ + "name": "UserService", + "tasks": [{ + "title": "UserService.GetUser", + "description": "Fetch user by ID", + "task_type": "csharp_method", + "complexity": "medium", + "depends_on": [], + "target_class": "MyApp.Services.UserService", + "target_signature": "public async Task GetUser(Guid id)", + "available_methods": ["public void Save(User u)"], + "constructor_params": ["IDbContext db", "ILogger logger"], + "requirement_ids": ["R1"], + }], + }], + } + + await tmp_db.execute_write( + "INSERT INTO plans (id, project_id, version, model_used, plan_json, status, created_at) " + "VALUES (?, ?, 1, 'test', ?, 'approved', ?)", + ("plan1", "proj1", json.dumps(plan_data), now), + ) + + decomposer = DecomposerService(db=tmp_db) + result = await decomposer.decompose("proj1", "plan1") + + # Should have created 1 method task + 1 assembly task + assert result["tasks_created"] >= 1 + + # Check the method task has C# context + task = await tmp_db.fetchone( + "SELECT context_json FROM tasks WHERE project_id = ? AND task_type = 'csharp_method'", + ("proj1",), + ) + assert task is not None + context = json.loads(task["context_json"]) + context_types = [c["type"] for c in context] + assert "target_signature" in context_types + assert "available_methods" in context_types + assert "constructor_params" in context_types + + # Check signature content + sig_entry = next(c for c in context if c["type"] == "target_signature") + assert "GetUser" in sig_entry["content"] + + +class TestCreateCsharpAssemblyTasks: + def test_creates_assembly_task_per_class(self): + tasks_data = [ + {"task_type": "csharp_method", "target_class": "MyApp.UserService", + "title": "GetUser", "affected_files": ["UserService.cs"]}, + {"task_type": "csharp_method", "target_class": "MyApp.UserService", + "title": "SaveUser", "affected_files": ["UserService.cs"]}, + {"task_type": "csharp_method", "target_class": "MyApp.OrderService", + "title": "CreateOrder", "affected_files": ["OrderService.cs"]}, + ] + task_ids = ["t1", "t2", "t3"] + waves = [0, 0, 0] + phase_names = ["UserService", "UserService", "OrderService"] + write_statements = [] + + _create_csharp_assembly_tasks( + tasks_data, task_ids, waves, phase_names, + "proj1", "plan1", 1000.0, write_statements, + ) + + # Should have 2 assembly tasks (UserService, OrderService) + # Each has 1 INSERT + N dependency INSERTs + inserts = [s for s in write_statements if "INSERT INTO tasks" in s[0]] + assert len(inserts) == 2 + + # UserService assembly depends on t1 and t2 + dep_inserts = [s for s in write_statements if "INSERT INTO task_deps" in s[0]] + user_deps = [s for s in dep_inserts if s[1][1] in ("t1", "t2")] + assert len(user_deps) == 2 + + # OrderService assembly depends on t3 + order_deps = [s for s in dep_inserts if s[1][1] == "t3"] + assert len(order_deps) == 1 + + def test_no_assembly_tasks_for_non_csharp(self): + tasks_data = [ + {"task_type": "code", "title": "Do stuff"}, + ] + task_ids = ["t1"] + waves = [0] + phase_names = [None] + write_statements = [] + + _create_csharp_assembly_tasks( + tasks_data, task_ids, waves, phase_names, + "proj1", "plan1", 1000.0, write_statements, + ) + + assert len(write_statements) == 0 + + def test_assembly_wave_is_after_methods(self): + tasks_data = [ + {"task_type": "csharp_method", "target_class": "Foo", + "title": "M1", "affected_files": []}, + {"task_type": "csharp_method", "target_class": "Foo", + "title": "M2", "affected_files": []}, + ] + task_ids = ["t1", "t2"] + waves = [0, 1] # M2 is in wave 1 + phase_names = ["Foo", "Foo"] + write_statements = [] + + _create_csharp_assembly_tasks( + tasks_data, task_ids, waves, phase_names, + "proj1", "plan1", 1000.0, write_statements, + ) + + # Assembly task should be in wave 2 (max(0,1) + 1) + insert = [s for s in write_statements if "INSERT INTO tasks" in s[0]][0] + # wave is at index 12 in the VALUES tuple + assembly_wave = insert[1][12] + assert assembly_wave == 2 + + def test_assembly_task_type(self): + tasks_data = [ + {"task_type": "csharp_method", "target_class": "Foo", + "title": "M1", "affected_files": []}, + ] + task_ids = ["t1"] + waves = [0] + phase_names = ["Foo"] + write_statements = [] + + _create_csharp_assembly_tasks( + tasks_data, task_ids, waves, phase_names, + "proj1", "plan1", 1000.0, write_statements, + ) + + insert = [s for s in write_statements if "INSERT INTO tasks" in s[0]][0] + # task_type is at index 5 in VALUES + assert insert[1][5] == "csharp_assembly" + # title should contain "Assemble" + assert "Assemble" in insert[1][3] + + +class TestBuildVerification: + async def test_build_success(self): + from backend.services.task_lifecycle import verify_csharp_build + + with patch( + "backend.tools.dotnet_reflection._run_subprocess", + new_callable=AsyncMock, + return_value=(0, "Build succeeded.", ""), + ): + success, output = await verify_csharp_build("/fake/Test.csproj") + assert success is True + assert "succeeded" in output + + async def test_build_failure_extracts_errors(self): + from backend.services.task_lifecycle import verify_csharp_build + + stderr = ( + "Program.cs(10,5): error CS1002: ; expected\n" + "Program.cs(15,1): error CS0246: The type 'Foo' could not be found\n" + "Build FAILED.\n" + ) + with patch( + "backend.tools.dotnet_reflection._run_subprocess", + new_callable=AsyncMock, + return_value=(1, "", stderr), + ): + success, output = await verify_csharp_build("/fake/Test.csproj") + assert success is False + assert "error CS1002" in output + assert "error CS0246" in output diff --git a/tests/unit/test_csharp_planner.py b/tests/unit/test_csharp_planner.py new file mode 100644 index 0000000..5fbdfbf --- /dev/null +++ b/tests/unit/test_csharp_planner.py @@ -0,0 +1,145 @@ +# Orchestration Engine - C# Planner Prompt Tests +# +# Tests for the C# reflection-based planning strategy. +# +# Depends on: backend/services/planner.py +# Used by: CI + +from unittest.mock import AsyncMock, patch + +from backend.services.planner import ( + PlannerService, + _build_csharp_system_prompt, + _build_system_prompt, +) +from backend.models.enums import PlanningRigor + + +class TestCsharpSystemPrompt: + def test_includes_type_map(self): + type_map = "class MyApp.UserService\n public async Task GetUser(Guid id)" + prompt = _build_csharp_system_prompt(type_map) + assert "" in prompt + assert "UserService" in prompt + assert "GetUser" in prompt + + def test_includes_csharp_preamble(self): + prompt = _build_csharp_system_prompt("some types") + assert "C# code architect" in prompt + assert "method-level implementation tasks" in prompt + + def test_includes_task_schema(self): + prompt = _build_csharp_system_prompt("types") + assert "target_signature" in prompt + assert "target_class" in prompt + assert "available_methods" in prompt + assert "constructor_params" in prompt + + def test_includes_strategy_rules(self): + prompt = _build_csharp_system_prompt("types") + assert "50 lines" in prompt + assert "assembly task" in prompt + assert "dotnet build" in prompt + + def test_does_not_include_generic_preamble(self): + prompt = _build_csharp_system_prompt("types") + # Should NOT have the generic planner's task_type list + assert "task_type \"research\"" not in prompt + + def test_generic_prompt_unchanged(self): + """Verify the generic prompt path still works.""" + prompt = _build_system_prompt(PlanningRigor.L2) + assert "project planner" in prompt + assert "reflected_types" not in prompt + + +def _make_planner_db_mock(config_json): + """Create a mock db that returns the right values for PlannerService.generate().""" + project_row = { + "id": "proj1", + "name": "Test", + "requirements": "Build a user service", + "config_json": config_json, + "status": "draft", + } + version_row = {"v": 0} + mock_db = AsyncMock() + mock_db.fetchone = AsyncMock(side_effect=[project_row, version_row]) + mock_db.execute_write = AsyncMock() + return mock_db + + +def _make_anthropic_mock(response_text='{"summary": "test", "phases": []}'): + """Create a mock anthropic module + client.""" + mock_anthropic = AsyncMock() + mock_client = AsyncMock() + mock_response = AsyncMock() + mock_response.content = [AsyncMock(text=response_text)] + mock_response.usage = AsyncMock(input_tokens=100, output_tokens=200) + mock_client.messages.create = AsyncMock(return_value=mock_response) + mock_client.close = AsyncMock() + mock_anthropic.AsyncAnthropic.return_value = mock_client + return mock_anthropic, mock_client + + +class TestPlannerServiceCsharpStrategy: + async def test_csharp_strategy_calls_reflection(self): + """When decomposition_strategy is csharp_reflection, planner calls reflection.""" + config = '{"decomposition_strategy": "csharp_reflection", "csproj_path": "/fake/Test.csproj"}' + mock_db = _make_planner_db_mock(config) + mock_budget = AsyncMock() + mock_budget.reserve_spend = AsyncMock(return_value=True) + + planner = PlannerService(db=mock_db, budget=mock_budget) + + with patch.object(planner, "_get_csharp_type_map", new_callable=AsyncMock) as mock_reflect: + mock_reflect.return_value = "class Foo\n public void Bar()" + + with patch("backend.services.planner.anthropic") as mock_anthropic_mod: + mock_anthropic, mock_client = _make_anthropic_mock() + mock_anthropic_mod.AsyncAnthropic.return_value = mock_client + + await planner.generate("proj1") + + mock_reflect.assert_called_once() + + async def test_csharp_strategy_fallback_on_reflection_failure(self): + """If reflection fails, falls back to generic planner.""" + config = '{"decomposition_strategy": "csharp_reflection", "csproj_path": "/fake/Test.csproj"}' + mock_db = _make_planner_db_mock(config) + mock_budget = AsyncMock() + mock_budget.reserve_spend = AsyncMock(return_value=True) + + planner = PlannerService(db=mock_db, budget=mock_budget) + + with patch.object(planner, "_get_csharp_type_map", new_callable=AsyncMock) as mock_reflect: + mock_reflect.return_value = None # Reflection failed + + with patch("backend.services.planner.anthropic") as mock_anthropic_mod: + mock_anthropic, mock_client = _make_anthropic_mock('{"summary": "test", "tasks": []}') + mock_anthropic_mod.AsyncAnthropic.return_value = mock_client + + await planner.generate("proj1") + + # Should have called create with a generic prompt (no reflected_types) + call_kwargs = mock_client.messages.create.call_args[1] + assert "reflected_types" not in call_kwargs["system"] + + async def test_get_csharp_type_map_no_paths(self): + """Returns None when no assembly/csproj paths are configured.""" + planner = PlannerService(db=AsyncMock(), budget=AsyncMock()) + result = await planner._get_csharp_type_map({}) + assert result is None + + async def test_get_csharp_type_map_with_assembly(self): + """Calls reflect_assembly when assembly_path is provided.""" + planner = PlannerService(db=AsyncMock(), budget=AsyncMock()) + + mock_data = {"assembly_name": "Test", "classes": []} + with patch("backend.tools.dotnet_reflection.reflect_assembly", new_callable=AsyncMock) as mock_reflect, \ + patch("backend.tools.dotnet_reflection.format_type_map") as mock_format: + mock_reflect.return_value = mock_data + mock_format.return_value = "formatted" + + result = await planner._get_csharp_type_map({"assembly_path": "/fake.dll"}) + assert result == "formatted" diff --git a/tests/unit/test_csharp_worker.py b/tests/unit/test_csharp_worker.py new file mode 100644 index 0000000..c6bae98 --- /dev/null +++ b/tests/unit/test_csharp_worker.py @@ -0,0 +1,96 @@ +# Orchestration Engine - C# Worker Prompt Tests +# +# Tests for the WorkerInstruction prompt builder in claude_agent.py. +# +# Depends on: backend/services/claude_agent.py +# Used by: CI + +from backend.services.claude_agent import ( + _build_csharp_worker_prompt, + _extract_csharp_context, +) + + +class TestExtractCsharpContext: + def test_extracts_csharp_fields(self): + context = [ + {"type": "project_summary", "content": "A project"}, + {"type": "target_signature", "content": "public async Task GetUser(Guid id)"}, + {"type": "available_methods", "content": "public void Save(User u)\npublic User Find(Guid id)"}, + {"type": "constructor_params", "content": "IDbContext db, ILogger logger"}, + ] + result = _extract_csharp_context(context) + assert result is not None + assert result["target_signature"] == "public async Task GetUser(Guid id)" + assert "Save" in result["available_methods"] + assert "IDbContext" in result["constructor_params"] + + def test_returns_none_without_target_signature(self): + context = [ + {"type": "project_summary", "content": "A project"}, + {"type": "available_methods", "content": "some methods"}, + ] + result = _extract_csharp_context(context) + assert result is None + + def test_returns_none_for_empty_context(self): + assert _extract_csharp_context([]) is None + + def test_minimal_csharp_context(self): + context = [{"type": "target_signature", "content": "public void DoStuff()"}] + result = _extract_csharp_context(context) + assert result is not None + assert "target_signature" in result + + +class TestBuildCsharpWorkerPrompt: + def test_includes_worker_instruction_tags(self): + ctx = { + "target_signature": "public async Task ValidateUser(Guid id)", + "available_methods": "public User Find(Guid id)", + "constructor_params": "IUserStore store", + } + task_row = {"description": "Check if user exists and has active subscription"} + prompt = _build_csharp_worker_prompt(ctx, task_row) + + assert "" in prompt + assert "" in prompt + assert "" in prompt + assert "ValidateUser" in prompt + + def test_includes_logic_goal(self): + ctx = {"target_signature": "public void Foo()"} + task_row = {"description": "Do the foo thing"} + prompt = _build_csharp_worker_prompt(ctx, task_row) + assert "" in prompt + assert "Do the foo thing" in prompt + + def test_includes_execution_rules(self): + ctx = {"target_signature": "public void Foo()"} + task_row = {"description": "test"} + prompt = _build_csharp_worker_prompt(ctx, task_row) + assert "ONLY the method body" in prompt + assert "Do not hallucinate" in prompt + assert "50 lines" in prompt + + def test_includes_dependencies(self): + ctx = { + "target_signature": "public void Foo()", + "constructor_params": "IDbContext db, ILogger log", + "available_methods": "public void Bar()\npublic int Baz()", + } + task_row = {"description": "test"} + prompt = _build_csharp_worker_prompt(ctx, task_row) + assert "" in prompt + assert "IDbContext db" in prompt + assert "" in prompt + assert "Bar()" in prompt + assert "Baz()" in prompt + + def test_handles_missing_optional_fields(self): + ctx = {"target_signature": "public void Foo()"} + task_row = {"description": "test"} + prompt = _build_csharp_worker_prompt(ctx, task_row) + # Should still produce valid XML without crashing + assert "public void Foo()" in prompt + assert "None" in prompt # default for missing fields diff --git a/tests/unit/test_dotnet_reflection.py b/tests/unit/test_dotnet_reflection.py new file mode 100644 index 0000000..1b7913c --- /dev/null +++ b/tests/unit/test_dotnet_reflection.py @@ -0,0 +1,228 @@ +# Orchestration Engine - .NET Reflection Tool Tests +# +# Tests for the Python wrapper around the dotnet-reflector. +# Uses mocked subprocess calls so CI doesn't need .NET SDK. +# +# Depends on: conftest.py, backend/tools/dotnet_reflection.py +# Used by: CI + +import json +from unittest.mock import AsyncMock, patch + +import pytest + +from backend.tools.dotnet_reflection import ( + DotNetReflectionTool, + build_project, + format_type_map, + reflect_assembly, +) + + +# Sample reflection output matching the reflector's JSON schema +SAMPLE_REFLECTION = { + "assembly_name": "MyApp", + "classes": [ + { + "name": "UserService", + "namespace": "MyApp.Services", + "kind": "class", + "base_class": None, + "interfaces": ["IUserService"], + "constructors": [ + {"parameters": [{"name": "db", "type": "IDbContext"}, {"name": "logger", "type": "ILogger"}]} + ], + "methods": [ + { + "name": "GetUser", + "return_type": "Task", + "parameters": [{"name": "id", "type": "Guid"}], + "accessibility": "public", + "is_async": True, + "is_static": None, + "signature": "public async Task GetUser(Guid id)", + }, + { + "name": "ValidateEmail", + "return_type": "bool", + "parameters": [{"name": "email", "type": "string"}], + "accessibility": "private", + "is_async": None, + "is_static": None, + "signature": "private bool ValidateEmail(string email)", + }, + ], + "properties": [ + {"name": "CacheEnabled", "type": "bool", "has_getter": True, "has_setter": True} + ], + }, + { + "name": "IUserService", + "namespace": "MyApp.Services", + "kind": "interface", + "methods": [ + { + "name": "GetUser", + "return_type": "Task", + "parameters": [{"name": "id", "type": "Guid"}], + "accessibility": "public", + "is_async": True, + "is_static": None, + "signature": "public async Task GetUser(Guid id)", + } + ], + }, + ], +} + + +class TestFormatTypeMap: + def test_formats_class_with_methods(self): + result = format_type_map(SAMPLE_REFLECTION) + assert "Assembly: MyApp" in result + assert "class MyApp.Services.UserService : IUserService" in result + assert "public async Task GetUser(Guid id)" in result + assert "private bool ValidateEmail(string email)" in result + + def test_formats_constructor(self): + result = format_type_map(SAMPLE_REFLECTION) + assert "ctor(IDbContext db, ILogger logger)" in result + + def test_formats_properties(self): + result = format_type_map(SAMPLE_REFLECTION) + assert "bool CacheEnabled { get; set; }" in result + + def test_formats_interface(self): + result = format_type_map(SAMPLE_REFLECTION) + assert "interface MyApp.Services.IUserService" in result + + def test_empty_assembly(self): + result = format_type_map({"assembly_name": "Empty", "classes": []}) + assert "Assembly: Empty" in result + + +class TestReflectAssembly: + async def test_calls_reflector_subprocess(self): + mock_output = json.dumps(SAMPLE_REFLECTION) + with patch( + "backend.tools.dotnet_reflection._run_subprocess", + new_callable=AsyncMock, + return_value=(0, mock_output, ""), + ), patch( + "backend.tools.dotnet_reflection._REFLECTOR_DIR", + ) as mock_dir: + # Simulate reflector DLL exists + mock_dir.__truediv__ = lambda self, x: type("P", (), { + "__truediv__": lambda s, y: type("P2", (), { + "__truediv__": lambda s2, z: type("P3", (), { + "__truediv__": lambda s3, w: type("P4", (), { + "exists": lambda s4: True, "__str__": lambda s4: "/fake/reflector.dll" + })() + })() + })() + })() + + result = await reflect_assembly("/fake/assembly.dll") + assert result["assembly_name"] == "MyApp" + assert len(result["classes"]) == 2 + + async def test_raises_on_failure(self): + with patch( + "backend.tools.dotnet_reflection._run_subprocess", + new_callable=AsyncMock, + return_value=(1, "", "Some error"), + ), patch( + "backend.tools.dotnet_reflection._REFLECTOR_DIR", + ) as mock_dir: + mock_dir.__truediv__ = lambda self, x: type("P", (), { + "__truediv__": lambda s, y: type("P2", (), { + "__truediv__": lambda s2, z: type("P3", (), { + "__truediv__": lambda s3, w: type("P4", (), { + "exists": lambda s4: True, "__str__": lambda s4: "/fake/reflector.dll" + })() + })() + })() + })() + + with pytest.raises(RuntimeError, match="Reflection failed"): + await reflect_assembly("/fake/assembly.dll") + + +class TestBuildProject: + async def test_build_success(self, tmp_path): + # Create a fake csproj and output DLL + csproj = tmp_path / "Test.csproj" + csproj.write_text("") + dll_dir = tmp_path / "bin" / "Release" / "net8.0" + dll_dir.mkdir(parents=True) + (dll_dir / "Test.dll").write_text("fake") + + with patch( + "backend.tools.dotnet_reflection._run_subprocess", + new_callable=AsyncMock, + return_value=(0, "Build succeeded", ""), + ): + success, result = await build_project(str(csproj)) + assert success is True + assert result.endswith("Test.dll") + + async def test_build_failure(self, tmp_path): + csproj = tmp_path / "Test.csproj" + csproj.write_text("") + + with patch( + "backend.tools.dotnet_reflection._run_subprocess", + new_callable=AsyncMock, + return_value=(1, "", "error CS1234: something broke"), + ): + success, result = await build_project(str(csproj)) + assert success is False + assert "Build failed" in result + + async def test_missing_csproj(self): + success, result = await build_project("/nonexistent/Test.csproj") + assert success is False + assert "not found" in result + + +class TestDotNetReflectionTool: + def test_tool_metadata(self): + tool = DotNetReflectionTool() + assert tool.name == "dotnet_reflection" + assert "assembly_path" in tool.parameters["properties"] + assert "csproj_path" in tool.parameters["properties"] + + async def test_execute_with_assembly(self): + mock_output = json.dumps(SAMPLE_REFLECTION) + with patch( + "backend.tools.dotnet_reflection._run_subprocess", + new_callable=AsyncMock, + return_value=(0, mock_output, ""), + ), patch( + "backend.tools.dotnet_reflection._REFLECTOR_DIR", + ) as mock_dir: + mock_dir.__truediv__ = lambda self, x: type("P", (), { + "__truediv__": lambda s, y: type("P2", (), { + "__truediv__": lambda s2, z: type("P3", (), { + "__truediv__": lambda s3, w: type("P4", (), { + "exists": lambda s4: True, "__str__": lambda s4: "/fake/reflector.dll" + })() + })() + })() + })() + + tool = DotNetReflectionTool() + result = await tool.execute({"assembly_path": "/fake/assembly.dll"}) + assert "UserService" in result + assert "GetUser" in result + + async def test_execute_no_paths_returns_error(self): + tool = DotNetReflectionTool() + result = await tool.execute({}) + assert "Error" in result + + async def test_to_claude_tool(self): + tool = DotNetReflectionTool() + claude_def = tool.to_claude_tool() + assert claude_def["name"] == "dotnet_reflection" + assert "input_schema" in claude_def diff --git a/tools/dotnet-reflector/Program.cs b/tools/dotnet-reflector/Program.cs new file mode 100644 index 0000000..56289eb --- /dev/null +++ b/tools/dotnet-reflector/Program.cs @@ -0,0 +1,241 @@ +// Orchestration Engine - .NET Assembly Reflector +// +// Loads a .NET assembly via MetadataLoadContext (no execution) and extracts +// type metadata as structured JSON for AI-driven code decomposition. +// +// Depends on: System.Reflection.MetadataLoadContext +// Used by: backend/tools/dotnet_reflection.py + +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; + +if (args.Length < 1) +{ + Console.Error.WriteLine("Usage: dotnet-reflector [--namespace ]"); + return 1; +} + +var assemblyPath = args[0]; +string? nsFilter = null; + +for (int i = 1; i < args.Length - 1; i++) +{ + if (args[i] == "--namespace" && i + 1 < args.Length) + nsFilter = args[i + 1]; +} + +if (!File.Exists(assemblyPath)) +{ + Console.Error.WriteLine($"Assembly not found: {assemblyPath}"); + return 1; +} + +try +{ + var result = ReflectAssembly(assemblyPath, nsFilter); + var options = new JsonSerializerOptions + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + }; + Console.Write(JsonSerializer.Serialize(result, options)); + return 0; +} +catch (Exception ex) +{ + Console.Error.WriteLine($"Reflection failed: {ex.Message}"); + return 1; +} + +static AssemblyInfo ReflectAssembly(string assemblyPath, string? nsFilter) +{ + // Resolve runtime assemblies for MetadataLoadContext + var runtimeDir = Path.GetDirectoryName(typeof(object).Assembly.Location)!; + var resolver = new PathAssemblyResolver( + Directory.GetFiles(runtimeDir, "*.dll") + .Append(assemblyPath) + ); + + using var mlc = new MetadataLoadContext(resolver); + var assembly = mlc.LoadFromAssemblyPath(assemblyPath); + + var classes = new List(); + + foreach (var type in assembly.GetTypes()) + { + if (type.IsCompilerGenerated()) + continue; + + if (nsFilter != null && type.Namespace?.StartsWith(nsFilter) != true) + continue; + + // Only process classes, structs, and interfaces + if (!type.IsClass && !type.IsValueType && !type.IsInterface) + continue; + + var classInfo = new ClassInfo + { + Name = type.Name, + Namespace = type.Namespace, + Kind = type.IsInterface ? "interface" : type.IsValueType ? "struct" : "class", + BaseClass = type.BaseType?.Name is "Object" or "ValueType" ? null : type.BaseType?.Name, + Interfaces = type.GetInterfaces() + .Where(i => !i.IsCompilerGenerated()) + .Select(i => FormatTypeName(i)) + .ToList(), + Constructors = type.GetConstructors(BindingFlags.Public | BindingFlags.Instance) + .Select(c => new ConstructorInfo_ + { + Parameters = c.GetParameters().Select(ToParamInfo).ToList(), + }) + .ToList(), + Methods = type.GetMethods(BindingFlags.Public | BindingFlags.NonPublic + | BindingFlags.Instance | BindingFlags.Static + | BindingFlags.DeclaredOnly) + .Where(m => !m.IsSpecialName && !m.IsCompilerGenerated()) + .Select(m => new MethodInfo_ + { + Name = m.Name, + ReturnType = FormatTypeName(m.ReturnType), + Parameters = m.GetParameters().Select(ToParamInfo).ToList(), + Accessibility = m.IsPublic ? "public" : m.IsFamily ? "protected" + : m.IsPrivate ? "private" : "internal", + IsStatic = m.IsStatic ? true : null, + IsAsync = m.ReturnType.Name.StartsWith("Task") ? true : null, + Signature = FormatMethodSignature(m), + }) + .ToList(), + Properties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance + | BindingFlags.DeclaredOnly) + .Select(p => new PropertyInfo_ + { + Name = p.Name, + Type = FormatTypeName(p.PropertyType), + HasGetter = p.GetMethod != null, + HasSetter = p.SetMethod != null, + }) + .ToList(), + }; + + // Drop empty lists for cleaner output + if (classInfo.Interfaces.Count == 0) classInfo.Interfaces = null; + if (classInfo.Constructors.Count == 0) classInfo.Constructors = null; + if (classInfo.Properties.Count == 0) classInfo.Properties = null; + + classes.Add(classInfo); + } + + return new AssemblyInfo + { + AssemblyName = assembly.GetName().Name ?? Path.GetFileNameWithoutExtension(assemblyPath), + Classes = classes, + }; +} + +static ParamInfo ToParamInfo(ParameterInfo p) => new() +{ + Name = p.Name ?? "arg", + Type = FormatTypeName(p.ParameterType), + HasDefault = p.HasDefaultValue ? true : null, +}; + +static string FormatTypeName(Type t) +{ + if (t.IsGenericType) + { + var name = t.Name.Split('`')[0]; + var args = string.Join(", ", t.GetGenericArguments().Select(FormatTypeName)); + return $"{name}<{args}>"; + } + return t.Name switch + { + "String" => "string", + "Int32" => "int", + "Int64" => "long", + "Boolean" => "bool", + "Single" => "float", + "Double" => "double", + "Decimal" => "decimal", + "Void" => "void", + "Object" => "object", + _ => t.Name, + }; +} + +static string FormatMethodSignature(MethodInfo m) +{ + var access = m.IsPublic ? "public" : m.IsFamily ? "protected" + : m.IsPrivate ? "private" : "internal"; + var staticMod = m.IsStatic ? " static" : ""; + var asyncMod = m.ReturnType.Name.StartsWith("Task") ? " async" : ""; + var returnType = FormatTypeName(m.ReturnType); + var parameters = string.Join(", ", + m.GetParameters().Select(p => $"{FormatTypeName(p.ParameterType)} {p.Name}")); + return $"{access}{staticMod}{asyncMod} {returnType} {m.Name}({parameters})"; +} + +static class TypeExtensions +{ + public static bool IsCompilerGenerated(this Type t) => + t.Name.StartsWith('<') || t.Name.Contains("__") || + t.GetCustomAttributesData().Any(a => + a.AttributeType.Name == "CompilerGeneratedAttribute"); + + public static bool IsCompilerGenerated(this MethodInfo m) => + m.Name.StartsWith('<') || + m.GetCustomAttributesData().Any(a => + a.AttributeType.Name == "CompilerGeneratedAttribute"); +} + +// --- JSON output models --- + +record AssemblyInfo +{ + public string AssemblyName { get; init; } = ""; + public List Classes { get; init; } = new(); +} + +record ClassInfo +{ + public string Name { get; init; } = ""; + public string? Namespace { get; init; } + public string Kind { get; init; } = "class"; + public string? BaseClass { get; init; } + public List? Interfaces { get; set; } + public List? Constructors { get; set; } + public List Methods { get; init; } = new(); + public List? Properties { get; set; } +} + +record ConstructorInfo_ +{ + public List Parameters { get; init; } = new(); +} + +record MethodInfo_ +{ + public string Name { get; init; } = ""; + public string ReturnType { get; init; } = "void"; + public List Parameters { get; init; } = new(); + public string Accessibility { get; init; } = "public"; + public bool? IsStatic { get; init; } + public bool? IsAsync { get; init; } + public string Signature { get; init; } = ""; +} + +record PropertyInfo_ +{ + public string Name { get; init; } = ""; + public string Type { get; init; } = ""; + public bool HasGetter { get; init; } + public bool HasSetter { get; init; } +} + +record ParamInfo +{ + public string Name { get; init; } = ""; + public string Type { get; init; } = ""; + public bool? HasDefault { get; init; } +} diff --git a/tools/dotnet-reflector/dotnet-reflector.csproj b/tools/dotnet-reflector/dotnet-reflector.csproj new file mode 100644 index 0000000..c66ce1d --- /dev/null +++ b/tools/dotnet-reflector/dotnet-reflector.csproj @@ -0,0 +1,11 @@ + + + Exe + net8.0 + enable + enable + + + + + From 01832d030ae78e0f91bb7e3fc8f1a32fd18d7d4a Mon Sep 17 00:00:00 2001 From: JRussas <159085336+JMRussas@users.noreply.github.com> Date: Thu, 5 Mar 2026 23:08:54 -0500 Subject: [PATCH 2/2] Move logging import to module level and gitignore dotnet build artifacts Co-Authored-By: Claude Opus 4.6 --- .gitignore | 2 ++ backend/services/planner.py | 6 +++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index d47af41..3a4eb0f 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,5 @@ frontend/dist/ *.log .claude/settings.local.json *.tsbuildinfo +tools/dotnet-reflector/bin/ +tools/dotnet-reflector/obj/ diff --git a/backend/services/planner.py b/backend/services/planner.py index 4588346..b436d0f 100644 --- a/backend/services/planner.py +++ b/backend/services/planner.py @@ -6,6 +6,7 @@ # Used by: routes/projects.py, container.py import json +import logging import time import uuid @@ -17,6 +18,8 @@ from backend.services.model_router import calculate_cost from backend.utils.json_utils import extract_json_object, parse_requirements +logger = logging.getLogger("orchestration.planner") + # Token estimates for budget reservation before API calls _EST_PLANNING_INPUT_TOKENS = 2000 # system prompt (~1.5k) + requirements _EST_PLANNING_OUTPUT_TOKENS = 2000 # plan JSON response @@ -301,9 +304,6 @@ async def _get_csharp_type_map(self, config: dict) -> str | None: Reads assembly_path or csproj_path from project config. Returns formatted type map string, or None if reflection fails/unavailable. """ - import logging - logger = logging.getLogger("orchestration.planner") - assembly_path = config.get("assembly_path") csproj_path = config.get("csproj_path")