From 2d17b0d773a7bd59e6c722ffe164ddb4416d4604 Mon Sep 17 00:00:00 2001 From: nia-sg-bot Date: Wed, 12 Aug 2026 16:07:29 +0530 Subject: [PATCH 1/5] Centralize the DiffGraph artifact contract Make schema versioning and fail-closed validation reusable by producers and consumers. Add a packaged structural golden artifact and compatibility tests as the first bounded step toward issue #23. --- README.md | 10 ++ diffgraph/cli.py | 20 +--- diffgraph/contract.py | 101 ++++++++++++++++ diffgraph/formatters/terminal.py | 28 +---- .../diffgraph-v2.structural.example.json | 103 ++++++++++++++++ diffgraph/structural.py | 3 +- setup.py | 7 +- tests/test_contract.py | 113 ++++++++++++++++++ tests/test_structural.py | 12 +- tests/test_terminal_formatter.py | 30 +++-- 10 files changed, 369 insertions(+), 58 deletions(-) create mode 100644 diffgraph/contract.py create mode 100644 diffgraph/schema/diffgraph-v2.structural.example.json create mode 100644 tests/test_contract.py diff --git a/README.md b/README.md index 533ba0d..b7c62b9 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,16 @@ is claimed. Every file records old/new paths, modes, Git object IDs, and content SHA-256 values in structural evidence, while symbol/relationship evidence names the parser package, query revision, and source blob identity. +### Artifact compatibility + +DiffGraph artifacts use a `MAJOR.MINOR` `schema_version`. Consumers reject +malformed versions and unknown major versions. Minor releases within major 2 +are additive: a consumer accepts them only when the complete artifact still +validates against its packaged v2 schema. This fail-closed rule lets producers +add optional data without weakening validation for existing consumers. The +canonical schema and a complete local-only example are packaged under +`diffgraph/schema/`; neither contains AI-derived symbols or relationships. + ## 📊 Example Output The generated HTML report includes: diff --git a/diffgraph/cli.py b/diffgraph/cli.py index f6fe21e..09da1f5 100644 --- a/diffgraph/cli.py +++ b/diffgraph/cli.py @@ -6,6 +6,7 @@ from typing import List, Dict import os from diffgraph import __version__ +from diffgraph.contract import DiffGraphContractError, validate_artifact from diffgraph.env_loader import load_env_file, debug_environment from diffgraph.git_snapshot import GitSnapshotError from diffgraph.utils import sanitize_diff_args, involves_working_tree @@ -153,23 +154,10 @@ def _structural_scope(diff_args: List[str], separator_present: bool = False): def _validate_structural_artifact(artifact): - """Fail closed when the canonical v2 schema cannot validate the artifact.""" + """Translate canonical contract failures into user-facing CLI errors.""" try: - import jsonschema - except ImportError as error: - raise click.ClickException( - "jsonschema is required to validate --structural-json output" - ) from error - schema_path = Path(__file__).parent / "schema" / "diffgraph-v2.schema.json" - try: - schema = json.loads(schema_path.read_text(encoding="utf-8")) - jsonschema.validate(artifact, schema) - except ( - OSError, - json.JSONDecodeError, - jsonschema.ValidationError, - jsonschema.SchemaError, - ) as error: + validate_artifact(artifact) + except DiffGraphContractError as error: raise click.ClickException(f"structural artifact validation failed: {error}") from error diff --git a/diffgraph/contract.py b/diffgraph/contract.py new file mode 100644 index 0000000..32de310 --- /dev/null +++ b/diffgraph/contract.py @@ -0,0 +1,101 @@ +"""Canonical DiffGraph artifact contract and compatibility checks. + +This module is intentionally independent of Git, tree-sitter, and AI/network +code so artifact consumers can validate DiffGraph data without importing any of +those subsystems. +""" +from __future__ import annotations + +import json +import re +from importlib import resources +from typing import Any, Mapping, Tuple + +SUPPORTED_SCHEMA_MAJOR = 2 +CURRENT_SCHEMA_VERSION = "2.0" +_SCHEMA_RESOURCE = "schema/diffgraph-v2.schema.json" +_VERSION_PATTERN = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)$") + + +class DiffGraphContractError(ValueError): + """Raised when an artifact cannot be proven to satisfy the contract.""" + + +def schema_version(value: object) -> Tuple[int, int]: + """Parse and compatibility-check a DiffGraph ``MAJOR.MINOR`` version.""" + if not isinstance(value, str): + raise DiffGraphContractError( + "DiffGraph schema_version must use MAJOR.MINOR format; " + f"received {value!r}" + ) + match = _VERSION_PATTERN.fullmatch(value) + if match is None: + raise DiffGraphContractError( + "DiffGraph schema_version must use MAJOR.MINOR format; " + f"received {value!r}" + ) + + major, minor = (int(part) for part in match.groups()) + if major != SUPPORTED_SCHEMA_MAJOR: + raise DiffGraphContractError( + f"Unsupported DiffGraph schema major {major}; " + f"this consumer supports major {SUPPORTED_SCHEMA_MAJOR}" + ) + return major, minor + + +def load_schema() -> Mapping[str, Any]: + """Load the packaged canonical schema, failing closed on missing/bad data.""" + try: + schema_text = ( + resources.files("diffgraph").joinpath(_SCHEMA_RESOURCE).read_text( + encoding="utf-8" + ) + ) + schema = json.loads(schema_text) + except (OSError, TypeError, json.JSONDecodeError) as error: + raise DiffGraphContractError( + f"could not load canonical DiffGraph schema: {error}" + ) from error + if not isinstance(schema, dict): + raise DiffGraphContractError("canonical DiffGraph schema is not a JSON object") + return schema + + +def validate_artifact(artifact: object) -> None: + """Validate version compatibility and every canonical schema constraint. + + Minor versions within the supported major are accepted when they remain + valid against this consumer's schema. Unknown majors, malformed versions, + missing validator support, invalid packaged schemas, and invalid artifacts + are all rejected. + """ + if not isinstance(artifact, dict): + raise DiffGraphContractError( + f"DiffGraph artifact must be a JSON object; received {type(artifact).__name__}" + ) + schema_version(artifact.get("schema_version")) + + try: + import jsonschema + except ImportError as error: + raise DiffGraphContractError( + "jsonschema is required to validate DiffGraph artifacts" + ) from error + + schema = load_schema() + try: + validator_class = jsonschema.validators.validator_for(schema) + validator_class.check_schema(schema) + validator = validator_class(schema, format_checker=jsonschema.FormatChecker()) + error = next(iter(validator.iter_errors(artifact)), None) + except jsonschema.SchemaError as error: + raise DiffGraphContractError( + f"canonical DiffGraph schema is invalid: {error.message}" + ) from error + + if error is not None: + location = ".".join(str(part) for part in error.absolute_path) or "" + raise DiffGraphContractError( + f"DiffGraph artifact validation failed at {location}: {error.message}" + ) from error diff --git a/diffgraph/formatters/terminal.py b/diffgraph/formatters/terminal.py index c6a20da..02c305b 100644 --- a/diffgraph/formatters/terminal.py +++ b/diffgraph/formatters/terminal.py @@ -17,12 +17,13 @@ from __future__ import annotations import os -import re import shutil import sys from dataclasses import dataclass, field from typing import Optional +from diffgraph.contract import validate_artifact + # --------------------------------------------------------------------------- # Color / terminal helpers @@ -133,8 +134,6 @@ class TerminalFormatter: """ DEFAULT_MAX_ITEMS = 10 - SUPPORTED_SCHEMA_MAJOR = 2 - def __init__( self, diffgraph: dict, @@ -143,28 +142,12 @@ def __init__( max_items: Optional[int] = DEFAULT_MAX_ITEMS, color: Optional[bool] = None, ): - self._validate_schema_version(diffgraph.get("schema_version")) + validate_artifact(diffgraph) self.dg = diffgraph self.compact = compact self.max_items = max_items self._color_override = color - @classmethod - def _validate_schema_version(cls, schema_version: object) -> None: - """Reject malformed or unsupported DiffGraph schema versions.""" - if not isinstance(schema_version, str) or not re.fullmatch(r"\d+\.\d+", schema_version): - raise ValueError( - "DiffGraph schema_version must use MAJOR.MINOR format; " - f"received {schema_version!r}" - ) - - major = int(schema_version.split(".", 1)[0]) - if major != cls.SUPPORTED_SCHEMA_MAJOR: - raise ValueError( - f"Unsupported DiffGraph schema major {major}; " - f"TerminalFormatter supports major {cls.SUPPORTED_SCHEMA_MAJOR}" - ) - # ------------------------------------------------------------------ # Public API # ------------------------------------------------------------------ @@ -323,9 +306,8 @@ def _write_warnings(self, out, color: bool) -> None: out.write(bold("▶ FILES CHANGED", color) + "\n") for f in changed_files: path = f.get("path", f.get("id", "?")) - stats = f.get("stats", {}) - additions = stats.get("additions", 0) - deletions = stats.get("deletions", 0) + additions = f.get("lines_added") or 0 + deletions = f.get("lines_removed") or 0 out.write(f" {path} +{additions} / -{deletions}\n") out.write("\n") diff --git a/diffgraph/schema/diffgraph-v2.structural.example.json b/diffgraph/schema/diffgraph-v2.structural.example.json new file mode 100644 index 0000000..30167ae --- /dev/null +++ b/diffgraph/schema/diffgraph-v2.structural.example.json @@ -0,0 +1,103 @@ +{ + "schema_version": "2.0", + "generated_at": "2026-08-12T10:00:00Z", + "wild_version": "1.1.0", + "diff_ref": { + "kind": "unstaged", + "base_ref": null, + "head_ref": null, + "pathspecs": [], + "repo_root": "/example/repository" + }, + "files": [ + { + "id": "file::src/greeting.py", + "path": "src/greeting.py", + "old_path": null, + "language": "python", + "change_kind": "modified", + "lines_added": 2, + "lines_removed": 1, + "analysis_source": "structural", + "evidence": [ + { + "kind": "git_diff_name_status", + "detail": "status=M; source=git" + } + ], + "classification": { + "is_test": false, + "analysis_source": "structural", + "evidence": [ + { + "kind": "path_pattern", + "pattern": "src/**" + } + ] + } + } + ], + "symbols": [ + { + "id": "sym::src/greeting.py::greet", + "name": "greet", + "qualified_name": "greet", + "file_id": "file::src/greeting.py", + "kind": "function", + "parent_id": null, + "change_kind": "modified", + "analysis_source": "structural", + "location": { + "file": "src/greeting.py", + "line_start": 1, + "line_end": 2 + }, + "evidence": [ + { + "kind": "ast_parse", + "file": "src/greeting.py", + "line_start": 1, + "line_end": 2, + "snippet": "def greet(name):" + } + ] + } + ], + "relationships": [ + { + "id": "rel::file::src/greeting.py->sym::src/greeting.py::greet", + "kind": "defines", + "source_id": "file::src/greeting.py", + "target_id": "sym::src/greeting.py::greet", + "analysis_source": "structural", + "confidence": null, + "resolution_method": null, + "evidence": [ + { + "kind": "ast_parse", + "file": "src/greeting.py", + "line_start": 1, + "line_end": 2 + } + ], + "label": null + } + ], + "summary": null, + "metadata": { + "privacy_tier": "local", + "cloud_providers_used": [], + "analysis_duration_ms": 12, + "languages_detected": [ + "python" + ], + "files_analyzed": 1, + "files_skipped": 0, + "llm_calls": 0, + "llm_model": null, + "tiers_used": [ + "structural" + ], + "warnings": [] + } +} diff --git a/diffgraph/structural.py b/diffgraph/structural.py index 0d00258..2d0ca1f 100644 --- a/diffgraph/structural.py +++ b/diffgraph/structural.py @@ -18,6 +18,7 @@ from typing import Dict, List, Optional, Sequence, Tuple from diffgraph import __version__ as package_version +from diffgraph.contract import CURRENT_SCHEMA_VERSION from diffgraph.git_snapshot import ( GitSnapshotError, ResolutionWarning, @@ -426,7 +427,7 @@ def analyze_local_diff( relationships.sort(key=lambda item: (item["id"], item["kind"])) warnings.sort(key=lambda item: (item.get("file", ""), item["code"], item.get("detail", ""))) return { - "schema_version": "2.0", + "schema_version": CURRENT_SCHEMA_VERSION, "generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), "wild_version": wild_version, "diff_ref": { diff --git a/setup.py b/setup.py index 7f6abff..d797107 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,12 @@ name="wild", version="1.1.0", packages=find_packages(), - package_data={"diffgraph": ["schema/*.json"]}, + package_data={ + "diffgraph": [ + "schema/diffgraph-v2.schema.json", + "schema/diffgraph-v2.structural.example.json", + ] + }, install_requires=[ "click>=8.1.7", "click-spinner>=0.1.10", diff --git a/tests/test_contract.py b/tests/test_contract.py new file mode 100644 index 0000000..9069985 --- /dev/null +++ b/tests/test_contract.py @@ -0,0 +1,113 @@ +"""Tests for the reusable, packaged DiffGraph artifact contract.""" +from __future__ import annotations + +import copy +import json +import subprocess +import sys +from importlib import resources + +import pytest + +import diffgraph.contract as contract +from diffgraph.contract import ( + CURRENT_SCHEMA_VERSION, + SUPPORTED_SCHEMA_MAJOR, + DiffGraphContractError, + load_schema, + schema_version, + validate_artifact, +) + + +@pytest.fixture +def golden_artifact(): + path = resources.files("diffgraph").joinpath( + "schema/diffgraph-v2.structural.example.json" + ) + return json.loads(path.read_text(encoding="utf-8")) + + +def test_packaged_schema_and_complete_golden_validate(golden_artifact): + schema = load_schema() + + assert schema["$id"].endswith("/v2.0/schema.json") + assert CURRENT_SCHEMA_VERSION == "2.0" + assert SUPPORTED_SCHEMA_MAJOR == 2 + validate_artifact(golden_artifact) + + +def test_golden_contains_only_local_structural_claims(golden_artifact): + assert golden_artifact["summary"] is None + assert golden_artifact["metadata"]["privacy_tier"] == "local" + assert golden_artifact["metadata"]["llm_calls"] == 0 + assert all( + item["analysis_source"] == "structural" + for collection in ("files", "symbols", "relationships") + for item in golden_artifact[collection] + ) + + +@pytest.mark.parametrize("value", [None, 2, "2", "v2", "2.0.0", "02.0", "2.-1"]) +def test_schema_version_rejects_malformed_values(value): + with pytest.raises(DiffGraphContractError, match=r"MAJOR\.MINOR"): + schema_version(value) + + +def test_schema_version_rejects_unknown_major(golden_artifact): + golden_artifact["schema_version"] = "3.0" + + with pytest.raises(DiffGraphContractError, match="Unsupported DiffGraph schema major 3"): + validate_artifact(golden_artifact) + + +def test_supported_additive_minor_is_accepted_when_schema_valid(golden_artifact): + golden_artifact["schema_version"] = "2.17" + + validate_artifact(golden_artifact) + + +def test_supported_minor_still_rejects_schema_invalid_artifact(golden_artifact): + artifact = copy.deepcopy(golden_artifact) + artifact["schema_version"] = "2.17" + del artifact["metadata"]["privacy_tier"] + + with pytest.raises(DiffGraphContractError, match="artifact validation failed"): + validate_artifact(artifact) + + +def test_invalid_packaged_schema_fails_closed(golden_artifact, monkeypatch): + monkeypatch.setattr( + contract, + "load_schema", + lambda: { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": 123, + }, + ) + + with pytest.raises(DiffGraphContractError, match="canonical DiffGraph schema is invalid"): + validate_artifact(golden_artifact) + + +def test_formatter_import_and_validation_do_not_load_ai_modules(): + script = """ +import json +import sys +from importlib import resources +from diffgraph.formatters.terminal import TerminalFormatter +artifact = json.loads(resources.files('diffgraph').joinpath( + 'schema/diffgraph-v2.structural.example.json' +).read_text(encoding='utf-8')) +TerminalFormatter(artifact) +assert 'diffgraph.ai_analysis' not in sys.modules +assert 'agents' not in sys.modules +""" + result = subprocess.run( + [sys.executable, "-c", script], + check=False, + capture_output=True, + text=True, + ) + + assert result.returncode == 0, result.stderr diff --git a/tests/test_structural.py b/tests/test_structural.py index 80b92bd..768990b 100644 --- a/tests/test_structural.py +++ b/tests/test_structural.py @@ -509,16 +509,16 @@ def test_cli_missing_structural_output_parent_is_a_click_error(tmp_path, monkeyp def test_schema_errors_become_click_errors(monkeypatch): - import jsonschema as jsonschema_module from click import ClickException - from diffgraph.cli import _validate_structural_artifact + from diffgraph.contract import DiffGraphContractError + import diffgraph.cli as cli - def invalid_schema(*args, **kwargs): - raise jsonschema_module.SchemaError("invalid schema") + def invalid_artifact(*args, **kwargs): + raise DiffGraphContractError("invalid schema") - monkeypatch.setattr(jsonschema_module, "validate", invalid_schema) + monkeypatch.setattr(cli, "validate_artifact", invalid_artifact) with pytest.raises(ClickException, match="structural artifact validation failed"): - _validate_structural_artifact({}) + cli._validate_structural_artifact({}) def test_missing_parser_dependency_is_a_run_level_cli_error(tmp_path, monkeypatch): diff --git a/tests/test_terminal_formatter.py b/tests/test_terminal_formatter.py index 8aa7347..dc51a04 100644 --- a/tests/test_terminal_formatter.py +++ b/tests/test_terminal_formatter.py @@ -32,11 +32,13 @@ def _make_file(file_id: str, path: str, change_kind: str = "modified", language: str = "Python") -> dict: return { - "id": file_id, + "id": f"file::{file_id}", "path": path, "change_kind": change_kind, - "language": language, - "stats": {"additions": 10, "deletions": 3}, + "language": language.lower(), + "lines_added": 10, + "lines_removed": 3, + "analysis_source": "structural", } @@ -49,20 +51,26 @@ def _make_symbol( line_end: int = 10, ) -> dict: return { - "id": sym_id, + "id": f"sym::fixture.py::{sym_id}", "name": name, - "file_id": file_id, + "qualified_name": name, + "file_id": f"file::{file_id}", "kind": "function", "change_kind": change_kind, - "location": {"line_start": line_start, "line_end": line_end}, + "analysis_source": "structural", + "location": { + "file": "fixture.py", + "line_start": max(1, line_start), + "line_end": max(1, line_end), + } if change_kind != "deleted" else None, } def _make_import_rel(rel_id: str, source_id: str, target_id: str) -> dict: return { - "id": rel_id, - "source_id": source_id, - "target_id": target_id, + "id": f"rel::{rel_id}->fixture", + "source_id": f"file::{source_id}", + "target_id": f"file::{target_id}", "kind": "imports", "analysis_source": "structural", } @@ -78,12 +86,12 @@ def _make_diffgraph( return { "schema_version": "2.0", "generated_at": "2026-07-10T16:30:00Z", + "wild_version": "1.1.0", "diff_ref": diff_ref or {"kind": "unstaged"}, "files": files or [], "symbols": symbols or [], "relationships": relationships or [], "metadata": metadata or { - "analysis_source": "structural", "privacy_tier": "local", "analysis_duration_ms": 840, }, @@ -384,6 +392,7 @@ def test_render_no_symbols_file_fallback(): assert "FILES CHANGED" in output assert "legacy/mystery.py" in output assert "legacy/helper.py" in output + assert "+10 / -3" in output assert "symbol extraction unavailable" in output @@ -395,7 +404,6 @@ def test_render_footer_shows_duration(): files = [_make_file("f1", "auth/validator.py", change_kind="modified")] symbols = [_make_symbol("s1", "fn", "f1", "modified", 1, 5)] metadata = { - "analysis_source": "structural", "privacy_tier": "local", "analysis_duration_ms": 1234, } From 70bcc9496ba230d09b65de68b99f0b0f9a9d4167 Mon Sep 17 00:00:00 2001 From: nia-sg-bot Date: Wed, 12 Aug 2026 16:20:18 +0530 Subject: [PATCH 2/5] Dispatch one validated canonical artifact Build and validate local DiffGraph data once before routing the same object to JSON or terminal consumers. Define offline output, conflict, no-change, cancellation, and atomic-write behavior while preserving the legacy HTML path. --- README.md | 42 ++++++- diffgraph/artifact.py | 64 +++++++++++ diffgraph/cli.py | 62 +++++++---- diffgraph/contract.py | 20 ++++ diffgraph/formatters/terminal.py | 15 ++- tests/test_artifact_dispatch.py | 183 +++++++++++++++++++++++++++++++ tests/test_contract.py | 6 + 7 files changed, 366 insertions(+), 26 deletions(-) create mode 100644 diffgraph/artifact.py create mode 100644 tests/test_artifact_dispatch.py diff --git a/README.md b/README.md index b7c62b9..e2272bd 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,9 @@ This will: ### Command-line Options - `--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) +- `--format`: Select `html` (default), `terminal`, or canonical `json` output. +- `--output` or `-o`: Specify the HTML or JSON output path. HTML defaults to + `diffgraph.html`; JSON defaults to stdout. Terminal output is always stdout. - `--no-open`: Don't automatically open the HTML report in browser - `--structural-json`: Write a local Python structural DiffGraph v2 artifact to the given path (`-` for stdout). Applies to `wild diff` only. - `--version`: Show version information @@ -66,17 +68,26 @@ Example: wild --output my-report.html --no-open ``` -### Local structural JSON (experimental) +### Canonical local output (experimental) A deterministic, network-free Python baseline can be written as a validated DiffGraph v2 artifact without changing the existing AI/HTML default: ```bash +wild diff --format json +wild diff --format json --output diffgraph.json +wild diff --format terminal +# Compatibility spelling retained for existing scripts: wild --structural-json diffgraph.json diff wild --structural-json staged.json diff --staged -- src/ wild --structural-json - diff -- path/to/file.py ``` +`--structural-json PATH` remains a compatibility alias for canonical JSON with +that destination. Do not combine it with `--format` or `--output`; ambiguous +combinations are usage errors. Likewise, `--format terminal` cannot use +`--output`. Terminal-only `--compact` and `--all` flags follow `diff`. + This increment intentionally supports only local unstaged (`index` → working tree) and staged (`HEAD` → index) snapshots. Put pathspecs after `--`. Pathspecs are interpreted relative to the directory where `wild` is invoked, @@ -92,6 +103,33 @@ is claimed. Every file records old/new paths, modes, Git object IDs, and content SHA-256 values in structural evidence, while symbol/relationship evidence names the parser package, query revision, and source blob identity. +#### CLI and offline contract + +- Each canonical invocation resolves the requested Git snapshot once, builds + one artifact, validates it against the packaged schema, and passes that same + validated object to the JSON or terminal consumer. Consumers never re-read + repository files or rebuild the artifact. +- JSON sent to stdout contains only the artifact. Terminal output also uses + stdout. A successful JSON file write reports its path on stderr; diagnostics, + usage help, and errors use stderr. Explicit JSON paths are replaced atomically + and parent directories are not created implicitly. +- Exit code `0` means success, including a snapshot with no changes. Empty JSON + has empty `files`, `symbols`, and `relationships` arrays; terminal output says + that the selected snapshot has no changes. Runtime, validation, output, and + cancellation failures return `1`; option/command usage errors return `2`. + Ctrl-C prints Click's `Aborted!` diagnostic and does not dispatch an artifact + or print a success message. +- Canonical JSON and terminal modes are local/offline. They import or invoke no + AI or network module, make no network calls, report `privacy_tier: local` and + `llm_calls: 0`, and use only local Git/object/worktree data plus packaged + parser/schema resources. + +The default `html` mode is intentionally outside this canonical dispatch in +this increment. Its existing AI analysis, progress output, report destination +(`diffgraph.html` by default), and browser-opening behavior remain unchanged; +HTML migration will happen separately rather than mixing legacy and canonical +artifact construction here. + ### Artifact compatibility DiffGraph artifacts use a `MAJOR.MINOR` `schema_version`. Consumers reject diff --git a/diffgraph/artifact.py b/diffgraph/artifact.py new file mode 100644 index 0000000..f754bdf --- /dev/null +++ b/diffgraph/artifact.py @@ -0,0 +1,64 @@ +"""Build and dispatch validated canonical DiffGraph artifacts. + +The structural producer and contract validator meet here so a CLI invocation +constructs one artifact, validates it once, and then hands the same validated +value to its selected consumer. This module is deliberately local-only: it +imports no AI SDK or network-capable DiffGraph module. +""" +from __future__ import annotations + +import json +import os +import tempfile +from pathlib import Path +from typing import Sequence + +from diffgraph.contract import ValidatedArtifact +from diffgraph.structural import analyze_local_diff + + +def build_validated_artifact( + repository: str, + *, + staged: bool = False, + pathspecs: Sequence[str] = (), + wild_version: str, +) -> ValidatedArtifact: + """Construct and validate exactly one local structural artifact.""" + artifact = analyze_local_diff( + repository, + staged=staged, + pathspecs=pathspecs, + wild_version=wild_version, + ) + return ValidatedArtifact.from_value(artifact) + + +def render_canonical_json(artifact: ValidatedArtifact) -> str: + """Return stable, human-readable canonical JSON with a trailing newline.""" + return json.dumps(artifact.value, indent=2, sort_keys=True) + "\n" + + +def write_canonical_json(artifact: ValidatedArtifact, destination: Path) -> None: + """Atomically write canonical JSON without creating missing directories.""" + rendered = render_canonical_json(artifact) + temporary_path = None + try: + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + dir=destination.parent, + prefix=f".{destination.name}.", + suffix=".tmp", + delete=False, + ) as temporary: + temporary_path = Path(temporary.name) + temporary.write(rendered) + os.replace(temporary_path, destination) + except BaseException: + if temporary_path is not None: + try: + temporary_path.unlink() + except FileNotFoundError: + pass + raise diff --git a/diffgraph/cli.py b/diffgraph/cli.py index 09da1f5..75aa5aa 100644 --- a/diffgraph/cli.py +++ b/diffgraph/cli.py @@ -1,16 +1,21 @@ -import json import subprocess import sys from pathlib import Path import click +from click.core import ParameterSource from typing import List, Dict import os from diffgraph import __version__ +from diffgraph.artifact import ( + build_validated_artifact, + render_canonical_json, + write_canonical_json, +) from diffgraph.contract import DiffGraphContractError, validate_artifact from diffgraph.env_loader import load_env_file, debug_environment from diffgraph.git_snapshot import GitSnapshotError from diffgraph.utils import sanitize_diff_args, involves_working_tree -from diffgraph.structural import StructuralDependencyError, analyze_local_diff +from diffgraph.structural import StructuralDependencyError # Load environment variables load_env_file() @@ -154,7 +159,7 @@ def _structural_scope(diff_args: List[str], separator_present: bool = False): def _validate_structural_artifact(artifact): - """Translate canonical contract failures into user-facing CLI errors.""" + """Backward-compatible validation helper for callers outside the CLI path.""" try: validate_artifact(artifact) except DiffGraphContractError as error: @@ -211,14 +216,17 @@ def load_file_contents(changed_files: List[Dict[str, str]], diff_args: List[str] @click.version_option(package_name='wild') @click.argument('args', nargs=-1, type=click.UNPROCESSED) @click.option('--api-key', envvar='OPENAI_API_KEY', help='OpenAI API key') -@click.option('--output', '-o', default='diffgraph.html', help='Output HTML file path') +@click.option( + '--output', '-o', default=None, + help='Output path (HTML default: diffgraph.html; JSON default: stdout)', +) @click.option( '--format', 'output_format', - type=click.Choice(['html', 'terminal'], case_sensitive=False), + type=click.Choice(['html', 'terminal', 'json'], case_sensitive=False), default='html', show_default=True, - help='Render the legacy HTML report or a local structural terminal review', + help='Render legacy HTML or a canonical local terminal/JSON artifact', ) @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') @@ -238,12 +246,20 @@ def main( ): """wild - Git wrapper CLI with DiffGraph for diff commands.""" + context = click.get_current_context() + format_was_explicit = ( + context.get_parameter_source("output_format") != ParameterSource.DEFAULT + ) if structural_json is not None and (not args or args[0] != "diff"): raise click.UsageError("--structural-json can only be used with 'diff'") - if output_format == "terminal" and (not args or args[0] != "diff"): - raise click.UsageError("--format terminal can only be used with 'diff'") - if output_format == "terminal" and structural_json is not None: - raise click.UsageError("--format terminal cannot be combined with --structural-json") + if output_format in ("terminal", "json") and (not args or args[0] != "diff"): + raise click.UsageError(f"--format {output_format} can only be used with 'diff'") + if structural_json is not None and format_was_explicit: + raise click.UsageError("--structural-json cannot be combined with --format") + if structural_json is not None and output is not None: + raise click.UsageError("--structural-json cannot be combined with --output") + if output_format == "terminal" and output is not None: + raise click.UsageError("--format terminal writes to stdout and cannot use --output") # Check if this is a diff command if args and args[0] == 'diff': @@ -259,7 +275,7 @@ def main( click.echo("❌ Error: Not a git repository", err=True) sys.exit(1) - if structural_json is not None or output_format == "terminal": + if structural_json is not None or output_format in ("terminal", "json"): compact = False show_all = False if output_format == "terminal": @@ -269,12 +285,15 @@ def main( diff_args, separator_present=_separator_follows_diff(raw_args) ) try: - artifact = analyze_local_diff( + artifact = build_validated_artifact( ".", staged=staged, pathspecs=pathspecs, wild_version=__version__ ) - except (GitSnapshotError, StructuralDependencyError) as error: + except ( + GitSnapshotError, + StructuralDependencyError, + DiffGraphContractError, + ) as error: raise click.ClickException(str(error)) from error - _validate_structural_artifact(artifact) if output_format == "terminal": from diffgraph.formatters.terminal import TerminalFormatter @@ -289,17 +308,20 @@ def main( except ValueError as error: raise click.ClickException(str(error)) from error else: - rendered = json.dumps(artifact, indent=2, sort_keys=True) + "\n" - if str(structural_json) == "-": + destination = structural_json if structural_json is not None else output + if destination is None or str(destination) == "-": + rendered = render_canonical_json(artifact) click.echo(rendered, nl=False) else: + destination = Path(destination) try: - structural_json.write_text(rendered, encoding="utf-8") + write_canonical_json(artifact, destination) except OSError as error: raise click.ClickException( - f"could not write {structural_json}: {error}" + f"could not write {destination}: {error}" ) from error - click.echo(f"✅ Structural DiffGraph written: {structural_json}", err=True) + label = "Structural" if structural_json is not None else "Canonical" + click.echo(f"✅ {label} DiffGraph written: {destination}", err=True) return # Keep the legacy AI/HTML path lazy so local structural output never @@ -364,7 +386,7 @@ def progress_callback(current_file, total_files, status): # Generate HTML report click.echo("🖨️ Generating HTML report...") - html_path = generate_html_report(analysis_result, output) + html_path = generate_html_report(analysis_result, output or "diffgraph.html") click.echo(f"✅ HTML report generated: {html_path}") # Open the HTML report in the default browser diff --git a/diffgraph/contract.py b/diffgraph/contract.py index 32de310..1a649ee 100644 --- a/diffgraph/contract.py +++ b/diffgraph/contract.py @@ -8,6 +8,7 @@ import json import re +from dataclasses import dataclass from importlib import resources from typing import Any, Mapping, Tuple @@ -21,6 +22,25 @@ class DiffGraphContractError(ValueError): """Raised when an artifact cannot be proven to satisfy the contract.""" +@dataclass(frozen=True, init=False) +class ValidatedArtifact: + """An artifact that has passed the packaged canonical contract. + + Construction is intentionally restricted to :meth:`from_value` so callers + cannot brand an unvalidated dictionary and bypass consumer checks. + """ + + value: dict + + @classmethod + def from_value(cls, artifact: dict) -> "ValidatedArtifact": + """Validate an existing value once and brand it for trusted consumers.""" + validate_artifact(artifact) + validated = object.__new__(cls) + object.__setattr__(validated, "value", artifact) + return validated + + def schema_version(value: object) -> Tuple[int, int]: """Parse and compatibility-check a DiffGraph ``MAJOR.MINOR`` version.""" if not isinstance(value, str): diff --git a/diffgraph/formatters/terminal.py b/diffgraph/formatters/terminal.py index 02c305b..ebfd899 100644 --- a/diffgraph/formatters/terminal.py +++ b/diffgraph/formatters/terminal.py @@ -22,7 +22,7 @@ from dataclasses import dataclass, field from typing import Optional -from diffgraph.contract import validate_artifact +from diffgraph.contract import ValidatedArtifact # --------------------------------------------------------------------------- @@ -136,14 +136,19 @@ class TerminalFormatter: DEFAULT_MAX_ITEMS = 10 def __init__( self, - diffgraph: dict, + diffgraph: dict | ValidatedArtifact, *, compact: bool = False, max_items: Optional[int] = DEFAULT_MAX_ITEMS, color: Optional[bool] = None, ): - validate_artifact(diffgraph) - self.dg = diffgraph + validated = ( + diffgraph + if isinstance(diffgraph, ValidatedArtifact) + else ValidatedArtifact.from_value(diffgraph) + ) + self.artifact = validated + self.dg = validated.value self.compact = compact self.max_items = max_items self._color_override = color @@ -161,6 +166,8 @@ def render(self, out=None) -> None: ranked = self._rank_symbols() self._write_header(out, color) self._write_warnings(out, color) + if not self.dg.get("files"): + out.write("No changes in the selected snapshot.\n\n") self._write_section("REVIEW FIRST", ranked.review_first, out, color, section_style="bold_yellow") self._write_section("REVIEW NEXT", ranked.review_next, out, color, section_style="bold") if not self.compact: diff --git a/tests/test_artifact_dispatch.py b/tests/test_artifact_dispatch.py new file mode 100644 index 0000000..4f91310 --- /dev/null +++ b/tests/test_artifact_dispatch.py @@ -0,0 +1,183 @@ +"""Contract tests for one-build validated canonical CLI dispatch.""" +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest +from click.testing import CliRunner + +import diffgraph.artifact as artifact_service +import diffgraph.cli as cli +from diffgraph.contract import ValidatedArtifact +from diffgraph.formatters.terminal import TerminalFormatter + + +def git(repo: Path, *args: str) -> None: + subprocess.run( + ["git", *args], cwd=repo, check=True, capture_output=True, text=True + ) + + +def changed_repo(tmp_path: Path) -> Path: + root = tmp_path / "repo" + root.mkdir() + git(root, "init") + git(root, "config", "user.name", "Artifact Tests") + git(root, "config", "user.email", "artifact@example.test") + (root / "app.py").write_text("def value():\n return 1\n", encoding="utf-8") + git(root, "add", "app.py") + git(root, "commit", "-m", "base") + (root / "app.py").write_text("def value():\n return 2\n", encoding="utf-8") + return root + + +def test_builder_constructs_and_validates_once_then_consumers_share_wrapper(monkeypatch): + value = {"identity": "one object"} + calls = [] + + def analyze(*args, **kwargs): + calls.append(("build", args, kwargs)) + return value + + def validate(candidate): + calls.append(("validate", candidate)) + assert candidate is value + + monkeypatch.setattr(artifact_service, "analyze_local_diff", analyze) + monkeypatch.setattr("diffgraph.contract.validate_artifact", validate) + + artifact = artifact_service.build_validated_artifact( + ".", staged=True, pathspecs=("src",), wild_version="test" + ) + + assert artifact.value is value + assert [call[0] for call in calls] == ["build", "validate"] + assert TerminalFormatter.__new__(TerminalFormatter) is not None # class is importable locally + assert artifact_service.render_canonical_json(artifact).endswith("\n") + + +def test_terminal_consumer_does_not_revalidate_branded_artifact(golden_artifact, monkeypatch): + artifact = ValidatedArtifact.from_value(golden_artifact) + + def unexpected_validation(*args, **kwargs): + raise AssertionError("branded CLI artifact must not be validated twice") + + monkeypatch.setattr("diffgraph.contract.validate_artifact", unexpected_validation) + formatter = TerminalFormatter(artifact, color=False) + + assert formatter.artifact is artifact + assert formatter.dg is artifact.value + + +@pytest.fixture +def golden_artifact(): + return json.loads( + (Path(__file__).parents[1] / "diffgraph/schema/diffgraph-v2.structural.example.json") + .read_text(encoding="utf-8") + ) + + +def test_format_json_stdout_is_artifact_only(tmp_path, monkeypatch): + root = changed_repo(tmp_path) + monkeypatch.chdir(root) + + result = CliRunner().invoke(cli.main, ["diff", "--format", "json"]) + + assert result.exit_code == 0, result.output + assert result.stderr == "" + parsed = json.loads(result.stdout) + assert parsed["files"][0]["path"] == "app.py" + + +def test_format_json_explicit_output_is_atomic_and_reports_on_stderr(tmp_path, monkeypatch): + root = changed_repo(tmp_path) + destination = root / "artifact.json" + monkeypatch.chdir(root) + + result = CliRunner().invoke( + cli.main, ["diff", "--format", "json", "--output", str(destination)] + ) + + assert result.exit_code == 0, result.output + assert result.stdout == "" + assert "Canonical DiffGraph written" in result.stderr + assert json.loads(destination.read_text(encoding="utf-8"))["schema_version"] == "2.0" + assert not list(root.glob(".artifact.json.*.tmp")) + + +@pytest.mark.parametrize( + "arguments,message", + [ + (["--structural-json", "legacy.json", "--format", "json", "diff"], "cannot be combined"), + (["--structural-json", "legacy.json", "--output", "other.json", "diff"], "cannot be combined"), + (["diff", "--format", "terminal", "--output", "terminal.txt"], "writes to stdout"), + (["status", "--format", "json"], "can only be used with 'diff'"), + ], +) +def test_canonical_option_conflicts_are_usage_errors(arguments, message): + result = CliRunner().invoke(cli.main, arguments) + + assert result.exit_code == 2 + assert result.stdout == "" + assert message in result.stderr + + +def test_no_change_json_and_terminal_succeed(tmp_path, monkeypatch): + root = changed_repo(tmp_path) + git(root, "checkout", "--", "app.py") + monkeypatch.chdir(root) + + json_result = CliRunner().invoke(cli.main, ["diff", "--format", "json"]) + terminal_result = CliRunner().invoke(cli.main, ["diff", "--format", "terminal"]) + + assert json_result.exit_code == 0 + assert json.loads(json_result.stdout)["files"] == [] + assert terminal_result.exit_code == 0 + assert "No changes in the selected snapshot." in terminal_result.stdout + assert terminal_result.stderr == "" + + +@pytest.mark.parametrize("output_format", ["json", "terminal"]) +def test_canonical_paths_are_offline_and_do_not_import_ai_modules( + output_format, tmp_path, monkeypatch +): + root = changed_repo(tmp_path) + monkeypatch.chdir(root) + sys.modules.pop("diffgraph.ai_analysis", None) + sys.modules.pop("agents", None) + + import socket + + monkeypatch.setattr( + socket, + "socket", + lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("network access")), + ) + result = CliRunner().invoke(cli.main, ["diff", "--format", output_format]) + + assert result.exit_code == 0, result.output + assert "diffgraph.ai_analysis" not in sys.modules + assert "agents" not in sys.modules + + +def test_ctrl_c_is_exit_one_on_stderr_and_does_not_create_output(tmp_path, monkeypatch): + root = changed_repo(tmp_path) + destination = root / "cancelled.json" + monkeypatch.chdir(root) + + def interrupt(*args, **kwargs): + raise KeyboardInterrupt + + monkeypatch.setattr(cli, "build_validated_artifact", interrupt) + result = CliRunner().invoke( + cli.main, ["diff", "--format", "json", "--output", str(destination)] + ) + + assert result.exit_code == 1 + assert result.stdout == "" + assert "Aborted!" in result.stderr + assert not destination.exists() diff --git a/tests/test_contract.py b/tests/test_contract.py index 9069985..c50cdd2 100644 --- a/tests/test_contract.py +++ b/tests/test_contract.py @@ -14,6 +14,7 @@ CURRENT_SCHEMA_VERSION, SUPPORTED_SCHEMA_MAJOR, DiffGraphContractError, + ValidatedArtifact, load_schema, schema_version, validate_artifact, @@ -37,6 +38,11 @@ def test_packaged_schema_and_complete_golden_validate(golden_artifact): validate_artifact(golden_artifact) +def test_validated_artifact_cannot_bypass_validation(): + with pytest.raises(TypeError): + ValidatedArtifact({}) + + def test_golden_contains_only_local_structural_claims(golden_artifact): assert golden_artifact["summary"] is None assert golden_artifact["metadata"]["privacy_tier"] == "local" From be6e4aec680d57345e51741c1d1137ef9a6033ad Mon Sep 17 00:00:00 2001 From: nia-sg-bot Date: Wed, 12 Aug 2026 16:37:39 +0530 Subject: [PATCH 3/5] Render HTML from the canonical artifact Make the default HTML report a self-contained offline consumer of the once-built validated artifact. Keep the former AI report behind an explicit deprecated legacy mode for one compatibility release. --- README.md | 67 +++++++----- diffgraph/cli.py | 41 ++++++-- diffgraph/formatters/html.py | 163 +++++++++++++++++++++++++++++ tests/test_artifact_dispatch.py | 58 +++++++++++ tests/test_html_formatter.py | 179 ++++++++++++++++++++++++++++++++ tests/test_structural.py | 20 +++- 6 files changed, 490 insertions(+), 38 deletions(-) create mode 100644 diffgraph/formatters/html.py create mode 100644 tests/test_html_formatter.py diff --git a/README.md b/README.md index e2272bd..eea17dc 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ # DiffGraph-CLI -DiffGraph-CLI is a powerful command-line tool that visualizes code changes using AI. It reads your current git diffs and untracked files, uses AI to understand the implications of your changes, and generates a beautiful, shareable HTML report with a dependency graph. +DiffGraph-CLI visualizes code changes from a validated canonical artifact. Its default HTML, terminal, and JSON reports are deterministic and local; the former AI-driven HTML report remains available for one compatibility release under an explicit deprecated option. ## 🌟 Features - 📊 Visualizes code changes as a dependency graph -- 🤖 AI-powered analysis of code changes +- 🤖 Deprecated AI HTML compatibility mode for one release - 🌙 Dark mode support - 📝 Markdown-formatted summaries - 🔍 Syntax highlighting for code blocks @@ -44,19 +44,19 @@ Add your OpenAI API key to the .env file Basic usage: ```bash -wild +wild diff ``` This will: -1. Read your current git changes -2. Analyze them using AI -3. Generate an HTML report (`diffgraph.html`) -4. Open the report in your default browser +1. Resolve the selected local Git snapshot once +2. Build and validate one canonical DiffGraph artifact +3. Generate an HTML report (`diffgraph.html`) from that artifact +4. Open the complete report in your default browser ### Command-line Options - `--api-key`: Specify your OpenAI API key (defaults to OPENAI_API_KEY environment variable) -- `--format`: Select `html` (default), `terminal`, or canonical `json` output. +- `--format`: Select canonical `html` (default), `terminal`, or `json` output. `legacy-html` temporarily selects the deprecated AI report. - `--output` or `-o`: Specify the HTML or JSON output path. HTML defaults to `diffgraph.html`; JSON defaults to stdout. Terminal output is always stdout. - `--no-open`: Don't automatically open the HTML report in browser @@ -65,15 +65,16 @@ This will: Example: ```bash -wild --output my-report.html --no-open +wild diff --output my-report.html --no-open ``` ### Canonical local output (experimental) -A deterministic, network-free Python baseline can be written as a validated -DiffGraph v2 artifact without changing the existing AI/HTML default: +A deterministic, network-free Python baseline can be rendered from one validated +DiffGraph v2 artifact: ```bash +wild diff --format html --no-open wild diff --format json wild diff --format json --output diffgraph.json wild diff --format terminal @@ -107,8 +108,8 @@ the parser package, query revision, and source blob identity. - Each canonical invocation resolves the requested Git snapshot once, builds one artifact, validates it against the packaged schema, and passes that same - validated object to the JSON or terminal consumer. Consumers never re-read - repository files or rebuild the artifact. + validated object to the HTML, JSON, or terminal consumer. Consumers never + re-read repository files or rebuild the artifact. - JSON sent to stdout contains only the artifact. Terminal output also uses stdout. A successful JSON file write reports its path on stderr; diagnostics, usage help, and errors use stderr. Explicit JSON paths are replaced atomically @@ -119,16 +120,22 @@ the parser package, query revision, and source blob identity. cancellation failures return `1`; option/command usage errors return `2`. Ctrl-C prints Click's `Aborted!` diagnostic and does not dispatch an artifact or print a success message. -- Canonical JSON and terminal modes are local/offline. They import or invoke no - AI or network module, make no network calls, report `privacy_tier: local` and - `llm_calls: 0`, and use only local Git/object/worktree data plus packaged - parser/schema resources. - -The default `html` mode is intentionally outside this canonical dispatch in -this increment. Its existing AI analysis, progress output, report destination -(`diffgraph.html` by default), and browser-opening behavior remain unchanged; -HTML migration will happen separately rather than mixing legacy and canonical -artifact construction here. +- Canonical HTML, JSON, and terminal modes are local/offline. They import or + invoke no AI or network module, make no network calls, report + `privacy_tier: local` and `llm_calls: 0`, and use only local + Git/object/worktree data plus packaged parser/schema resources. Canonical HTML + is self-contained and has no external asset dependency. + +#### Deprecated AI HTML compatibility + +For this compatibility release only, `wild diff --format legacy-html` retains +old AI analysis, progress output, default `diffgraph.html` destination, +`--output`, `--no-open`, and browser-opening behavior. This option is deprecated +and scheduled for removal after one release. It may call the configured AI +provider, and its old renderer is isolated in `diffgraph/html_report.py`; that +legacy renderer still loads Mermaid, Tailwind, Highlight.js, and Marked from +external CDNs. Canonical `--format html` does not import that module or any AI +SDK. ### Artifact compatibility @@ -142,12 +149,16 @@ canonical schema and a complete local-only example are packaged under ## 📊 Example Output -The generated HTML report includes: -- A summary of code changes -- A Mermaid.js dependency graph -- Syntax-highlighted code blocks +The canonical HTML report includes: +- Exact artifact metadata and optional canonical summary +- Files, symbols, warnings, and relationship evidence +- Deterministically ordered relationship topology - Dark mode support -- Responsive design for all screen sizes +- Responsive, self-contained styling + +The deprecated `legacy-html` report retains its Mermaid.js diagram, +syntax-highlighted code blocks, and AI-generated summary during the one-release +compatibility window. ## 🤝 Contributing diff --git a/diffgraph/cli.py b/diffgraph/cli.py index 75aa5aa..96482da 100644 --- a/diffgraph/cli.py +++ b/diffgraph/cli.py @@ -223,10 +223,15 @@ def load_file_contents(changed_files: List[Dict[str, str]], diff_args: List[str] @click.option( '--format', 'output_format', - type=click.Choice(['html', 'terminal', 'json'], case_sensitive=False), + type=click.Choice( + ['html', 'terminal', 'json', 'legacy-html'], case_sensitive=False + ), default='html', show_default=True, - help='Render legacy HTML or a canonical local terminal/JSON artifact', + help=( + 'Render a canonical local HTML, terminal, or JSON artifact; ' + 'legacy-html retains the deprecated AI report for one compatibility release' + ), ) @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') @@ -252,7 +257,10 @@ def main( ) if structural_json is not None and (not args or args[0] != "diff"): raise click.UsageError("--structural-json can only be used with 'diff'") - if output_format in ("terminal", "json") and (not args or args[0] != "diff"): + if ( + output_format in ("terminal", "json", "legacy-html") + or (output_format == "html" and format_was_explicit) + ) and (not args or args[0] != "diff"): raise click.UsageError(f"--format {output_format} can only be used with 'diff'") if structural_json is not None and format_was_explicit: raise click.UsageError("--structural-json cannot be combined with --format") @@ -275,7 +283,7 @@ def main( click.echo("❌ Error: Not a git repository", err=True) sys.exit(1) - if structural_json is not None or output_format in ("terminal", "json"): + if structural_json is not None or output_format in ("html", "terminal", "json"): compact = False show_all = False if output_format == "terminal": @@ -294,7 +302,7 @@ def main( DiffGraphContractError, ) as error: raise click.ClickException(str(error)) from error - if output_format == "terminal": + if structural_json is None and output_format == "terminal": from diffgraph.formatters.terminal import TerminalFormatter try: @@ -307,6 +315,25 @@ def main( ).render() except ValueError as error: raise click.ClickException(str(error)) from error + elif structural_json is None and output_format == "html": + from diffgraph.formatters.html import HtmlFormatter + + destination = Path(output or "diffgraph.html") + try: + html_path = HtmlFormatter(artifact).write(destination) + except (OSError, TypeError, ValueError) as error: + raise click.ClickException( + f"could not write {destination}: {error}" + ) from error + click.echo(f"✅ HTML report generated: {html_path}") + if not no_open: + click.echo("🌐 Opening report in browser...") + if sys.platform == 'darwin': + subprocess.run(['open', str(html_path)]) + elif sys.platform == 'win32': + os.startfile(html_path) + else: + subprocess.run(['xdg-open', str(html_path)]) else: destination = structural_json if structural_json is not None else output if destination is None or str(destination) == "-": @@ -324,8 +351,8 @@ def main( click.echo(f"✅ {label} DiffGraph written: {destination}", err=True) return - # Keep the legacy AI/HTML path lazy so local structural output never - # imports a network-capable SDK. + # One-release compatibility path. Keep it lazy so canonical output never + # imports a network-capable SDK. Remove after the documented transition. try: from click_spinner import spinner from diffgraph.ai_analysis import CodeAnalysisAgent diff --git a/diffgraph/formatters/html.py b/diffgraph/formatters/html.py new file mode 100644 index 0000000..81aa227 --- /dev/null +++ b/diffgraph/formatters/html.py @@ -0,0 +1,163 @@ +"""Deterministic HTML adapter for a validated canonical DiffGraph artifact. + +This consumer performs no repository reads, structural analysis, AI calls, or +network access. It renders only values already present in the artifact. The +report is self-contained; the deprecated legacy HTML renderer remains the only +HTML path with external CDN assets. +""" +from __future__ import annotations + +import html +import json +import os +import tempfile +from pathlib import Path + +from diffgraph.contract import ValidatedArtifact + + +def _text(value: object) -> str: + """Escape one artifact value for HTML text content.""" + if value is None: + return "null" + if isinstance(value, bool): + return "true" if value else "false" + return html.escape(str(value), quote=True) + + +def _json(value: object) -> str: + """Render stable escaped JSON for human inspection.""" + return html.escape(json.dumps(value, indent=2, sort_keys=True), quote=True) + + +def _embedded_json(value: object) -> str: + """Embed JSON without allowing artifact strings to terminate the script tag.""" + return ( + json.dumps(value, indent=2, sort_keys=True) + .replace("<", "\\u003c") + .replace(">", "\\u003e") + .replace("&", "\\u0026") + ) + + +class HtmlFormatter: + """Render one already-validated DiffGraph artifact as self-contained HTML.""" + + def __init__(self, artifact: ValidatedArtifact): + if not isinstance(artifact, ValidatedArtifact): + raise TypeError("HtmlFormatter requires a ValidatedArtifact") + self.artifact = artifact + self.dg = artifact.value + + def render(self) -> str: + """Return deterministic HTML containing only canonical artifact data.""" + files = sorted(self.dg["files"], key=lambda item: item["id"]) + symbols = sorted(self.dg["symbols"], key=lambda item: item["id"]) + relationships = sorted(self.dg["relationships"], key=lambda item: item["id"]) + metadata = self.dg["metadata"] + warnings = metadata.get("warnings", []) + + file_items = self._object_items(files, "No files in the selected snapshot.") + symbol_items = self._object_items(symbols, "No symbols in the artifact.") + relationship_items = self._relationship_items(relationships) + warning_items = ( + "".join(f"
  • {_json(warning)}
  • " for warning in warnings) + or "
  • None
  • " + ) + summary = ( + f"

    Summary

    {_json(self.dg['summary'])}
    " + if "summary" in self.dg + else "" + ) + + return f""" + + + + + DiffGraph Report + + + +

    DiffGraph Report

    +
    +

    Artifact

    +
    +
    Schema version
    {_text(self.dg['schema_version'])}
    +
    Generated at
    {_text(self.dg['generated_at'])}
    +
    wild version
    {_text(self.dg['wild_version'])}
    +
    +

    Diff reference

    {_json(self.dg['diff_ref'])}
    + {summary} +
    +

    Files

    {file_items}
    +

    Symbols

    {symbol_items}
    +
    +

    Deterministic topology

    + {relationship_items} +
    +

    Warnings

      {warning_items}
    +

    Metadata

    {_json(metadata)}
    + + + +""" + + @staticmethod + def _object_items(items: list[dict], empty_message: str) -> str: + if not items: + return f'

    {html.escape(empty_message)}

    ' + return "".join( + f'

    {_text(item["id"])}

    {_json(item)}
    ' + for item in items + ) + + @staticmethod + def _relationship_items(relationships: list[dict]) -> str: + if not relationships: + return '

    No relationships in the artifact.

    ' + return "".join( + "
    " + f'
    {_text(item["source_id"])}' + f'{_text(item["kind"])}' + f'{_text(item["target_id"])}
    ' + f'
    {_json(item)}
    ' + "
    " + for item in relationships + ) + + def write(self, destination: Path) -> Path: + """Atomically write a complete report and return its absolute path.""" + rendered = self.render() + temporary_path = None + try: + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + dir=destination.parent, + prefix=f".{destination.name}.", + suffix=".tmp", + delete=False, + ) as temporary: + temporary_path = Path(temporary.name) + temporary.write(rendered) + os.replace(temporary_path, destination) + except BaseException: + if temporary_path is not None: + try: + temporary_path.unlink() + except FileNotFoundError: + pass + raise + return destination.absolute() diff --git a/tests/test_artifact_dispatch.py b/tests/test_artifact_dispatch.py index 4f91310..5aeaf57 100644 --- a/tests/test_artifact_dispatch.py +++ b/tests/test_artifact_dispatch.py @@ -60,6 +60,64 @@ def validate(candidate): assert artifact_service.render_canonical_json(artifact).endswith("\n") +def test_json_terminal_and_html_receive_the_exact_once_built_artifact( + golden_artifact, monkeypatch, tmp_path +): + artifact = ValidatedArtifact.from_value(golden_artifact) + build_calls = [] + received = {} + + def build(*args, **kwargs): + build_calls.append((args, kwargs)) + return artifact + + class RecordingTerminal: + DEFAULT_MAX_ITEMS = 10 + + def __init__(self, candidate, **kwargs): + received["terminal"] = candidate + + def render(self): + pass + + class RecordingHtml: + def __init__(self, candidate): + received["html"] = candidate + + def write(self, destination): + return Path(destination).absolute() + + def render_json(candidate): + received["json"] = candidate + return "{}\n" + + monkeypatch.setattr(cli, "is_git_repo", lambda: True) + monkeypatch.setattr(cli, "build_validated_artifact", build) + monkeypatch.setattr(cli, "render_canonical_json", render_json) + monkeypatch.setattr("diffgraph.formatters.terminal.TerminalFormatter", RecordingTerminal) + monkeypatch.setattr("diffgraph.formatters.html.HtmlFormatter", RecordingHtml) + + invocations = [ + ["diff", "--format", "json"], + ["diff", "--format", "terminal"], + [ + "diff", + "--format", + "html", + "--output", + str(tmp_path / "report.html"), + "--no-open", + ], + ] + for arguments in invocations: + result = CliRunner().invoke(cli.main, arguments) + assert result.exit_code == 0, result.output + + assert len(build_calls) == 3 + assert received == {"json": artifact, "terminal": artifact, "html": artifact} + assert all(candidate is artifact for candidate in received.values()) + + def test_terminal_consumer_does_not_revalidate_branded_artifact(golden_artifact, monkeypatch): artifact = ValidatedArtifact.from_value(golden_artifact) diff --git a/tests/test_html_formatter.py b/tests/test_html_formatter.py new file mode 100644 index 0000000..c52100f --- /dev/null +++ b/tests/test_html_formatter.py @@ -0,0 +1,179 @@ +"""Golden and CLI contracts for canonical HTML artifact consumption.""" +from __future__ import annotations + +import json +import re +import subprocess +import sys +from pathlib import Path + +from click.testing import CliRunner + +import diffgraph.cli as cli +from diffgraph.contract import ValidatedArtifact +from diffgraph.formatters.html import HtmlFormatter + + +def git(repo: Path, *args: str) -> None: + subprocess.run(["git", *args], cwd=repo, check=True, capture_output=True, text=True) + + +def changed_repo(tmp_path: Path) -> Path: + root = tmp_path / "repo" + root.mkdir() + git(root, "init") + git(root, "config", "user.name", "HTML Tests") + git(root, "config", "user.email", "html@example.test") + (root / "app.py").write_text("def value():\n return 1\n", encoding="utf-8") + git(root, "add", "app.py") + git(root, "commit", "-m", "base") + (root / "app.py").write_text("def value():\n return 2\n", encoding="utf-8") + return root + + +def golden_artifact() -> dict: + return json.loads( + (Path(__file__).parents[1] / "diffgraph/schema/diffgraph-v2.structural.example.json") + .read_text(encoding="utf-8") + ) + + +def embedded_artifact(report: str) -> dict: + match = re.search( + r'', + report, + re.DOTALL, + ) + assert match is not None + return json.loads(match.group(1)) + + +def test_html_formatter_renders_complete_golden_artifact_without_inference(): + value = golden_artifact() + artifact = ValidatedArtifact.from_value(value) + + report = HtmlFormatter(artifact).render() + + assert embedded_artifact(report) == value + assert "src/greeting.py" in report + assert "sym::src/greeting.py::greet" in report + assert "rel::file::src/greeting.py->sym::src/greeting.py::greet" in report + assert "privacy_tier" in report + assert "Deterministic topology" in report + # The canonical example has no prose summary or inferred impact claim. + assert "likely impact" not in report.lower() + assert "AI analysis" not in report + assert "https://" not in report + + +def test_html_formatter_sorts_topology_and_escapes_artifact_text(): + value = golden_artifact() + first = dict(value["relationships"][0]) + first["id"] = "rel::z->target" + second = dict(first) + second.update( + {"id": "rel::a->target", "label": ""} + ) + value["relationships"] = [first, second] + artifact = ValidatedArtifact.from_value(value) + + report = HtmlFormatter(artifact).render() + + assert report.index("rel::a->target") < report.index("rel::z->target") + assert "