diff --git a/CHANGELOG.md b/CHANGELOG.md index 491e233..8eb52b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,35 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.1.0] - 2025-10-28 + +### Added +- **Processing Modes System**: Introduced modular architecture for different diffgraph generation strategies + - Created `BaseProcessor` abstract class for implementing custom processing modes + - Added processor registry and factory pattern for mode instantiation + - Implemented `@register_processor` decorator for automatic mode registration +- **OpenAI Agents Dependency Graph Mode**: Refactored existing AI analysis into `openai-agents-dependency-graph` mode + - Maintains all existing functionality as the default processing mode + - Analyzes code at component level (classes, functions, methods) + - Generates dependency graphs showing component relationships +- **CLI Enhancements**: + - Added `--mode` / `-m` option to select processing mode + - Added `--list-modes` flag to display available processing modes + - Default mode: `openai-agents-dependency-graph` (backward compatible) +- **Documentation**: + - Added comprehensive developer guide: `docs/ADDING_PROCESSING_MODES.md` + - Updated README.md with processing modes information + - Documented how to create custom processing modes + +### Changed +- Refactored `CodeAnalysisAgent` into modular `OpenAIAgentsProcessor` +- Removed direct dependency on `ai_analysis.py` in CLI (now uses processor factory) +- Improved extensibility for adding new analysis approaches (Tree-sitter, data flow, etc.) + +### Removed +- `diffgraph/ai_analysis.py` - Functionality moved to `diffgraph/processing_modes/openai_agents_dependency.py` + + ## [1.0.0] - 2025-08-06 ### Changed diff --git a/README.md b/README.md index ee66c7b..88f4b92 100644 --- a/README.md +++ b/README.md @@ -58,13 +58,53 @@ This will: - `--api-key`: Specify your OpenAI API key (defaults to OPENAI_API_KEY environment variable) - `--output` or `-o`: Specify the output HTML file path (default: diffgraph.html) - `--no-open`: Don't automatically open the HTML report in browser +- `--mode` or `-m`: Select processing mode for diffgraph generation (default: openai-agents-dependency-graph) +- `--list-modes`: List all available processing modes - `--version`: Show version information -Example: +Examples: ```bash -wild --output my-report.html --no-open +# Generate report with default mode +wild diff + +# Generate report with custom output path +wild diff --output my-report.html --no-open + +# List available processing modes +wild diff --list-modes + +# Use a specific processing mode +wild diff --mode openai-agents-dependency-graph ``` +## šŸ”§ Processing Modes + +DiffGraph supports multiple processing modes for analyzing code changes. Each mode provides a different perspective on your code: + +### Available Modes + +#### `openai-agents-dependency-graph` (default) +Uses OpenAI Agents SDK to analyze code and generate component-level dependency graphs. This mode: +- Identifies classes, functions, and methods in your changes +- Analyzes dependencies between components +- Generates a visual dependency graph showing how components relate to each other +- Best for understanding architectural changes and component interactions + +### Future Modes + +The architecture is designed to support additional processing modes: +- **tree-sitter-dependency-graph**: AST-based analysis using Tree-sitter +- **data-flow-analysis**: Focus on data flow and transformations +- **user-context-analysis**: Analyze changes from a user interaction perspective +- **architecture-analysis**: System-level architectural insights + +### Adding Custom Processing Modes + +Developers can extend DiffGraph by creating custom processing modes. See the `diffgraph/processing_modes/` directory for examples. Each processor must: +1. Inherit from `BaseProcessor` +2. Implement the `analyze_changes()` method +3. Register itself using the `@register_processor` decorator + ## šŸ“Š Example Output The generated HTML report includes: diff --git a/diffgraph/__init__.py b/diffgraph/__init__.py index 317dee1..eb877fc 100644 --- a/diffgraph/__init__.py +++ b/diffgraph/__init__.py @@ -2,4 +2,4 @@ DiffGraph - A CLI tool for visualizing code changes with AI """ -__version__ = "0.1.0" \ No newline at end of file +__version__ = "1.1.0" \ No newline at end of file diff --git a/diffgraph/cli.py b/diffgraph/cli.py index 15e0522..3e51148 100644 --- a/diffgraph/cli.py +++ b/diffgraph/cli.py @@ -5,10 +5,11 @@ from click_spinner import spinner from typing import List, Dict import os -from diffgraph.ai_analysis import CodeAnalysisAgent from diffgraph.html_report import generate_html_report, AnalysisResult from diffgraph.env_loader import load_env_file, debug_environment from diffgraph.utils import sanitize_diff_args, involves_working_tree +from diffgraph.processing_modes import get_processor, list_available_modes +from diffgraph.consent import ensure_consent # Load environment variables load_env_file() @@ -136,9 +137,21 @@ def load_file_contents(changed_files: List[Dict[str, str]], diff_args: List[str] @click.option('--output', '-o', default='diffgraph.html', help='Output HTML file path') @click.option('--no-open', is_flag=True, help='Do not open the HTML report automatically') @click.option('--debug-env', is_flag=True, help='Debug environment variable loading') -def main(args, api_key: str, output: str, no_open: bool, debug_env: bool): +@click.option('--mode', '-m', default='local-structural', + help='Processing mode for diffgraph generation (default: local-structural)') +@click.option('--list-modes', is_flag=True, help='List available processing modes and exit') +def main(args, api_key: str, output: str, no_open: bool, debug_env: bool, mode: str, list_modes: bool): """wild - Git wrapper CLI with DiffGraph for diff commands.""" + # Handle --list-modes flag + if list_modes: + click.echo("Available processing modes:\n") + modes = list_available_modes() + for mode_name, description in modes.items(): + click.echo(f" • {mode_name}") + click.echo(f" {description}\n") + return + # Check if this is a diff command if args and args[0] == 'diff': # Handle diff command with custom logic @@ -167,9 +180,17 @@ def main(args, api_key: str, output: str, no_open: bool, debug_env: bool): files_with_content = load_file_contents(files, diff_args) try: - # Initialize the AI analysis agent - click.echo("šŸ¤– Initializing AI analysis...") - agent = CodeAnalysisAgent(api_key=api_key) + # Initialize the processor based on selected mode + click.echo(f"šŸ¤– Initializing {mode} processor...") + try: + processor = get_processor(mode, api_key=api_key) + except ValueError as e: + click.echo(f"āŒ Error: {e}", err=True) + click.echo("\nUse --list-modes to see available processing modes.", err=True) + sys.exit(1) + + # Consent check — exits cleanly if user declines cloud tier. + ensure_consent(processor.privacy_tier, processor.name) # Define progress callback def progress_callback(current_file, total_files, status): @@ -178,7 +199,7 @@ def progress_callback(current_file, total_files, status): return file_name = os.path.basename(current_file) - current_index = len(agent.graph_manager.processed_files) + 1 + current_index = len(processor.graph_manager.processed_files) + 1 if status == "processing": click.echo(f"šŸ”„ Processing {file_name} ({current_index}/{total_files})...") @@ -193,7 +214,7 @@ def progress_callback(current_file, total_files, status): # Analyze the changes with progress updates click.echo("🧠 Starting code analysis...") - analysis = agent.analyze_changes(files_with_content, progress_callback) + analysis = processor.analyze_changes(files_with_content, progress_callback) # Create analysis result click.echo("šŸ“Š Creating analysis result...") diff --git a/diffgraph/consent.py b/diffgraph/consent.py new file mode 100644 index 0000000..4ce7316 --- /dev/null +++ b/diffgraph/consent.py @@ -0,0 +1,127 @@ +""" +Consent management for cloud-tier processors. + +When a user runs `wild diff` with a processor that sends data to a cloud API, +they must explicitly consent once. The decision is persisted to: + + ~/.config/wild/config.json + +Local-tier processors (privacy_tier == "local") bypass this check entirely. + +Usage +----- + from diffgraph.consent import ensure_consent + + # Call before processor.analyze_changes(). + # Exits cleanly if the user declines. + ensure_consent(processor.privacy_tier, processor.name) +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path +from typing import Optional + +import click + +# --------------------------------------------------------------------------- +# Config storage +# --------------------------------------------------------------------------- + +CONFIG_PATH = Path.home() / ".config" / "wild" / "config.json" + +_CONSENT_KEY_MAP = { + "cloud_llm": "cloud_llm_consent_given", + "cloud_backend": "cloud_backend_consent_given", +} + + +def _load_config() -> dict: + if CONFIG_PATH.exists(): + try: + return json.loads(CONFIG_PATH.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return {} + return {} + + +def _save_config(updates: dict) -> None: + CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) + config = _load_config() + config.update(updates) + CONFIG_PATH.write_text(json.dumps(config, indent=2), encoding="utf-8") + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + +def has_consent(privacy_tier: str) -> bool: + """ + Return True if the user has already consented (or if no consent is needed). + + Local-tier processors never require consent. + """ + if privacy_tier == "local": + return True + consent_key = _CONSENT_KEY_MAP.get(privacy_tier) + if consent_key is None: + # Unknown tier — conservatively require consent. + return False + return bool(_load_config().get(consent_key)) + + +def request_consent(privacy_tier: str, processor_name: str) -> bool: + """ + Prompt the user for one-time consent. + + Prints a privacy notice, asks for confirmation, persists the decision, and + returns True if the user consented. Returns False (without raising) if the + user declines. + """ + tier_label = { + "cloud_llm": "a third-party LLM API (e.g. OpenAI)", + "cloud_backend": "the WildestAI cloud backend", + }.get(privacy_tier, "an external cloud service") + + click.echo( + f"\n⚠ Privacy notice — '{processor_name}' mode\n" + f"\n" + f" This processor sends your diff to {tier_label}.\n" + f" Your code will leave this machine.\n" + f"\n" + f" You only need to answer this once. Your choice is saved to:\n" + f" {CONFIG_PATH}\n" + ) + + if not click.confirm(" Continue?", default=False): + click.echo( + "\n Consent declined. No data was sent.\n" + "\n" + " To analyze locally (no data leaves your machine):\n" + "\n" + " wild diff\n" + "\n" + " (Local structural analysis is the default — no flag needed.)\n" + ) + return False + + consent_key = _CONSENT_KEY_MAP.get(privacy_tier) + if consent_key: + _save_config({consent_key: True}) + click.echo(" āœ“ Consent recorded. This prompt won't appear again.\n") + return True + + +def ensure_consent(privacy_tier: str, processor_name: str) -> None: + """ + Ensure the user has consented before running a cloud-tier processor. + + Exits cleanly (sys.exit(0)) if the user declines. No-op for local-tier. + """ + if has_consent(privacy_tier): + return + if not request_consent(privacy_tier, processor_name): + sys.exit(0) diff --git a/diffgraph/processing_modes/__init__.py b/diffgraph/processing_modes/__init__.py new file mode 100644 index 0000000..bcdc87a --- /dev/null +++ b/diffgraph/processing_modes/__init__.py @@ -0,0 +1,81 @@ +""" +Processing modes module for different diffgraph generation strategies. + +This module provides a registry of available processing modes and factory +functions to create processor instances. +""" + +from typing import Dict, Type, Optional +from .base import BaseProcessor + +# Registry of available processing modes +_PROCESSOR_REGISTRY: Dict[str, Type[BaseProcessor]] = {} + + +def register_processor(mode_name: str): + """ + Decorator to register a processor class. + + Args: + mode_name: The name identifier for this processing mode + + Example: + @register_processor("openai-agents-dependency-graph") + class OpenAIAgentsProcessor(BaseProcessor): + ... + """ + def decorator(cls: Type[BaseProcessor]): + _PROCESSOR_REGISTRY[mode_name] = cls + return cls + return decorator + + +def get_processor(mode_name: str, **kwargs) -> BaseProcessor: + """ + Factory function to create a processor instance. + + Args: + mode_name: The name of the processing mode + **kwargs: Configuration parameters for the processor + + Returns: + An instance of the requested processor + + Raises: + ValueError: If the mode_name is not registered + """ + if mode_name not in _PROCESSOR_REGISTRY: + available_modes = ", ".join(_PROCESSOR_REGISTRY.keys()) + raise ValueError( + f"Unknown processing mode: '{mode_name}'. " + f"Available modes: {available_modes}" + ) + + processor_class = _PROCESSOR_REGISTRY[mode_name] + return processor_class(**kwargs) + + +def list_available_modes() -> Dict[str, str]: + """ + Return a dict of {mode_name: description} for all registered processors. + + Uses the class-level ``description`` attribute so no instantiation is + required (avoids unsafe ``__new__`` and missing-arg errors). + """ + return { + name: cls.description + for name, cls in _PROCESSOR_REGISTRY.items() + } + + +# Import processors to trigger registration +from . import openai_agents_dependency # noqa: F401, E402 +from . import local_structural # noqa: F401, E402 + + +__all__ = [ + "BaseProcessor", + "register_processor", + "get_processor", + "list_available_modes", +] diff --git a/diffgraph/processing_modes/base.py b/diffgraph/processing_modes/base.py new file mode 100644 index 0000000..a0e3796 --- /dev/null +++ b/diffgraph/processing_modes/base.py @@ -0,0 +1,93 @@ +""" +Base processor interface for different diffgraph generation modes. + +This module defines the abstract base class that all processing modes must implement. +""" + +from abc import ABC, abstractmethod +from typing import Any, Dict, List, Optional, Callable + + +class BaseProcessor(ABC): + """ + Abstract base class for diffgraph processors. + + Each processing mode (e.g., Tree-sitter, OpenAI Agents, etc.) must inherit + from this class and implement analyze_changes() and privacy_tier. + + Return type contract + -------------------- + analyze_changes() returns a schema v2 DiffGraph dict. The JSON Schema + contract lives at diffgraph/schema/diffgraph-v2.schema.json. No Pydantic + model is imposed here — the schema file IS the contract. + """ + + # Class-level description; subclasses should override this string. + description: str = "No description provided." + + def __init__(self, **kwargs): + """ + Initialize the processor with configuration options. + + Args: + **kwargs: Configuration parameters specific to the processor + """ + self.config = kwargs + + @abstractmethod + def analyze_changes( + self, + files_with_content: List[Dict[str, str]], + progress_callback: Optional[Callable] = None, + ) -> Dict[str, Any]: + """ + Analyze code changes and return a schema v2 DiffGraph dict. + + Args: + files_with_content: List of dicts with keys: + - path: File path + - status: Change status (modified, untracked, etc.) + - content: File content or diff + progress_callback: Optional (current_file, total_files, status) callback. + + Returns: + dict — a schema v2 DiffGraph dict validated against + diffgraph/schema/diffgraph-v2.schema.json. + """ + pass + + @property + @abstractmethod + def privacy_tier(self) -> str: + """ + Privacy tier for this processor. One of: + + "local" — No data leaves the machine. + "cloud_llm" — Diff sent to a third-party LLM API. + Requires user consent before first use. + "cloud_backend" — Diff sent to the WildestAI backend. + Requires user consent before first use. + """ + pass + + @property + @abstractmethod + def name(self) -> str: + """Return the name/identifier of this processing mode.""" + pass + + @property + @abstractmethod + def description(self) -> str: + """Return a human-readable description of this processing mode.""" + pass + + @classmethod + def get_required_config(cls) -> List[str]: + """ + Return list of required configuration parameters for this processor. + + Returns: + List of configuration parameter names + """ + return [] diff --git a/diffgraph/processing_modes/local_structural.py b/diffgraph/processing_modes/local_structural.py new file mode 100644 index 0000000..af7c838 --- /dev/null +++ b/diffgraph/processing_modes/local_structural.py @@ -0,0 +1,91 @@ +""" +Local structural processor — placeholder for the tree-sitter-based processor. + +When Phase 1 (PR #13, tree-sitter extraction) merges into main, this file is +replaced with a real import of TreeSitterDependencyProcessor. Until then, this +placeholder: + + - registers the "local-structural" mode so the CLI's default mode works + - reports privacy_tier = "local" (no data leaves the machine) + - prints a human-readable message and exits cleanly rather than silently + producing empty output + +This lets Phase 2 (consent + privacy default) ship and be tested before +Phase 1 lands. +""" + +import sys +from typing import Any, Callable, Dict, List, Optional + +import click + +from .base import BaseProcessor +from . import register_processor + + +# --------------------------------------------------------------------------- +# Attempt to import the real tree-sitter processor. +# It only exists on the phase1-tree-sitter-dependency-extraction branch; +# on main / this branch it will be absent. +# --------------------------------------------------------------------------- +try: + from .tree_sitter_dependency import TreeSitterDependencyProcessor as _RealProcessor + _TREE_SITTER_AVAILABLE = True +except ImportError: + _RealProcessor = None # type: ignore[assignment,misc] + _TREE_SITTER_AVAILABLE = False + + +@register_processor("local-structural") +class LocalStructuralProcessor(BaseProcessor): + """ + Local static analysis via tree-sitter — no data leaves the machine. + + Extracts Python symbols and import relationships from the diff using + tree-sitter AST parsing. Produces schema v2 output. + """ + + description: str = ( + "Local static analysis (tree-sitter). No data leaves the machine. " + "Extracts symbols and import relationships from the diff. " + "Output: schema v2 DiffGraph dict." + ) + + def __init__(self, **kwargs): + super().__init__(**kwargs) + if _TREE_SITTER_AVAILABLE: + self._impl = _RealProcessor(**kwargs) + else: + self._impl = None + + @property + def name(self) -> str: + return "local-structural" + + @property + def privacy_tier(self) -> str: + return "local" + + def analyze_changes( + self, + files_with_content: List[Dict[str, str]], + progress_callback: Optional[Callable] = None, + ) -> Dict[str, Any]: + """ + Delegate to TreeSitterDependencyProcessor if available, otherwise + print an actionable message and exit. + """ + if self._impl is not None: + return self._impl.analyze_changes(files_with_content, progress_callback) + + # Phase 1 not yet merged — print an informative message. + click.echo( + "\n⚠ Local structural analysis (tree-sitter) is not yet available " + "in this build.\n" + " It ships with Phase 1 of the v2 roadmap (PR #13).\n\n" + " In the meantime, use the OpenAI-powered mode:\n\n" + " wild diff --mode openai-agents-dependency-graph\n\n" + " Note: that mode sends your diff to OpenAI's API.\n", + err=True, + ) + sys.exit(1) diff --git a/diffgraph/ai_analysis.py b/diffgraph/processing_modes/openai_agents_dependency.py similarity index 83% rename from diffgraph/ai_analysis.py rename to diffgraph/processing_modes/openai_agents_dependency.py index c8c1990..ff2070e 100644 --- a/diffgraph/ai_analysis.py +++ b/diffgraph/processing_modes/openai_agents_dependency.py @@ -1,14 +1,41 @@ -from typing import List, Dict, Optional, Tuple -from agents import Agent, Runner +""" +OpenAI Agents SDK processor for dependency graph generation. + +This processor uses OpenAI's Agents SDK to analyze code changes and generate +dependency graphs at the component level. +""" + +from __future__ import annotations + import os -from pydantic import BaseModel -from .graph_manager import GraphManager, FileStatus, ChangeType, ComponentNode -import time import random -import openai -import re -import networkx as nx +import time from enum import Enum +from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Tuple + +import networkx as nx +from pydantic import BaseModel + +from ..graph_manager import ChangeType, ComponentNode, FileStatus, GraphManager +from . import register_processor +from .base import BaseProcessor + +# The OpenAI Agents SDK is an optional dependency. Import lazily so that +# `wild diff --mode local-structural` (the default) works even when the SDK +# is not installed. +try: + from agents import Agent, Runner # type: ignore[import-untyped] + _AGENTS_SDK_AVAILABLE = True +except ImportError: # pragma: no cover + Agent = None # type: ignore[assignment,misc] + Runner = None # type: ignore[assignment,misc] + _AGENTS_SDK_AVAILABLE = False + +try: + import openai as _openai_module +except ImportError: # pragma: no cover + _openai_module = None # type: ignore[assignment] + class FileChange(BaseModel): """Model representing a file change.""" @@ -16,10 +43,6 @@ class FileChange(BaseModel): status: str content: str -class DiffAnalysis(BaseModel): - """Model representing the analysis of code changes.""" - summary: str - mermaid_diagram: str class ComponentAnalysis(BaseModel): """Model representing a single component's analysis.""" @@ -32,17 +55,20 @@ class ComponentAnalysis(BaseModel): dependents: List[str] = [] nested_components: List[str] = [] # names of components that are nested within this one + class CodeChangeAnalysis(BaseModel): """Model representing the analysis of code changes from the LLM.""" summary: str components: List[ComponentAnalysis] impact: str + class DependencyMode(Enum): """Mode for processing dependency relationships.""" DEPENDENCY = "dependency" # Process components that this component depends on DEPENDENT = "dependent" # Process components that depend on this component + def exponential_backoff_retry(func): """Decorator to implement exponential backoff retry logic using API rate limit information.""" def wrapper(*args, **kwargs): @@ -53,7 +79,7 @@ def wrapper(*args, **kwargs): for attempt in range(max_retries): try: return func(*args, **kwargs) - except openai.RateLimitError as e: + except (_openai_module.RateLimitError if _openai_module else Exception) as e: if attempt == max_retries - 1: # Last attempt raise # Re-raise the exception if all retries failed @@ -76,11 +102,26 @@ def wrapper(*args, **kwargs): raise # Re-raise other exceptions immediately return wrapper -class CodeAnalysisAgent: - """Agent for analyzing code changes using OpenAI's Agents SDK.""" - def __init__(self, api_key: Optional[str] = None): - """Initialize the agent with OpenAI API key.""" +@register_processor("openai-agents-dependency-graph") +class OpenAIAgentsProcessor(BaseProcessor): + """ + Processor using OpenAI Agents SDK for dependency graph generation. + + This processor analyzes code changes at the component level (classes, functions, methods) + and builds a dependency graph showing relationships between components. + """ + + def __init__(self, api_key: Optional[str] = None, **kwargs): + """Initialize the processor with OpenAI API key.""" + super().__init__(**kwargs) + if not _AGENTS_SDK_AVAILABLE: + raise ImportError( + "The OpenAI Agents SDK ('agents' package) is not installed.\n" + "Install it with: pip install openai-agents\n" + "Or use the local mode (no cloud needed): wild diff" + ) + self.api_key = api_key or os.getenv("OPENAI_API_KEY") if not self.api_key: raise ValueError("OpenAI API key is required. Set OPENAI_API_KEY environment variable.") @@ -122,6 +163,27 @@ def __init__(self, api_key: Optional[str] = None): self.graph_manager = GraphManager() + # Class-level description (read by list_available_modes without instantiation). + description: str = ( + "Cloud LLM: OpenAI Agents SDK — component-level dependency graph analysis. " + "Sends diff to OpenAI API; requires consent on first use." + ) + + @property + def name(self) -> str: + """Return the name of this processing mode.""" + return "openai-agents-dependency-graph" + + @property + def privacy_tier(self) -> str: + """Sends diff to OpenAI API — requires user consent on first use.""" + return "cloud_llm" + + @classmethod + def get_required_config(cls) -> List[str]: + """Return required configuration parameters.""" + return ["api_key"] + def _determine_change_type(self, status: str) -> ChangeType: """Convert git status to ChangeType.""" if status == "untracked": @@ -137,7 +199,11 @@ def _run_agent_analysis(self, prompt: str) -> str: result = Runner.run_sync(self.agent, prompt) return result.final_output - def analyze_changes(self, files_with_content: List[Dict[str, str]], progress_callback=None) -> DiffAnalysis: + def analyze_changes( + self, + files_with_content: List[Dict[str, str]], + progress_callback: Optional[Callable] = None + ) -> DiffAnalysis: """ Analyze code changes using the OpenAI agent, processing files incrementally. @@ -341,4 +407,4 @@ def _process_dependencies(self, comp: ComponentNode, current_file: str, mode: De break if not found: - print(f"Warning: Could not resolve {mode.value} '{item}' for component '{comp.name}' in {current_file}") \ No newline at end of file + print(f"Warning: Could not resolve {mode.value} '{item}' for component '{comp.name}' in {current_file}") diff --git a/docs/ADDING_PROCESSING_MODES.md b/docs/ADDING_PROCESSING_MODES.md new file mode 100644 index 0000000..cd474dd --- /dev/null +++ b/docs/ADDING_PROCESSING_MODES.md @@ -0,0 +1,257 @@ +# Adding New Processing Modes + +This guide explains how to add new processing modes to DiffGraph. + +## Overview + +Processing modes allow DiffGraph to analyze code changes from different perspectives. Each mode can focus on different aspects like: +- User context +- System architecture +- Module & component level interactions +- Data flow +- Abstract syntax tree analysis +- Dependency graphs + +## Architecture + +The processing mode system is built on: +1. **BaseProcessor**: Abstract base class that defines the interface +2. **Processor Registry**: Automatic registration of processing modes +3. **Factory Pattern**: `get_processor()` creates instances based on mode name + +## Creating a New Processing Mode + +### Step 1: Create Your Processor Class + +Create a new file in `diffgraph/processing_modes/` (e.g., `tree_sitter_dependency.py`): + +```python +from typing import List, Dict, Optional, Callable +from .base import BaseProcessor, DiffAnalysis +from . import register_processor + +@register_processor("tree-sitter-dependency-graph") +class TreeSitterProcessor(BaseProcessor): + """ + Processor using Tree-sitter for AST-based dependency analysis. + """ + + def __init__(self, **kwargs): + """Initialize the processor with any required configuration.""" + super().__init__(**kwargs) + # Initialize your tools/libraries here + # self.parser = ... + + @property + def name(self) -> str: + """Return the mode identifier.""" + return "tree-sitter-dependency-graph" + + @property + def description(self) -> str: + """Return a human-readable description.""" + return "Uses Tree-sitter for AST-based dependency analysis" + + @classmethod + def get_required_config(cls) -> List[str]: + """List any required configuration parameters.""" + return [] # e.g., ["api_key", "model_name"] + + def analyze_changes( + self, + files_with_content: List[Dict[str, str]], + progress_callback: Optional[Callable] = None + ) -> DiffAnalysis: + """ + Analyze code changes and generate diffgraph. + + Args: + files_with_content: List of dicts with 'path', 'status', 'content' + progress_callback: Optional callback(current_file, total_files, status) + + Returns: + DiffAnalysis with 'summary' and 'mermaid_diagram' + """ + # Your analysis logic here + summary = "Analysis summary..." + mermaid_diagram = "graph LR\\n A --> B" + + return DiffAnalysis( + summary=summary, + mermaid_diagram=mermaid_diagram + ) +``` + +### Step 2: Register the Processor + +Import your processor in `diffgraph/processing_modes/__init__.py`: + +```python +# Add to the imports section at the bottom +from . import tree_sitter_dependency # noqa: F401, E402 +``` + +The `@register_processor` decorator automatically registers your mode. + +### Step 3: Test Your Processor + +```python +# Test your processor +from diffgraph.processing_modes import get_processor, list_available_modes + +# Check it's registered +modes = list_available_modes() +assert "tree-sitter-dependency-graph" in modes + +# Create an instance +processor = get_processor("tree-sitter-dependency-graph") + +# Test analysis +files = [ + { + "path": "test.py", + "status": "modified", + "content": "def hello(): pass" + } +] +result = processor.analyze_changes(files) +print(result.summary) +print(result.mermaid_diagram) +``` + +### Step 4: Use via CLI + +```bash +# List all modes +wild diff --list-modes + +# Use your new mode +wild diff --mode tree-sitter-dependency-graph +``` + +## Best Practices + +### 1. Progress Reporting +Use the `progress_callback` to report progress: + +```python +if progress_callback: + progress_callback(current_file, total_files, "processing") +``` + +Status values: `"processing"`, `"analyzing"`, `"processing_components"`, `"completed"`, `"error"` + +### 2. Error Handling +Handle errors gracefully and provide useful error messages: + +```python +try: + # Your analysis code + pass +except Exception as e: + if progress_callback: + progress_callback(current_file, total_files, "error") + # Log or handle the error appropriately +``` + +### 3. Configuration +Define required configuration in `get_required_config()`: + +```python +@classmethod +def get_required_config(cls) -> List[str]: + return ["api_key", "model_name"] +``` + +The CLI will pass configuration via `**kwargs` to your `__init__` method. + +### 4. Mermaid Diagram Generation +Generate valid Mermaid syntax for visualization: + +```python +mermaid = ["graph LR"] +mermaid.append(" A[Component A] --> B[Component B]") +mermaid.append(" B --> C[Component C]") +return "\\n".join(mermaid) +``` + +### 5. Reusability +Consider using the existing `GraphManager` class for managing component graphs: + +```python +from ..graph_manager import GraphManager, ChangeType + +self.graph_manager = GraphManager() +self.graph_manager.add_file(file_path, ChangeType.MODIFIED) +self.graph_manager.add_component(name, file_path, change_type, ...) +mermaid_diagram = self.graph_manager.get_mermaid_diagram() +``` + +## Example Modes to Implement + +### 1. Tree-sitter Dependency Graph +Focus: AST-based static analysis +- Use Tree-sitter for language-agnostic parsing +- Extract imports, function calls, class inheritance +- Build dependency graph from AST structure + +### 2. Data Flow Analysis +Focus: How data flows through the code +- Track variable assignments and transformations +- Identify data sources and sinks +- Visualize data pipelines + +### 3. User Context Analysis +Focus: User-facing changes +- Identify UI components and API endpoints +- Analyze user interaction flows +- Show impact on user experience + +### 4. Architecture Analysis +Focus: System-level structure +- Identify modules and layers +- Analyze architectural patterns +- Show system component relationships + +## Testing + +Create tests in `tests/processing_modes/`: + +```python +import pytest +from diffgraph.processing_modes import get_processor + +def test_tree_sitter_processor(): + processor = get_processor("tree-sitter-dependency-graph") + + files = [{ + "path": "example.py", + "status": "modified", + "content": "def foo(): return 42" + }] + + result = processor.analyze_changes(files) + + assert result.summary + assert result.mermaid_diagram + assert "graph" in result.mermaid_diagram +``` + +## Documentation + +Update the README.md with your new mode: + +```markdown +### Available Modes + +#### `tree-sitter-dependency-graph` +Uses Tree-sitter for AST-based dependency analysis. This mode: +- Parses code using language-specific grammars +- Extracts structural dependencies from the AST +- Generates dependency graphs without AI +- Best for quick, deterministic analysis +``` + +## Questions? + +If you have questions or need help implementing a new processing mode, please open an issue on GitHub. diff --git a/setup.py b/setup.py index 33e623f..5624d69 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ setup( name="wild", - version="1.0.0", + version="1.1.0", packages=find_packages(), install_requires=[ "click>=8.1.7", diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..dcf732c --- /dev/null +++ b/tests/README.md @@ -0,0 +1,100 @@ +# Tests + +This directory contains tests for the DiffGraph CLI tool. + +## Structure + +``` +tests/ +ā”œā”€ā”€ __init__.py +ā”œā”€ā”€ conftest.py # Shared pytest fixtures +ā”œā”€ā”€ README.md # This file +ā”œā”€ā”€ test_cli.py # CLI integration tests +└── processing_modes/ + ā”œā”€ā”€ __init__.py + ā”œā”€ā”€ test_base.py # Base processor tests + ā”œā”€ā”€ test_registry.py # Registry and factory tests + └── test_openai_agents.py # OpenAI processor tests +``` + +## Running Tests + +### Install pytest + +```bash +pip install pytest pytest-cov +``` + +### Run all tests + +```bash +pytest +``` + +### Run with coverage + +```bash +pytest --cov=diffgraph --cov-report=html +``` + +### Run specific test file + +```bash +pytest tests/test_cli.py +``` + +### Run specific test function + +```bash +pytest tests/processing_modes/test_registry.py::test_list_available_modes +``` + +### Run with verbose output + +```bash +pytest -v +``` + +## Test Categories + +### Unit Tests +- `processing_modes/test_base.py` - Base processor interface +- `processing_modes/test_registry.py` - Registry and factory pattern +- `processing_modes/test_openai_agents.py` - OpenAI processor specifics + +### Integration Tests +- `test_cli.py` - CLI command integration + +## Adding New Tests + +When adding a new processing mode, create a corresponding test file: + +```bash +tests/processing_modes/test_your_mode.py +``` + +Example: +```python +"""Tests for your new processor.""" +import pytest +from diffgraph.processing_modes import get_processor + +def test_your_processor_initialization(): + """Test your processor can be initialized.""" + processor = get_processor("your-mode-name", config="value") + assert processor.name == "your-mode-name" + +def test_your_processor_analyze(): + """Test analysis functionality.""" + processor = get_processor("your-mode-name", config="value") + result = processor.analyze_changes([...]) + assert result.summary + assert result.mermaid_diagram +``` + +## Continuous Integration + +These tests are designed to run in CI/CD pipelines. Make sure: +- All tests pass before committing +- New features include tests +- Test coverage stays high diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..810e6d9 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,3 @@ +""" +Tests for DiffGraph CLI. +""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..e1979b0 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,28 @@ +""" +Pytest configuration and shared fixtures. +""" +import pytest +import os + + +@pytest.fixture +def mock_api_key(): + """Provide a mock API key for testing.""" + return "test-api-key-12345" + + +@pytest.fixture +def sample_file_changes(): + """Provide sample file changes for testing.""" + return [ + { + "path": "test.py", + "status": "modified", + "content": "def hello():\n print('Hello, world!')\n" + }, + { + "path": "new_file.py", + "status": "untracked", + "content": "class MyClass:\n pass\n" + } + ] diff --git a/tests/processing_modes/__init__.py b/tests/processing_modes/__init__.py new file mode 100644 index 0000000..e70e735 --- /dev/null +++ b/tests/processing_modes/__init__.py @@ -0,0 +1,3 @@ +""" +Tests for processing modes. +""" diff --git a/tests/processing_modes/test_base.py b/tests/processing_modes/test_base.py new file mode 100644 index 0000000..9aecd47 --- /dev/null +++ b/tests/processing_modes/test_base.py @@ -0,0 +1,47 @@ +""" +Tests for base processor interface. +""" +import pytest +from diffgraph.processing_modes.base import BaseProcessor + + +def test_base_processor_is_abstract(): + """Test that BaseProcessor cannot be instantiated directly.""" + with pytest.raises(TypeError): + BaseProcessor() # type: ignore[abstract] + + +def test_base_processor_has_privacy_tier(): + """Test that BaseProcessor declares privacy_tier as an abstract property.""" + # Concrete subclass must implement privacy_tier; if omitted, instantiation fails. + class _NoPrivacyTier(BaseProcessor): + description = "test" + + @property + def name(self) -> str: + return "no-privacy-tier" + + def analyze_changes(self, files_with_content, progress_callback=None): + return {} + + with pytest.raises(TypeError): + _NoPrivacyTier() + + +def test_base_processor_description_class_attr(): + """list_available_modes() reads description as a class attr; verify it works.""" + class _ConcreteProcessor(BaseProcessor): + description = "A test processor." + + @property + def name(self) -> str: + return "test" + + @property + def privacy_tier(self) -> str: + return "local" + + def analyze_changes(self, files_with_content, progress_callback=None): + return {} + + assert _ConcreteProcessor.description == "A test processor." diff --git a/tests/processing_modes/test_openai_agents.py b/tests/processing_modes/test_openai_agents.py new file mode 100644 index 0000000..2a87adc --- /dev/null +++ b/tests/processing_modes/test_openai_agents.py @@ -0,0 +1,41 @@ +""" +Tests for OpenAI Agents processor. + +Tests that require the OpenAI Agents SDK are skipped automatically when the +'agents' package is not installed (e.g. in CI or local-only environments). +""" +import pytest +from diffgraph.processing_modes import get_processor +from diffgraph.processing_modes.openai_agents_dependency import _AGENTS_SDK_AVAILABLE + +pytestmark = pytest.mark.skipif( + not _AGENTS_SDK_AVAILABLE, + reason="openai-agents SDK not installed", +) + + +def test_openai_processor_initialization(): + """Test OpenAI processor can be initialized.""" + processor = get_processor("openai-agents-dependency-graph", api_key="test-key") + + assert processor.name == "openai-agents-dependency-graph" + assert "OpenAI" in processor.description + assert hasattr(processor, 'graph_manager') + + +def test_openai_processor_requires_api_key(): + """Test that OpenAI processor requires API key.""" + with pytest.raises(ValueError) as exc_info: + get_processor("openai-agents-dependency-graph") + + assert "API key" in str(exc_info.value) + + +def test_openai_processor_metadata(): + """Test OpenAI processor metadata.""" + processor = get_processor("openai-agents-dependency-graph", api_key="test-key") + + assert processor.name == "openai-agents-dependency-graph" + assert len(processor.description) > 0 + assert "dependency" in processor.description.lower() + assert "component" in processor.description.lower() diff --git a/tests/processing_modes/test_registry.py b/tests/processing_modes/test_registry.py new file mode 100644 index 0000000..54c2b01 --- /dev/null +++ b/tests/processing_modes/test_registry.py @@ -0,0 +1,88 @@ +""" +Tests for processor registry and factory. +""" +import pytest +from diffgraph.processing_modes import get_processor, list_available_modes +from diffgraph.processing_modes.openai_agents_dependency import _AGENTS_SDK_AVAILABLE + +_skip_openai = pytest.mark.skipif( + not _AGENTS_SDK_AVAILABLE, + reason="openai-agents SDK not installed", +) + + +def test_list_available_modes(): + """Test listing available processing modes.""" + modes = list_available_modes() + + assert isinstance(modes, dict) + assert len(modes) >= 1 + # Both built-in processors must be listed. + assert "openai-agents-dependency-graph" in modes + assert "local-structural" in modes + # All descriptions must be non-empty strings. + for name, desc in modes.items(): + assert isinstance(desc, str) and len(desc) > 0, ( + f"Mode '{name}' has empty or non-string description" + ) + + +def test_list_available_modes_no_instantiation(): + """ + list_available_modes() must not instantiate any processor class. + + Instantiation of openai-agents-dependency-graph requires the agents SDK + and an API key — neither of which is available in test environments. + If this test passes without those, the implementation is safe. + """ + # Should not raise even without API key or agents SDK. + modes = list_available_modes() + assert "openai-agents-dependency-graph" in modes + + +@_skip_openai +def test_get_processor_success(): + """Test getting a processor instance.""" + processor = get_processor("openai-agents-dependency-graph", api_key="test-key") + + assert processor is not None + assert processor.name == "openai-agents-dependency-graph" + assert hasattr(processor, 'analyze_changes') + assert callable(processor.analyze_changes) + + +def test_get_processor_invalid_mode(): + """Test that getting an invalid processor raises ValueError.""" + with pytest.raises(ValueError) as exc_info: + get_processor("non-existent-mode") + + assert "Unknown processing mode" in str(exc_info.value) + assert "non-existent-mode" in str(exc_info.value) + + +@_skip_openai +def test_processor_interface(): + """Test that processor has required interface.""" + processor = get_processor("openai-agents-dependency-graph", api_key="test-key") + + # Check required attributes + assert hasattr(processor, 'name') + assert hasattr(processor, 'description') + assert hasattr(processor, 'analyze_changes') + assert hasattr(processor, 'privacy_tier') + + # Check properties work + assert isinstance(processor.name, str) + assert isinstance(processor.description, str) + assert processor.privacy_tier == "cloud_llm" + assert callable(processor.analyze_changes) + + +def test_local_structural_processor_privacy_tier(): + """LocalStructuralProcessor must report local privacy tier without instantiation.""" + from diffgraph.processing_modes.local_structural import LocalStructuralProcessor + # Check the class attribute without instantiation (no tree-sitter needed). + # Instantiated version: + p = LocalStructuralProcessor() + assert p.privacy_tier == "local" + assert p.name == "local-structural" diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..49042e8 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,59 @@ +""" +Tests for CLI functionality. +""" +import pytest +from click.testing import CliRunner +from diffgraph.cli import main + + +@pytest.fixture +def cli_runner(): + """Fixture providing a Click CLI runner.""" + return CliRunner() + + +def test_cli_list_modes(cli_runner): + """Test --list-modes flag.""" + result = cli_runner.invoke(main, ['diff', '--list-modes']) + + assert result.exit_code == 0 + assert 'Available processing modes' in result.output + assert 'openai-agents-dependency-graph' in result.output + + +def test_cli_help_shows_mode_option(cli_runner): + """Test that help shows --mode option.""" + result = cli_runner.invoke(main, ['diff', '--help']) + + assert result.exit_code == 0 + assert '--mode' in result.output or '-m' in result.output + assert '--list-modes' in result.output + + +def test_cli_help_shows_default_mode(cli_runner): + """Test that help shows default mode.""" + result = cli_runner.invoke(main, ['diff', '--help']) + + assert result.exit_code == 0 + # Mode name might be wrapped, so check for components + assert 'openai-agents-dependency-graph' in result.output or \ + ('openai-' in result.output and 'dependency-graph' in result.output) + + +def test_cli_invalid_mode_error(cli_runner): + """Test that invalid mode shows helpful error.""" + # We need a git repo and changes for this to work properly + # This test will fail early with invalid mode error + result = cli_runner.invoke(main, ['diff', '--mode', 'invalid-mode']) + + # Should show error about invalid mode + # (might fail earlier if not in a git repo, which is fine) + assert result.exit_code != 0 or 'Available processing modes' in result.output + + +def test_cli_version(cli_runner): + """Test --version flag.""" + result = cli_runner.invoke(main, ['--version']) + + assert result.exit_code == 0 + assert 'version' in result.output.lower()