diff --git a/diffgraph/processing_modes/__init__.py b/diffgraph/processing_modes/__init__.py index ef4b1a6..e507d86 100644 --- a/diffgraph/processing_modes/__init__.py +++ b/diffgraph/processing_modes/__init__.py @@ -87,7 +87,11 @@ def list_available_modes() -> Dict[str, str]: # Import processors to trigger registration # This will be populated as we add more processors -from . import openai_agents_dependency # noqa: F401, E402 +try: + from . import openai_agents_dependency # noqa: F401, E402 +except ImportError: + # openai-agents not installed, skip this processor + pass # Import optional processors try: diff --git a/diffgraph/processing_modes/schema_v2_adapter.py b/diffgraph/processing_modes/schema_v2_adapter.py new file mode 100644 index 0000000..dce54d6 --- /dev/null +++ b/diffgraph/processing_modes/schema_v2_adapter.py @@ -0,0 +1,262 @@ +""" +Schema v2 output adapter for the tree-sitter processor. + +Converts ExtractedComponent + ExtractedDependency objects into the DiffGraph +v2.0 JSON schema format defined in diffgraph/schema/diffgraph-v2.schema.json. + +Key invariants: +- Every claim carries analysis_source="structural" — deterministic, no LLM. +- Every symbol carries change_kind computed by comparing pre/post AST snapshots. +- No network calls are made by this module. + +Consumed by TreeSitterProcessor.analyze_changes_v2(). +""" + +from datetime import datetime, timezone +from typing import List, Dict, Optional, Any +from dataclasses import dataclass + + +# --------------------------------------------------------------------------- +# Internal data types +# --------------------------------------------------------------------------- + +@dataclass +class SymbolChange: + """A symbol (function, class, method) with its change_kind.""" + qualified_name: str # e.g. "ClassName.method_name" or "standalone_func" + file_path: str + component_type: str # "function" | "method" | "container" + parent: Optional[str] # Class name for methods; None for top-level symbols + change_kind: str # "added" | "modified" | "deleted" | "unchanged" + start_line: int # 0-indexed AST start (converted to 1-indexed in output) + end_line: int # 0-indexed AST end + + +# --------------------------------------------------------------------------- +# Symbol identity +# --------------------------------------------------------------------------- + +def qualified_name_for(component: Any) -> str: + """ + Stable qualified name for a component. + + Rules: + - standalone function / top-level class → ``func_name`` + - method → ``ClassName.method_name`` + """ + if component.parent: + return f"{component.parent}.{component.name}" + return component.name + + +def symbol_id(file_path: str, qname: str) -> str: + """ + Schema v2 stable symbol ID. + + Format: ``sym::::`` + + The ``sym::`` prefix allows consumers to distinguish symbol IDs from + file-scope synthetic IDs (``sym::file::``). + """ + return f"sym::{file_path}::{qname}" + + +def file_symbol_id(file_path: str) -> str: + """Synthetic ID for a file-scope import source (schema v2 D1).""" + return f"sym::file::{file_path}" + + +# --------------------------------------------------------------------------- +# Symbol diff computation +# --------------------------------------------------------------------------- + +def _content_slice(content: Optional[str], start_line: int, end_line: int) -> str: + """Extract lines [start_line, end_line] (0-indexed, inclusive) from content.""" + if not content: + return "" + lines = content.splitlines() + # Guard against stale line numbers + start = max(0, start_line) + end = min(len(lines) - 1, end_line) + return "\n".join(lines[start:end + 1]) + + +def compute_symbol_diff( + pre_components: Optional[List[Any]], + post_components: Optional[List[Any]], + pre_content: Optional[str], + post_content: Optional[str], +) -> List[SymbolChange]: + """ + Compute per-symbol change_kind by comparing pre-change and post-change + component lists extracted from the AST. + + Algorithm + --------- + 1. Build lookup maps keyed by qualified_name for each snapshot. + 2. Union of keys → iterate. + 3. Present in post only → ``"added"`` + Present in pre only → ``"deleted"`` + Present in both: + same content slice → ``"unchanged"`` + different content slice → ``"modified"`` + + Why content slice comparison? + AST line numbers shift when code is inserted above; comparing the + *text* of the symbol body (not just the line range) correctly identifies + symbols that moved without changing as ``"unchanged"``. + + Args: + pre_components: Components extracted from the pre-change version + (``git show HEAD:``). None or [] for new files. + post_components: Components extracted from the post-change version + (working tree / staged). None or [] for deleted files. + pre_content: Full file text of the pre-change version. + post_content: Full file text of the post-change version. + + Returns: + List of SymbolChange, one per unique symbol across both snapshots. + """ + pre_map: Dict[str, Any] = { + qualified_name_for(c): c for c in (pre_components or []) + } + post_map: Dict[str, Any] = { + qualified_name_for(c): c for c in (post_components or []) + } + + all_keys = set(pre_map) | set(post_map) + changes: List[SymbolChange] = [] + + for key in sorted(all_keys): # sorted for deterministic output order + pre_comp = pre_map.get(key) + post_comp = post_map.get(key) + + if post_comp and not pre_comp: + change_kind = "added" + comp = post_comp + elif pre_comp and not post_comp: + change_kind = "deleted" + comp = pre_comp + else: + # Both snapshots have the symbol — compare content + pre_text = _content_slice(pre_content, pre_comp.start_line or 0, pre_comp.end_line or 0) + post_text = _content_slice(post_content, post_comp.start_line or 0, post_comp.end_line or 0) + change_kind = "unchanged" if pre_text == post_text else "modified" + comp = post_comp # prefer post location for "where is it now" + + changes.append(SymbolChange( + qualified_name=qualified_name_for(comp), + file_path=comp.file_path or "", + component_type=comp.component_type, + parent=comp.parent, + change_kind=change_kind, + start_line=comp.start_line or 0, + end_line=comp.end_line or 0, + )) + + return changes + + +# --------------------------------------------------------------------------- +# Schema v2 output builder +# --------------------------------------------------------------------------- + +def build_symbol_entry(sc: SymbolChange) -> Dict: + """Convert a SymbolChange into a schema v2 ``symbols[]`` entry.""" + return { + "id": symbol_id(sc.file_path, sc.qualified_name), + "qualified_name": sc.qualified_name, + "file": sc.file_path, + "change_kind": sc.change_kind, + "analysis_source": "structural", + "evidence": [ + { + "kind": "ast_parse", + "location": { + "file": sc.file_path, + # Schema v2 line numbers are 1-indexed + "line_start": sc.start_line + 1, + "line_end": sc.end_line + 1, + }, + } + ], + } + + +def build_import_relationship( + source_file: str, + imported_module: str, + source_line: Optional[int] = None, +) -> Dict: + """ + Build a schema v2 ``relationships[]`` entry for an import statement. + + The ``from`` side uses a file-scope synthetic symbol ID (schema v2 D1) to + represent the module-level import, not a specific function. + """ + entry: Dict = { + "from": file_symbol_id(source_file), + "to": imported_module, + "kind": "imports", + "analysis_source": "structural", + "evidence": [ + { + "kind": "import_statement", + "location": {"file": source_file}, + } + ], + } + if source_line is not None: + entry["evidence"][0]["location"]["line_start"] = source_line + 1 + return entry + + +def build_schema_v2_output( + *, + symbol_changes: List[SymbolChange], + import_relationships: List[Dict], # already-built relationship dicts + file_changes: List[Dict], # [{"path": str, "change_kind": str}] + diff_ref: Dict, # {from, to, kind} + wild_version: str, + analysis_duration_ms: int, + languages_detected: List[str], +) -> Dict: + """ + Assemble a complete schema v2 JSON dict from structural analysis data. + + Invariants enforced here: + - ``summary: null`` (no LLM → D2 from JSON-SCHEMA.md) + - ``warnings: []`` (always present → D4) + - ``metadata.privacy_tier: "local"`` (no network calls) + - ``metadata.cloud_providers_used: []`` + + Args: + symbol_changes: Output of compute_symbol_diff(). + import_relationships: Output of build_import_relationship() calls. + file_changes: List of dicts with "path" and "change_kind". + diff_ref: {"from": str, "to": str, "kind": str} + wild_version: Semver string, e.g. "2.0.0". + analysis_duration_ms: Wall-clock ms for the full analysis run. + languages_detected: List of language slugs, e.g. ["python"]. + + Returns: + A dict that validates against diffgraph-v2.schema.json. + """ + return { + "schema_version": "2.0", + "generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "wild_version": wild_version, + "diff_ref": diff_ref, + "files": file_changes, + "symbols": [build_symbol_entry(sc) for sc in symbol_changes], + "relationships": import_relationships, + "summary": None, # D2: null when no LLM + "warnings": [], # D4: always present + "metadata": { + "privacy_tier": "local", + "cloud_providers_used": [], + "analysis_duration_ms": analysis_duration_ms, + "languages_detected": sorted(languages_detected), + }, + } diff --git a/diffgraph/processing_modes/tree_sitter_dependency.py b/diffgraph/processing_modes/tree_sitter_dependency.py index cebad59..334c7f8 100644 --- a/diffgraph/processing_modes/tree_sitter_dependency.py +++ b/diffgraph/processing_modes/tree_sitter_dependency.py @@ -8,6 +8,7 @@ from typing import List, Dict, Optional, Set, Tuple, Callable import subprocess import re +import time from pathlib import Path from dataclasses import dataclass @@ -24,6 +25,11 @@ from ..graph_manager import GraphManager, ChangeType, ComponentNode from .base import BaseProcessor, DiffAnalysis +from .schema_v2_adapter import ( + compute_symbol_diff, + build_schema_v2_output, + build_import_relationship, +) from . import register_processor @@ -788,6 +794,195 @@ def traverse(node): traverse(tree.root_node) return calls + # ------------------------------------------------------------------ + # Pre/post content helpers (Gap 7 from PR-13-REVIEW.md) + # ------------------------------------------------------------------ + + def _get_pre_change_content(self, file_path: str, staged: bool = False) -> Optional[str]: + """ + Fetch the pre-change version of a file. + + - For unstaged diffs: ``git show HEAD:`` + - For staged diffs: ``git show HEAD:`` (same — HEAD is the base) + - New / untracked files: returns None (no pre-change version exists) + """ + try: + result = subprocess.run( + ["git", "show", f"HEAD:{file_path}"], + capture_output=True, text=True, check=True, + ) + return result.stdout + except subprocess.CalledProcessError: + return None # File is new — no pre-change version + + def _get_post_change_content(self, file_path: str, staged: bool = False) -> Optional[str]: + """ + Fetch the post-change version of a file. + + - For unstaged diffs: read from the working tree filesystem + - For staged diffs: ``git show :0:`` (index / staging area) + - Deleted files: returns None + """ + if staged: + try: + result = subprocess.run( + ["git", "show", f":0:{file_path}"], + capture_output=True, text=True, check=True, + ) + return result.stdout + except subprocess.CalledProcessError: + return None # Deleted in staging area + else: + try: + with open(file_path, "r", encoding="utf-8", errors="replace") as f: + return f.read() + except OSError: + return None # File deleted or unreadable + + # ------------------------------------------------------------------ + # Schema v2 entry point (Gap 1–7 from PR-13-REVIEW.md) + # ------------------------------------------------------------------ + + def analyze_changes_v2( + self, + files_with_content: List[Dict[str, str]], + diff_ref: Optional[Dict] = None, + wild_version: str = "2.0.0-dev", + staged: bool = False, + progress_callback: Optional[Callable] = None, + ) -> Dict: + """ + Analyse code changes and return a DiffGraph v2.0-compliant dict. + + Differences from analyze_changes(): + - Compares pre/post AST snapshots to compute ``change_kind`` per symbol. + - Emits ``symbols[]`` + ``relationships[]`` with ``analysis_source``. + - No GraphManager, no Mermaid, no LLM — purely structural. + - ``metadata.privacy_tier`` is always ``"local"``. + + Args: + files_with_content: Same format as analyze_changes(). + diff_ref: Optional diff provenance dict. Defaults to + {"from": "HEAD", "to": "working_tree", "kind": "unstaged"}. + wild_version: Semver string for the schema ``wild_version`` field. + staged: True when analysing staged changes (``wild diff --staged``). + progress_callback: Optional (file, total, status) callback. + + Returns: + A dict that validates against diffgraph-v2.schema.json. + """ + if diff_ref is None: + diff_ref = { + "from": "HEAD", + "to": "index" if staged else "working_tree", + "kind": "staged" if staged else "unstaged", + } + + start_time = time.time() + total_files = len(files_with_content) + + all_symbol_changes = [] + all_import_relationships = [] + file_change_list = [] + languages_seen: set = set() + + for idx, file_data in enumerate(files_with_content): + file_path = file_data["path"] + status = file_data.get("status", "modified") + + if progress_callback: + progress_callback(file_path, total_files, "processing") + + # Map status → schema v2 change_kind for the file entry + file_change_kind_map = { + "untracked": "added", + "added": "added", + "deleted": "deleted", + "renamed": "renamed", + "modified": "modified", + } + file_change_kind = file_change_kind_map.get(status, "modified") + file_change_list.append({"path": file_path, "change_kind": file_change_kind}) + + # Determine language — skip unsupported files + language = get_language_from_file(file_path) + if not language: + if progress_callback: + progress_callback(file_path, total_files, "skipped") + continue + languages_seen.add(language) + + # Fetch pre/post content + is_new = status in ("untracked", "added") + is_deleted = status == "deleted" + + pre_content: Optional[str] = None if is_new else self._get_pre_change_content(file_path, staged) + post_content: Optional[str] = None if is_deleted else self._get_post_change_content(file_path, staged) + + # Parse pre snapshot + pre_components = [] + if pre_content: + try: + parser = self._get_parser(language) + pre_tree = parser.parse(pre_content.encode("utf-8")) + pre_components = self._extract_components( + language, pre_tree, pre_content.encode("utf-8"), file_path + ) + except Exception as e: + pass # Treat parse failure as empty pre-snapshot + + # Parse post snapshot + post_components = [] + post_imports: List[str] = [] + if post_content: + try: + parser = self._get_parser(language) + post_tree = parser.parse(post_content.encode("utf-8")) + post_source_bytes = post_content.encode("utf-8") + post_components = self._extract_components( + language, post_tree, post_source_bytes, file_path + ) + # Extract imports from post-change version only + if language == "python": + post_imports = self._extract_imports_python( + post_tree, post_source_bytes + ) + except Exception as e: + pass # Treat parse failure as empty post-snapshot + + # Compute symbol-level diff + symbol_changes = compute_symbol_diff( + pre_components=pre_components, + post_components=post_components, + pre_content=pre_content, + post_content=post_content, + ) + all_symbol_changes.extend(symbol_changes) + + # Build import relationships (structural, from post-change) + for imported_module in post_imports: + all_import_relationships.append( + build_import_relationship( + source_file=file_path, + imported_module=imported_module, + ) + ) + + if progress_callback: + progress_callback(file_path, total_files, "completed") + + duration_ms = int((time.time() - start_time) * 1000) + + return build_schema_v2_output( + symbol_changes=all_symbol_changes, + import_relationships=all_import_relationships, + file_changes=file_change_list, + diff_ref=diff_ref, + wild_version=wild_version, + analysis_duration_ms=duration_ms, + languages_detected=sorted(languages_seen), + ) + def analyze_changes( self, files_with_content: List[Dict[str, str]], diff --git a/diffgraph/schema/diffgraph-v2.schema.json b/diffgraph/schema/diffgraph-v2.schema.json new file mode 100644 index 0000000..7ae06a6 --- /dev/null +++ b/diffgraph/schema/diffgraph-v2.schema.json @@ -0,0 +1,450 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://wildestai.com/schemas/diffgraph/v2.0/schema.json", + "title": "DiffGraph v2.0", + "description": "Canonical output schema for the `wild diff` CLI. Every claim carries its analysis_source (structural | inferred | derived) and an evidence pointer. Consumers MUST reject unknown major schema_version values.", + "type": "object", + "required": ["schema_version", "generated_at", "wild_version", "diff_ref", "files", "symbols", "relationships", "metadata"], + "additionalProperties": false, + + "properties": { + + "schema_version": { + "type": "string", + "description": "Semver-style MAJOR.MINOR. Consumers MUST reject unknown MAJOR. MINOR bumps are additive only.", + "pattern": "^\\d+\\.\\d+$", + "examples": ["2.0"] + }, + + "generated_at": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 UTC timestamp of the analysis run." + }, + + "wild_version": { + "type": "string", + "description": "Semver of the `wild` CLI that produced this artifact.", + "examples": ["2.0.0", "2.1.0-dev"] + }, + + "diff_ref": { + "type": "object", + "description": "Describes what was diffed.", + "required": ["kind"], + "additionalProperties": false, + "properties": { + "kind": { + "type": "string", + "enum": ["unstaged", "staged", "commit_range", "file_scope"], + "description": "Maps directly to the `wild diff` variant used." + }, + "base_ref": { + "type": ["string", "null"], + "description": "Commit SHA or ref for the base side. null for working-tree diffs." + }, + "head_ref": { + "type": ["string", "null"], + "description": "Commit SHA or ref for the head side. null for working-tree diffs." + }, + "pathspecs": { + "type": "array", + "items": { "type": "string" }, + "description": "File or glob filters passed to `wild diff`. Empty = all files." + }, + "repo_root": { + "type": "string", + "description": "Absolute path to the git repo root. Used to resolve relative file paths." + } + } + }, + + "files": { + "type": "array", + "description": "One entry per file that appeared in the diff.", + "items": { "$ref": "#/$defs/FileEntry" } + }, + + "symbols": { + "type": "array", + "description": "Named code entities in changed files. Extracted by static analysis (structural) or LLM (inferred).", + "items": { "$ref": "#/$defs/SymbolEntry" } + }, + + "relationships": { + "type": "array", + "description": "Edges between symbols or files. analysis_source is mandatory on every edge.", + "items": { "$ref": "#/$defs/RelationshipEntry" } + }, + + "summary": { + "oneOf": [ + { "$ref": "#/$defs/SummaryEntry" }, + { "type": "null" } + ], + "description": "Top-level LLM summary of the change. null when running in local-only mode." + }, + + "metadata": { + "$ref": "#/$defs/Metadata" + } + }, + + "$defs": { + + "AnalysisSource": { + "type": "string", + "enum": ["structural", "inferred", "derived"], + "description": "structural = deterministic static analysis; inferred = LLM interpretation; derived = aggregated from structural + inferred." + }, + + "Evidence": { + "type": "object", + "description": "Pointer to what produced a claim. kind determines which fields are present.", + "required": ["kind"], + "properties": { + "kind": { + "type": "string", + "enum": [ + "git_diff_stat", + "git_diff_name_status", + "path_pattern", + "ast_parse", + "import_statement", + "call_site", + "llm_inference", + "structural_basis" + ] + }, + "file": { "type": "string", "description": "Relevant for ast_parse, import_statement, call_site." }, + "line_start": { "type": "integer", "minimum": 1, "description": "1-indexed line number." }, + "line_end": { "type": "integer", "minimum": 1 }, + "snippet": { "type": "string", "description": "Short source excerpt (signature line or import statement)." }, + "pattern": { "type": "string", "description": "Glob/regex pattern (kind=path_pattern)." }, + "detail": { "type": "string", "description": "Free-text detail (kind=git_diff_stat/name_status)." }, + "model": { "type": "string", "description": "LLM model id (kind=llm_inference)." }, + "prompt_ref": { "type": "string", "description": "Internal prompt template reference (kind=llm_inference)." }, + "temperature": { "type": "number", "minimum": 0, "maximum": 2, "description": "(kind=llm_inference)." }, + "symbol_ids": { + "type": "array", + "items": { "type": "string" }, + "description": "Symbol IDs that grounded this inferred claim (kind=structural_basis)." + }, + "file_ids": { + "type": "array", + "items": { "type": "string" }, + "description": "File IDs that grounded this inferred claim (kind=structural_basis)." + } + } + }, + + "Classification": { + "type": "object", + "required": ["is_test", "analysis_source"], + "additionalProperties": false, + "properties": { + "is_test": { "type": "boolean" }, + "analysis_source": { "$ref": "#/$defs/AnalysisSource" }, + "evidence": { + "type": "array", + "items": { "$ref": "#/$defs/Evidence" } + } + } + }, + + "FileEntry": { + "type": "object", + "required": ["id", "path", "change_kind", "analysis_source"], + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "description": "Stable ID: 'file::' (normalized, forward slashes, relative to repo_root).", + "pattern": "^file::.+" + }, + "path": { + "type": "string", + "description": "Current path relative to repo root." + }, + "old_path": { + "type": ["string", "null"], + "description": "Pre-rename path. null if not a rename." + }, + "language": { + "type": ["string", "null"], + "description": "Detected language. null = unknown (static analysis not available for this file)." + }, + "change_kind": { + "type": "string", + "enum": ["added", "modified", "deleted", "renamed", "renamed_modified"] + }, + "lines_added": { + "type": ["integer", "null"], + "minimum": 0 + }, + "lines_removed": { + "type": ["integer", "null"], + "minimum": 0 + }, + "analysis_source": { + "type": "string", + "const": "structural", + "description": "File entries are always structural (git metadata)." + }, + "evidence": { + "type": "array", + "items": { "$ref": "#/$defs/Evidence" } + }, + "classification": { + "oneOf": [ + { "$ref": "#/$defs/Classification" }, + { "type": "null" } + ] + } + } + }, + + "SymbolEntry": { + "type": "object", + "required": ["id", "name", "file_id", "kind", "change_kind", "analysis_source"], + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "description": "Stable ID: 'sym::::'. Deterministic for the same diff + repo state.", + "pattern": "^sym::.+::.+" + }, + "name": { + "type": "string", + "description": "Short name as it appears in source." + }, + "qualified_name": { + "type": ["string", "null"], + "description": "Dotted path where resolvable (e.g. 'auth.validator.validate_token'). null if cannot determine." + }, + "file_id": { + "type": "string", + "description": "Refers to files[].id.", + "pattern": "^file::.+" + }, + "kind": { + "type": "string", + "enum": ["function", "class", "method", "import", "constant", "type_alias", "module"], + "description": "Symbol category." + }, + "parent_id": { + "type": ["string", "null"], + "description": "Symbol ID of containing class/function. null for top-level symbols.", + "pattern": "^sym::.+::.+" + }, + "change_kind": { + "type": "string", + "enum": ["added", "modified", "deleted", "unchanged"], + "description": "Derived by diffing tree-sitter AST outputs pre- and post-change." + }, + "analysis_source": { + "$ref": "#/$defs/AnalysisSource" + }, + "location": { + "oneOf": [ + { + "type": "object", + "required": ["file", "line_start", "line_end"], + "additionalProperties": false, + "properties": { + "file": { "type": "string" }, + "line_start": { "type": "integer", "minimum": 1, "description": "1-indexed line number." }, + "line_end": { "type": "integer", "minimum": 1, "description": "1-indexed line number (inclusive)." } + } + }, + { "type": "null" } + ], + "description": "Line range in post-change file. null for deleted symbols." + }, + "evidence": { + "type": "array", + "items": { "$ref": "#/$defs/Evidence" }, + "description": "Required for inferred; strongly recommended for structural. Structural symbols should include at least one ast_parse entry." + } + }, + "if": { + "properties": { "analysis_source": { "const": "inferred" } }, + "required": ["analysis_source"] + }, + "then": { + "required": ["evidence"], + "properties": { + "evidence": { "minItems": 1 } + } + } + }, + + "RelationshipEntry": { + "type": "object", + "required": ["id", "kind", "source_id", "target_id", "analysis_source"], + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "description": "Stable ID: 'rel::->'. Append '#N' for multi-edges.", + "pattern": "^rel::.+->.+" + }, + "kind": { + "type": "string", + "enum": ["imports", "calls", "inherits", "implements", "defines", "contains", "semantic_related", "co_changed"], + "description": "See design/JSON-SCHEMA.md for semantics and allowed analysis_source per kind." + }, + "source_id": { + "type": "string", + "description": "symbols[].id or files[].id." + }, + "target_id": { + "type": "string", + "description": "symbols[].id or files[].id." + }, + "analysis_source": { + "$ref": "#/$defs/AnalysisSource" + }, + "confidence": { + "type": ["number", "null"], + "minimum": 0, + "maximum": 1, + "description": "Required when analysis_source == 'inferred'. null for structural relationships." + }, + "resolution_method": { + "type": ["string", "null"], + "enum": ["import_grounded", "resolved", "heuristic", null], + "description": "For 'calls' relationships: how the target was resolved. 'import_grounded' = explicit import + call site, not full project indexing." + }, + "evidence": { + "type": "array", + "items": { "$ref": "#/$defs/Evidence" } + }, + "label": { + "type": ["string", "null"], + "description": "Human-readable edge description. From LLM for inferred edges." + } + }, + "if": { + "properties": { "analysis_source": { "const": "inferred" } }, + "required": ["analysis_source"] + }, + "then": { + "required": ["confidence", "evidence"], + "properties": { + "evidence": { "minItems": 1 }, + "confidence": { "type": "number" } + } + } + }, + + "SummaryEntry": { + "type": "object", + "required": ["text", "analysis_source", "evidence"], + "additionalProperties": false, + "properties": { + "text": { + "type": "string", + "description": "Human-readable summary of the change." + }, + "analysis_source": { + "type": "string", + "const": "inferred", + "description": "Summaries are always inferred (require LLM interpretation)." + }, + "confidence": { + "type": ["number", "null"], + "minimum": 0, + "maximum": 1 + }, + "evidence": { + "type": "array", + "items": { "$ref": "#/$defs/Evidence" }, + "allOf": [ + { + "contains": { + "properties": { "kind": { "const": "llm_inference" } }, + "required": ["kind"] + } + }, + { + "contains": { + "properties": { "kind": { "const": "structural_basis" } }, + "required": ["kind"] + } + } + ], + "description": "Must include at least one llm_inference entry and one structural_basis entry." + } + } + }, + + "Warning": { + "type": "object", + "required": ["code"], + "additionalProperties": false, + "properties": { + "code": { + "type": "string", + "enum": ["PARSE_FAILURE", "UNSUPPORTED_LANGUAGE", "PARTIAL_ANALYSIS", "LLM_TIMEOUT", "LLM_ERROR", "UNKNOWN"], + "description": "Machine-readable warning code. Consumers can surface these to the user." + }, + "file": { "type": "string" }, + "detail": { "type": "string" } + } + }, + + "Metadata": { + "type": "object", + "required": ["privacy_tier"], + "additionalProperties": false, + "properties": { + "privacy_tier": { + "type": "string", + "enum": ["local", "cloud_llm", "cloud_backend"], + "description": "local = no data left the machine; cloud_llm = diff sent to LLM API; cloud_backend = data sent to WildestAI backend." + }, + "cloud_providers_used": { + "type": "array", + "items": { "type": "string" }, + "description": "LLM provider IDs used (e.g. 'openai', 'anthropic'). Empty for local-only runs." + }, + "analysis_duration_ms": { + "type": ["integer", "null"], + "minimum": 0 + }, + "languages_detected": { + "type": "array", + "items": { "type": "string" }, + "description": "Languages found in the diffed files." + }, + "files_analyzed": { + "type": ["integer", "null"], + "minimum": 0 + }, + "files_skipped": { + "type": ["integer", "null"], + "minimum": 0 + }, + "llm_calls": { + "type": ["integer", "null"], + "minimum": 0, + "description": "Number of LLM API calls made. 0 for local-only runs." + }, + "llm_model": { + "type": ["string", "null"], + "description": "Primary LLM model used, if any." + }, + "tiers_used": { + "type": "array", + "items": { "$ref": "#/$defs/AnalysisSource" }, + "description": "Which analysis tiers contributed to this output." + }, + "warnings": { + "type": "array", + "items": { "$ref": "#/$defs/Warning" } + } + } + } + } +} diff --git a/llms.txt b/llms.txt new file mode 100644 index 0000000..cfadb19 --- /dev/null +++ b/llms.txt @@ -0,0 +1,37 @@ +# DiffGraph-CLI (WildestAI) + +> `wild diff` — AI-powered semantic analysis of git diffs. + +## What it does + +Wraps `git` and adds an AI-powered `wild diff` command that analyses your git diff +and produces a dependency graph HTML report showing what changed, how components +relate, and what needs attention. + +## Quick start + +```bash +pip install wildest-ai +export OPENAI_API_KEY=sk-... +wild diff # analyse current unstaged changes +wild diff --staged # analyse staged changes +``` + +All other `wild ` commands pass through to `git` transparently. + +## Agent usage + +See `skill.md` in this repo for the full agent skill. + +For programmatic MCP access, run: +```bash +python mcp_server.py +``` + +MCP tools: `run_wild_diff`, `list_docs`, `get_docs`, `search_docs` + +## Links + +- GitHub: https://github.com/WildestAI/DiffGraph-CLI +- Website: https://wildest.ai +- Full context: https://wildest.ai/llms-full.txt diff --git a/mcp_server.py b/mcp_server.py new file mode 100644 index 0000000..a1358b7 --- /dev/null +++ b/mcp_server.py @@ -0,0 +1,303 @@ +#!/usr/bin/env python3 +""" +WildestAI MCP Server + +Exposes DiffGraph-CLI functionality and documentation to AI agents via the +Model Context Protocol (MCP). Agents can run `wild diff` on a repo and retrieve +docs programmatically. + +Usage: + python mcp_server.py + +Requirements: + pip install mcp +""" + +import json +import os +import subprocess +import sys +from pathlib import Path + +from mcp.server.fastmcp import FastMCP + +# --------------------------------------------------------------------------- +# Setup +# --------------------------------------------------------------------------- + +REPO_ROOT = Path(__file__).parent +DOCS_DIR = REPO_ROOT / "docs" + +mcp = FastMCP( + name="wildestai", + instructions=( + "WildestAI helps developers understand code changes. " + "Use run_wild_diff to analyse a git repository's current diff and get a " + "semantic understanding of what changed. Use list_docs / get_docs / search_docs " + "to explore the project's documentation." + ), +) + +# --------------------------------------------------------------------------- +# Tool: run_wild_diff +# --------------------------------------------------------------------------- + +@mcp.tool() +def run_wild_diff( + repo_path: str, + args: str = "", + output_file: str = "", +) -> dict: + """ + Run `wild diff` on a git repository and return the result. + + Args: + repo_path: Absolute path to the git repository root. + args: Optional extra arguments, e.g. "--staged", "", "". + Pass as a space-separated string. + output_file: Optional path for the HTML output file. Defaults to + /diffgraph.html. + + Returns: + A dict with keys: + success (bool): Whether the command succeeded. + stdout (str): Standard output from wild diff. + stderr (str): Standard error output. + output_file (str): Path to the generated HTML report (if any). + returncode (int): Process exit code. + """ + repo = Path(repo_path).expanduser().resolve() + if not repo.is_dir(): + return {"success": False, "error": f"Directory not found: {repo_path}"} + + if not (repo / ".git").exists(): + return {"success": False, "error": f"Not a git repository: {repo_path}"} + + cmd = ["wild", "diff", "--no-open"] + if output_file: + # Sanitize output_file — must resolve inside the repo directory + resolved_output = (repo / output_file).resolve() + if not resolved_output.is_relative_to(repo): + return { + "success": False, + "error": f"output_file must be inside the repository directory: {repo}", + } + resolved_output.parent.mkdir(parents=True, exist_ok=True) + cmd += ["--output", str(resolved_output)] + if args: + cmd += args.split() + + try: + result = subprocess.run( + cmd, + cwd=str(repo), + capture_output=True, + text=True, + timeout=120, + ) + out_path = output_file or str(repo / "diffgraph.html") + return { + "success": result.returncode == 0, + "stdout": result.stdout, + "stderr": result.stderr, + "output_file": out_path if Path(out_path).exists() else "", + "returncode": result.returncode, + } + except subprocess.TimeoutExpired: + return {"success": False, "error": "wild diff timed out after 120 seconds"} + except FileNotFoundError: + return { + "success": False, + "error": ( + "`wild` command not found. Install with: pip install wildest-ai " + "or `pip install -e .` from the DiffGraph-CLI repo." + ), + } + +# --------------------------------------------------------------------------- +# Tool: list_docs +# --------------------------------------------------------------------------- + +@mcp.tool() +def list_docs() -> list[dict]: + """ + List available documentation pages for DiffGraph-CLI / WildestAI. + + Returns: + A list of dicts, each with keys: + name (str): Short name / slug of the document. + title (str): Human-readable title. + path (str): Relative path from the repo root. + """ + pages = [] + + # Core docs + built_in = [ + ("readme", "README — Overview & Quick Start", "README.md"), + ("changelog", "Changelog", "CHANGELOG.md"), + ("skill", "Agent Skill — how to use wild from an AI agent", "skill.md"), + ("context", "Project Context — deep reference", "../../wildestai/docs/DiffGraph-CLI/CONTEXT.md"), + ("vision", "WildestAI Vision — 22-section strategic context", "../../wildestai/docs/DiffGraph-CLI/WildestAI-vision.md"), + ] + for name, title, rel_path in built_in: + full = (REPO_ROOT / rel_path).resolve() + if full.exists(): + pages.append({"name": name, "title": title, "path": str(full.relative_to(REPO_ROOT.parent.parent) if full.is_relative_to(REPO_ROOT.parent.parent) else full)}) + + # docs/ subdirectory + if DOCS_DIR.exists(): + for f in sorted(DOCS_DIR.glob("**/*.md")): + slug = f.stem.lower().replace(" ", "-") + pages.append({ + "name": slug, + "title": f.stem.replace("-", " ").replace("_", " ").title(), + "path": str(f.relative_to(REPO_ROOT)), + }) + + return pages + + +# --------------------------------------------------------------------------- +# Tool: get_docs +# --------------------------------------------------------------------------- + +@mcp.tool() +def get_docs(name: str) -> dict: + """ + Retrieve the content of a documentation page by name or path. + + Args: + name: The short name from list_docs (e.g. "readme", "context") or a + relative path from the repo root (e.g. "docs/Roadmap-v1-git-wrapper.md"). + + Returns: + A dict with keys: + found (bool): Whether the document was located. + name (str): The name used to look up. + content (str): Full text of the document (if found). + error (str): Error message (if not found). + """ + # Map known slugs to paths + slug_map = { + "readme": REPO_ROOT / "README.md", + "changelog": REPO_ROOT / "CHANGELOG.md", + "skill": REPO_ROOT / "skill.md", + "context": REPO_ROOT.parent / "docs" / "DiffGraph-CLI" / "CONTEXT.md", + "vision": REPO_ROOT.parent / "docs" / "DiffGraph-CLI" / "WildestAI-vision.md", + } + + target: Path | None = None + + if name in slug_map: + target = slug_map[name] + else: + # Try as relative path from repo root — guard against path traversal + candidate = (REPO_ROOT / name).resolve() + if candidate.is_relative_to(REPO_ROOT.resolve()) and candidate.exists(): + target = candidate + else: + # Try docs subdir + candidate2 = (DOCS_DIR / name).resolve() + if DOCS_DIR.exists() and candidate2.is_relative_to(DOCS_DIR.resolve()) and candidate2.exists(): + target = candidate2 + + if target is None or not target.exists(): + return { + "found": False, + "name": name, + "content": "", + "error": f"Document '{name}' not found. Call list_docs() to see available pages.", + } + + try: + content = target.read_text(encoding="utf-8") + return {"found": True, "name": name, "content": content, "error": ""} + except Exception as e: + return {"found": False, "name": name, "content": "", "error": str(e)} + + +# --------------------------------------------------------------------------- +# Tool: search_docs +# --------------------------------------------------------------------------- + +@mcp.tool() +def search_docs(query: str, max_results: int = 5) -> list[dict]: + """ + Search across all documentation for a query string (case-insensitive). + + Args: + query: The search term or phrase. + max_results: Maximum number of matching excerpts to return (default 5). + + Returns: + A list of dicts, each with: + document (str): Document name. + path (str): Relative path. + excerpt (str): The matching line + a few lines of context. + line (int): Line number of the match. + """ + results = [] + query_lower = query.lower() + + search_paths = [ + (REPO_ROOT / "README.md", "readme"), + (REPO_ROOT / "CHANGELOG.md", "changelog"), + (REPO_ROOT / "skill.md", "skill"), + (REPO_ROOT.parent / "docs" / "DiffGraph-CLI" / "CONTEXT.md", "context"), + ] + + if DOCS_DIR.exists(): + for f in DOCS_DIR.glob("**/*.md"): + search_paths.append((f, f.stem.lower())) + + for path, name in search_paths: + if not path.exists(): + continue + try: + lines = path.read_text(encoding="utf-8").splitlines() + except Exception: + continue + + for i, line in enumerate(lines): + if query_lower in line.lower(): + start = max(0, i - 2) + end = min(len(lines), i + 3) + excerpt = "\n".join(lines[start:end]) + results.append({ + "document": name, + "path": str(path.relative_to(REPO_ROOT) if path.is_relative_to(REPO_ROOT) else path), + "excerpt": excerpt, + "line": i + 1, + }) + if len(results) >= max_results: + return results + + return results + + +# --------------------------------------------------------------------------- +# Resource: llms.txt +# --------------------------------------------------------------------------- + +@mcp.resource("wildestai://llms.txt") +def llms_txt() -> str: + """The llms.txt for WildestAI — compact AI discoverability summary.""" + llms_path = REPO_ROOT.parent / "wildest-ai-website" / "public" / "llms.txt" + if llms_path.exists(): + return llms_path.read_text(encoding="utf-8") + return ( + "# WildestAI\n\n" + "WildestAI turns code changes from raw diffs into understandable, " + "evidence-linked software evolution.\n\n" + "CLI: wild diff — analyses git diffs with AI, generates dependency graph HTML report.\n" + "Website: https://wildest.ai\n" + "GitHub: https://github.com/WildestAI/DiffGraph-CLI\n" + ) + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + +if __name__ == "__main__": + mcp.run() diff --git a/skill.md b/skill.md new file mode 100644 index 0000000..7c17f6c --- /dev/null +++ b/skill.md @@ -0,0 +1,86 @@ +# skill: wild-diff + +Use this skill to analyse git diffs with WildestAI's `wild` CLI and get semantic understanding of code changes. + +## What it does + +`wild diff` analyses your current git diff using AI and produces: +- A dependency graph of changed components and their relationships +- An HTML report (`diffgraph.html`) with Mermaid.js visualisation +- Syntax-highlighted code blocks with AI-generated summaries +- Answers to: what changed, how components relate, what needs attention + +## Prerequisites + +```bash +# Install +pip install wildest-ai +# or from source: +git clone https://github.com/WildestAI/DiffGraph-CLI && cd DiffGraph-CLI && pip install -e . + +# Set your OpenAI API key +export OPENAI_API_KEY=sk-... +``` + +## Commands + +```bash +# Analyse unstaged + untracked changes (most common) +wild diff + +# Analyse only staged changes +wild diff --staged + +# Diff between HEAD and a specific commit +wild diff + +# Diff between two commits +wild diff + +# Diff for a specific file +wild diff path/to/file.py + +# Write output to a custom file, don't auto-open +wild diff --output report.html --no-open + +# All other git commands pass through transparently +wild log +wild blame path/to/file.py +wild status +``` + +## Output + +By default, generates `diffgraph.html` in the current directory and opens it in your browser. + +Use `--no-open` to suppress auto-open (useful in CI or headless environments). +Use `--output ` to write to a specific location. + +## MCP Server + +If you need programmatic access (agent-to-agent), start the MCP server: + +```bash +cd path/to/DiffGraph-CLI +python mcp_server.py +``` + +The MCP server exposes tools: +- `run_wild_diff(repo_path, args, output_file)` — run wild diff and return structured analysis; `output_file` is optional (defaults to `/diffgraph.html`) +- `get_docs(page)` — retrieve a documentation page +- `list_docs()` — list available documentation pages +- `search_docs(query)` — search across docs + +## Configuration + +Environment variables: +- `OPENAI_API_KEY` — required for AI analysis +- Copy `.env.example` to `.env` to set locally + +## Notes + +- Works with Python 3.7+ +- Tested on macOS and Linux +- The CLI wraps `git` — it must be run inside a git repository +- Large diffs may be slow; consider scoping with file paths or commit ranges +- The `.env` file is git-ignored — never commit API keys diff --git a/tests/test_schema_v2_adapter.py b/tests/test_schema_v2_adapter.py new file mode 100644 index 0000000..933b408 --- /dev/null +++ b/tests/test_schema_v2_adapter.py @@ -0,0 +1,324 @@ +""" +Tests for the schema v2 output adapter (schema_v2_adapter.py). + +These tests cover: +- Symbol diff computation (added / modified / deleted / unchanged) +- Schema v2 output structure +- Zero network calls (all structural, local-only) + +Design principle: every test assertion can be expressed as an exact equality +because all outputs are deterministic (no LLM, no network, no randomness). +""" + +import pytest +from dataclasses import dataclass +from typing import Optional + +from diffgraph.processing_modes.schema_v2_adapter import ( + compute_symbol_diff, + build_schema_v2_output, + build_import_relationship, + build_symbol_entry, + qualified_name_for, + symbol_id, + file_symbol_id, +) + + +# --------------------------------------------------------------------------- +# Minimal stub for ExtractedComponent (mirrors the real dataclass) +# --------------------------------------------------------------------------- + +@dataclass +class StubComponent: + name: str + component_type: str = "function" + parent: Optional[str] = None + start_line: Optional[int] = 0 + end_line: Optional[int] = 5 + file_path: Optional[str] = "test.py" + + +# --------------------------------------------------------------------------- +# Helper +# --------------------------------------------------------------------------- + +FILE = "auth/validator.py" +CONTENT_V1 = """\ +def validate_token(token): + if not token: + raise ValueError("empty token") + return True + +class RateLimiter: + def check(self, key): + return True +""" + +CONTENT_V2 = """\ +def validate_token(token): + if not token: + raise ValueError("empty token") + # Added expiry check + if is_expired(token): + raise ValueError("expired token") + return True + +class RateLimiter: + def check(self, key): + return True + +def helper(): + pass +""" + + +# --------------------------------------------------------------------------- +# qualified_name_for +# --------------------------------------------------------------------------- + +class TestQualifiedName: + def test_standalone_function(self): + comp = StubComponent(name="validate_token") + assert qualified_name_for(comp) == "validate_token" + + def test_method(self): + comp = StubComponent(name="check", parent="RateLimiter", component_type="method") + assert qualified_name_for(comp) == "RateLimiter.check" + + def test_class(self): + comp = StubComponent(name="RateLimiter", component_type="container") + assert qualified_name_for(comp) == "RateLimiter" + + +# --------------------------------------------------------------------------- +# symbol_id / file_symbol_id +# --------------------------------------------------------------------------- + +class TestSymbolIds: + def test_symbol_id_format(self): + sid = symbol_id("auth/validator.py", "RateLimiter.check") + assert sid == "sym::auth/validator.py::RateLimiter.check" + + def test_file_symbol_id_format(self): + fid = file_symbol_id("auth/validator.py") + assert fid == "sym::file::auth/validator.py" + + +# --------------------------------------------------------------------------- +# compute_symbol_diff +# --------------------------------------------------------------------------- + +class TestComputeSymbolDiff: + + def _make_comps(self, specs): + """Make StubComponents from (name, start, end, parent) tuples.""" + result = [] + for name, start, end, parent in specs: + comp_type = "method" if parent else "function" + result.append(StubComponent( + name=name, component_type=comp_type, + parent=parent, start_line=start, end_line=end, file_path=FILE, + )) + return result + + def test_added_symbol(self): + pre = self._make_comps([("validate_token", 0, 3, None)]) + post = self._make_comps([ + ("validate_token", 0, 5, None), + ("helper", 12, 13, None), # new + ]) + content_pre = CONTENT_V1 + content_post = CONTENT_V2 + + changes = compute_symbol_diff(pre, post, content_pre, content_post) + by_name = {c.qualified_name: c for c in changes} + + assert "helper" in by_name + assert by_name["helper"].change_kind == "added" + + def test_deleted_symbol(self): + pre = self._make_comps([ + ("validate_token", 0, 3, None), + ("old_func", 5, 8, None), # will be deleted + ]) + post = self._make_comps([("validate_token", 0, 3, None)]) + changes = compute_symbol_diff(pre, post, CONTENT_V1, CONTENT_V1) + by_name = {c.qualified_name: c for c in changes} + + assert by_name["old_func"].change_kind == "deleted" + + def test_modified_symbol(self): + # Same name, different content + pre = self._make_comps([("validate_token", 0, 3, None)]) + post = self._make_comps([("validate_token", 0, 6, None)]) + changes = compute_symbol_diff(pre, post, CONTENT_V1, CONTENT_V2) + by_name = {c.qualified_name: c for c in changes} + + assert by_name["validate_token"].change_kind == "modified" + + def test_unchanged_symbol(self): + # RateLimiter.check exists unchanged in both versions. + # In CONTENT_V1 the method body is at lines 6–7 (0-indexed). + # In CONTENT_V2 it has shifted to lines 9–10 due to inserted lines above. + # Both slices resolve to the same text: + # " def check(self, key):\n return True" + # → change_kind should be "unchanged". + pre_comps = [StubComponent(name="check", component_type="method", + parent="RateLimiter", start_line=6, end_line=7, + file_path=FILE)] + post_comps = [StubComponent(name="check", component_type="method", + parent="RateLimiter", start_line=9, end_line=10, + file_path=FILE)] + changes = compute_symbol_diff(pre_comps, post_comps, CONTENT_V1, CONTENT_V2) + by_name = {c.qualified_name: c for c in changes} + + assert by_name["RateLimiter.check"].change_kind == "unchanged" + + def test_new_file_all_added(self): + """All symbols in a new file should be 'added'.""" + post = self._make_comps([("validate_token", 0, 3, None)]) + changes = compute_symbol_diff(None, post, None, CONTENT_V1) + + assert all(c.change_kind == "added" for c in changes) + + def test_deleted_file_all_deleted(self): + """All symbols in a deleted file should be 'deleted'.""" + pre = self._make_comps([("validate_token", 0, 3, None)]) + changes = compute_symbol_diff(pre, None, CONTENT_V1, None) + + assert all(c.change_kind == "deleted" for c in changes) + + def test_no_changes(self): + """Identical pre/post → all unchanged.""" + comps = self._make_comps([("validate_token", 0, 3, None)]) + changes = compute_symbol_diff(comps, comps, CONTENT_V1, CONTENT_V1) + + assert all(c.change_kind == "unchanged" for c in changes) + + +# --------------------------------------------------------------------------- +# build_symbol_entry +# --------------------------------------------------------------------------- + +class TestBuildSymbolEntry: + + def test_required_fields_present(self): + from diffgraph.processing_modes.schema_v2_adapter import SymbolChange + sc = SymbolChange( + qualified_name="validate_token", + file_path=FILE, + component_type="function", + parent=None, + change_kind="modified", + start_line=0, + end_line=3, + ) + entry = build_symbol_entry(sc) + + assert entry["id"] == f"sym::{FILE}::validate_token" + assert entry["qualified_name"] == "validate_token" + assert entry["file"] == FILE + assert entry["change_kind"] == "modified" + assert entry["analysis_source"] == "structural" + assert len(entry["evidence"]) >= 1 + ev = entry["evidence"][0] + assert ev["kind"] == "ast_parse" + assert ev["location"]["line_start"] == 1 # 0-indexed → 1-indexed + assert ev["location"]["line_end"] == 4 # 3 → 4 + + def test_structural_source_always_set(self): + from diffgraph.processing_modes.schema_v2_adapter import SymbolChange + sc = SymbolChange("f", FILE, "function", None, "added", 0, 2) + assert build_symbol_entry(sc)["analysis_source"] == "structural" + + +# --------------------------------------------------------------------------- +# build_import_relationship +# --------------------------------------------------------------------------- + +class TestBuildImportRelationship: + + def test_basic_import(self): + rel = build_import_relationship("api/routes.py", "auth.validator") + assert rel["from"] == "sym::file::api/routes.py" + assert rel["to"] == "auth.validator" + assert rel["kind"] == "imports" + assert rel["analysis_source"] == "structural" + assert rel["evidence"][0]["kind"] == "import_statement" + + +# --------------------------------------------------------------------------- +# build_schema_v2_output +# --------------------------------------------------------------------------- + +class TestBuildSchemaV2Output: + + def _minimal_output(self): + from diffgraph.processing_modes.schema_v2_adapter import SymbolChange + sc = SymbolChange("validate_token", FILE, "function", None, "modified", 0, 3) + return build_schema_v2_output( + symbol_changes=[sc], + import_relationships=[], + file_changes=[{"path": FILE, "change_kind": "modified"}], + diff_ref={"from": "HEAD", "to": "working_tree", "kind": "unstaged"}, + wild_version="2.0.0-dev", + analysis_duration_ms=123, + languages_detected=["python"], + ) + + def test_schema_version(self): + out = self._minimal_output() + assert out["schema_version"] == "2.0" + + def test_privacy_tier_local(self): + out = self._minimal_output() + assert out["metadata"]["privacy_tier"] == "local" + assert out["metadata"]["cloud_providers_used"] == [] + + def test_summary_null_no_llm(self): + """schema v2 D2: summary must be null (not absent) when no LLM.""" + out = self._minimal_output() + assert "summary" in out + assert out["summary"] is None + + def test_warnings_always_present(self): + """schema v2 D4: warnings must always be present.""" + out = self._minimal_output() + assert "warnings" in out + assert out["warnings"] == [] + + def test_no_network_calls(self): + """ + Structural invariant: building schema v2 output makes zero network calls. + + We verify this by monkeypatching socket.connect and asserting it is + never called during build_schema_v2_output(). + """ + import socket + original_connect = socket.socket.connect + + calls = [] + + def mock_connect(self, *args, **kwargs): + calls.append(args) + return original_connect(self, *args, **kwargs) + + socket.socket.connect = mock_connect + try: + self._minimal_output() + finally: + socket.socket.connect = original_connect + + assert calls == [], ( + f"build_schema_v2_output() made {len(calls)} network connection(s): {calls}" + ) + + def test_required_top_level_keys(self): + required = { + "schema_version", "generated_at", "wild_version", "diff_ref", + "files", "symbols", "relationships", "metadata", + } + out = self._minimal_output() + missing = required - set(out.keys()) + assert not missing, f"Missing required keys: {missing}"