diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..fbf80a9 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,30 @@ +name: Tests + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12"] + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + - run: python -m pip install --upgrade pip + - run: python -m pip install -r requirements.txt -e . pytest + - run: python -m pytest + - run: python -m compileall -q diffgraph tests + - run: git diff --check diff --git a/README.md b/README.md index ee66c7b..533ba0d 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ This will: - `--api-key`: Specify your OpenAI API key (defaults to OPENAI_API_KEY environment variable) - `--output` or `-o`: Specify the output HTML file path (default: diffgraph.html) - `--no-open`: Don't automatically open the HTML report in browser +- `--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 Example: @@ -65,6 +66,32 @@ Example: wild --output my-report.html --no-open ``` +### Local structural JSON (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 --structural-json diffgraph.json diff +wild --structural-json staged.json diff --staged -- src/ +wild --structural-json - diff -- path/to/file.py +``` + +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, +matching Git's command-line behavior. Commit ranges are rejected rather than +analyzed with guessed semantics. + +Python (`.py`) is the only language with structural symbol/import extraction in +this baseline. Other changed files remain in `files[]` and receive a scoped +`UNSUPPORTED_LANGUAGE` warning. Syntax/decoding failures receive a scoped +`PARSE_FAILURE` warning and do not produce invented symbol changes. Import +targets are explicitly labeled unresolved/external; no project-wide resolution +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. + ## 📊 Example Output The generated HTML report includes: diff --git a/diffgraph/__init__.py b/diffgraph/__init__.py index 317dee1..9caa0ea 100644 --- a/diffgraph/__init__.py +++ b/diffgraph/__init__.py @@ -2,4 +2,4 @@ DiffGraph - A CLI tool for visualizing code changes with AI """ -__version__ = "0.1.0" \ No newline at end of file +__version__ = "1.1.0" diff --git a/diffgraph/cli.py b/diffgraph/cli.py index 15e0522..c2a586f 100644 --- a/diffgraph/cli.py +++ b/diffgraph/cli.py @@ -1,14 +1,15 @@ +import json import subprocess import sys from pathlib import Path import click -from click_spinner import spinner from typing import List, Dict import os -from diffgraph.ai_analysis import CodeAnalysisAgent -from diffgraph.html_report import generate_html_report, AnalysisResult +from diffgraph import __version__ 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 # Load environment variables load_env_file() @@ -86,6 +87,77 @@ def get_changed_files(diff_args: List[str] = None) -> List[Dict[str, str]]: return changed_files +class _RawArgsCommand(click.Command): + """Retain the raw separator that Click removes from variadic arguments.""" + + def parse_args(self, ctx, args): + ctx.meta["raw_args"] = tuple(args) + return super().parse_args(ctx, args) + + +def _separator_follows_diff(raw_args) -> bool: + """Return whether the raw CLI placed ``--`` after the ``diff`` operand.""" + + value_options = {"--api-key", "--output", "-o", "--structural-json"} + index = 0 + while index < len(raw_args): + argument = raw_args[index] + if argument in value_options: + index += 2 + continue + if any(argument.startswith(option + "=") for option in value_options): + index += 1 + continue + if argument.startswith("-o") and argument != "-o": + index += 1 + continue + if argument == "diff": + return "--" in raw_args[index + 1 :] + index += 1 + return False + + +def _structural_scope(diff_args: List[str], separator_present: bool = False): + """Accept only the exact local snapshot modes implemented by this increment.""" + staged = False + pathspecs = [] + after_separator = separator_present + for argument in diff_args: + if argument == "--": + after_separator = True + elif argument in ("--staged", "--cached") and not after_separator: + staged = True + elif after_separator: + pathspecs.append(argument) + else: + raise click.UsageError( + "--structural-json currently supports only unstaged or --staged/--cached " + "local diffs; put pathspecs after '--'" + ) + return staged, pathspecs + + +def _validate_structural_artifact(artifact): + """Fail closed when the canonical v2 schema cannot validate the artifact.""" + 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: + raise click.ClickException(f"structural artifact validation failed: {error}") from error + + def load_file_contents(changed_files: List[Dict[str, str]], diff_args: List[str] = None) -> List[Dict[str, str]]: """ Load contents of changed files. @@ -129,16 +201,27 @@ def load_file_contents(changed_files: List[Dict[str, str]], diff_args: List[str] return files_with_content -@click.command(context_settings={"ignore_unknown_options": True, "allow_extra_args": True}) +@click.command( + cls=_RawArgsCommand, + context_settings={"ignore_unknown_options": True, "allow_extra_args": True}, +) @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('--no-open', is_flag=True, help='Do not open the HTML report automatically') @click.option('--debug-env', is_flag=True, help='Debug environment variable loading') -def main(args, api_key: str, output: str, no_open: bool, debug_env: bool): +@click.option( + '--structural-json', + type=click.Path(dir_okay=False, path_type=Path), + help="Write the local Python structural DiffGraph v2 artifact ('-' for stdout)", +) +def main(args, api_key: str, output: str, no_open: bool, debug_env: bool, structural_json: Path): """wild - Git wrapper CLI with DiffGraph for diff commands.""" + if structural_json is not None and (not args or args[0] != "diff"): + raise click.UsageError("--structural-json can only be used with 'diff'") + # Check if this is a diff command if args and args[0] == 'diff': # Handle diff command with custom logic @@ -153,6 +236,42 @@ def main(args, api_key: str, output: str, no_open: bool, debug_env: bool): click.echo("❌ Error: Not a git repository", err=True) sys.exit(1) + if structural_json is not None: + raw_args = click.get_current_context().meta.get("raw_args", ()) + staged, pathspecs = _structural_scope( + diff_args, separator_present=_separator_follows_diff(raw_args) + ) + try: + artifact = analyze_local_diff( + ".", staged=staged, pathspecs=pathspecs, wild_version=__version__ + ) + except (GitSnapshotError, StructuralDependencyError) as error: + raise click.ClickException(str(error)) from error + _validate_structural_artifact(artifact) + rendered = json.dumps(artifact, indent=2, sort_keys=True) + "\n" + if str(structural_json) == "-": + click.echo(rendered, nl=False) + else: + try: + structural_json.write_text(rendered, encoding="utf-8") + except OSError as error: + raise click.ClickException( + f"could not write {structural_json}: {error}" + ) from error + click.echo(f"✅ Structural DiffGraph written: {structural_json}", err=True) + return + + # Keep the legacy AI/HTML path lazy so local structural output never + # imports a network-capable SDK. + try: + from click_spinner import spinner + from diffgraph.ai_analysis import CodeAnalysisAgent + from diffgraph.html_report import generate_html_report, AnalysisResult + except ImportError as error: + raise click.ClickException( + f"The AI report path requires additional dependencies: {error}" + ) from error + click.echo("🔍 Scanning for changed files...") changed_files = get_changed_files(diff_args) @@ -233,4 +352,4 @@ def progress_callback(current_file, total_files, status): sys.exit(1) if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/diffgraph/git_snapshot.py b/diffgraph/git_snapshot.py new file mode 100644 index 0000000..f77676d --- /dev/null +++ b/diffgraph/git_snapshot.py @@ -0,0 +1,454 @@ +"""Resolve exact Git object identities for index and working-tree changes. + +This module deliberately models only the two local snapshot pairs: + +* staged: ``HEAD`` -> index +* unstaged: index -> working tree + +Commit/range resolution belongs to a separate layer. Paths returned by Git are +read using its NUL-delimited raw format, so tabs, newlines, and other unusual +filename bytes are not delimiters. +""" + +from __future__ import annotations + +import os +import posixpath +import stat +import subprocess +from dataclasses import dataclass +from typing import List, Optional, Sequence, Tuple + + +@dataclass(frozen=True) +class SnapshotEntry: + """One changed path pair and its exact pre/post Git identities. + + ``status`` is Git's one-letter raw status (``A``, ``M``, ``D``, ``R``, + ``C``, or ``T``). A path, mode, or object ID is ``None`` when that side of + the change does not exist. Object IDs are full-length hexadecimal IDs. + """ + + status: str + old_path: Optional[str] + new_path: Optional[str] + old_mode: Optional[str] + new_mode: Optional[str] + old_oid: Optional[str] + new_oid: Optional[str] + + +@dataclass(frozen=True) +class ResolutionWarning: + """A structured, non-fatal reason a snapshot entry was not resolved.""" + + code: str + message: str + path: Optional[str] = None + + +@dataclass(frozen=True) +class SnapshotResolution: + """Deterministically ordered entries plus any resolver warnings.""" + + entries: Tuple[SnapshotEntry, ...] + warnings: Tuple[ResolutionWarning, ...] + + +class GitSnapshotError(RuntimeError): + """A Git or working-tree operation required for exact snapshots failed.""" + + +@dataclass(frozen=True) +class _RawEntry: + status: str + old_path: Optional[str] + new_path: Optional[str] + old_mode: Optional[str] + new_mode: Optional[str] + old_oid: Optional[str] + new_oid: Optional[str] + + +def resolve_staged( + repository: str, pathspecs: Optional[Sequence[str]] = None +) -> SnapshotResolution: + """Resolve changes from ``HEAD`` to the index. + + ``pathspecs`` are passed verbatim after Git's ``--`` separator. In + particular, a non-empty scope is never replaced with a repository-wide + query if it has no matches. + """ + + return _resolve(repository, pathspecs, staged=True) + + +def resolve_unstaged( + repository: str, pathspecs: Optional[Sequence[str]] = None +) -> SnapshotResolution: + """Resolve tracked changes from the index to the working tree. + + Git does not include ordinary untracked files in this diff. For each + post-change regular file, the object ID is computed with ``git + hash-object --path`` so clean filters and attributes match ``git add`` + semantics without modifying the index. + """ + + return _resolve(repository, pathspecs, staged=False) + + +def _resolve( + repository: str, pathspecs: Optional[Sequence[str]], staged: bool +) -> SnapshotResolution: + warnings: List[ResolutionWarning] = [] + root = _repository_root(repository, warnings) + if root is None: + return SnapshotResolution((), tuple(warnings)) + + command = ["git", "diff"] + if staged: + command.append("--cached") + command.extend( + ["--raw", "-z", "--no-abbrev", "--no-ext-diff", "--find-renames=50%"] + ) + scoped_pathspecs = _root_relative_pathspecs(repository, root, pathspecs) + if scoped_pathspecs: + command.append("--") + command.extend(scoped_pathspecs) + + output = _run(command, root, warnings, "git_diff_failed") + if output is None: + return SnapshotResolution((), tuple(warnings)) + + raw_entries = _parse_raw(output, warnings) + entries: List[SnapshotEntry] = [] + for raw in raw_entries: + if staged: + entry = _exact_staged_entry(raw, warnings) + else: + entry = _exact_unstaged_entry(root, raw, warnings) + if entry is not None: + entries.append(entry) + + entries.sort(key=_entry_sort_key) + return SnapshotResolution(tuple(entries), tuple(warnings)) + + +def _repository_root( + repository: str, warnings: List[ResolutionWarning] +) -> Optional[str]: + output = _run( + ["git", "rev-parse", "--show-toplevel"], + os.fspath(repository), + warnings, + "not_a_git_repository", + ) + if output is None: + return None + return os.fsdecode(output.rstrip(b"\n")) + + +def repository_root(repository: str) -> str: + """Return the repository root or raise a typed, user-facing error.""" + + output = run_git(repository, "rev-parse", "--show-toplevel") + return os.fsdecode(output.rstrip(b"\n")) + + +def run_git( + repository: str, *args: str, input_bytes: Optional[bytes] = None +) -> bytes: + """Run Git with captured output and raise :class:`GitSnapshotError`.""" + + try: + completed = subprocess.run( + ["git", *args], + cwd=os.fspath(repository), + input=input_bytes, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + except (OSError, ValueError) as error: + raise GitSnapshotError(str(error)) from error + + if completed.returncode != 0: + detail = os.fsdecode(completed.stderr).strip() + message = detail or "Git command exited with status {}".format( + completed.returncode + ) + raise GitSnapshotError(message) + return completed.stdout + + +def _root_relative_pathspecs( + repository: str, + root: str, + pathspecs: Optional[Sequence[str]], +) -> List[str]: + """Translate caller-relative pathspecs for a Git process run at ``root``.""" + + if not pathspecs: + return [] + caller = os.path.abspath(os.fspath(repository)) + prefix = os.path.relpath(caller, root) + if prefix == ".": + return list(pathspecs) + prefix = prefix.replace(os.sep, "/") + scoped: List[str] = [] + for pathspec in pathspecs: + if os.path.isabs(pathspec): + scoped.append(os.path.relpath(pathspec, root).replace(os.sep, "/")) + else: + scoped.append(_prefix_pathspec(pathspec, prefix)) + return scoped + + +def _prefix_pathspec(pathspec: str, prefix: str) -> str: + """Prefix a Git pathspec while preserving common pathspec magic.""" + + def prefixed(pattern: str) -> str: + return posixpath.normpath(posixpath.join(prefix, pattern)) + + if pathspec.startswith(":/"): + return pathspec + if pathspec.startswith(":("): + end = pathspec.find(")") + if end != -1: + magic = pathspec[2:end].split(",") + if "top" in magic: + return pathspec + return pathspec[: end + 1] + prefixed(pathspec[end + 1 :]) + if pathspec.startswith((":!", ":^")): + return pathspec[:2] + prefixed(pathspec[2:]) + return prefixed(pathspec) + + +def _run( + command: Sequence[str], + cwd: str, + warnings: List[ResolutionWarning], + code: str, + input_bytes: Optional[bytes] = None, + path: Optional[str] = None, +) -> Optional[bytes]: + try: + if not command or command[0] != "git": + raise ValueError("only Git commands are supported") + return run_git(cwd, *command[1:], input_bytes=input_bytes) + except (GitSnapshotError, ValueError) as error: + warnings.append(ResolutionWarning(code, str(error), path)) + return None + + +def _parse_raw(data: bytes, warnings: List[ResolutionWarning]) -> List[_RawEntry]: + fields = data.split(b"\0") + if fields and fields[-1] == b"": + fields.pop() + + parsed: List[_RawEntry] = [] + index = 0 + while index < len(fields): + header = fields[index] + index += 1 + try: + metadata = header[1:].split(b" ") if header.startswith(b":") else [] + if len(metadata) != 5: + raise ValueError("malformed raw-diff metadata") + old_mode_b, new_mode_b, old_oid_b, new_oid_b, status_b = metadata + status_text = status_b.decode("ascii") + status = status_text[:1] + path_count = 2 if status in ("R", "C") else 1 + if not status or index + path_count > len(fields): + raise ValueError("malformed raw-diff path fields") + paths = [os.fsdecode(value) for value in fields[index : index + path_count]] + index += path_count + + if status in ("R", "C"): + old_path, new_path = paths + elif status == "A": + old_path, new_path = None, paths[0] + elif status == "D": + old_path, new_path = paths[0], None + else: + old_path = new_path = paths[0] + + parsed.append( + _RawEntry( + status=status, + old_path=old_path, + new_path=new_path, + old_mode=_mode(old_mode_b), + new_mode=_mode(new_mode_b), + old_oid=_oid(old_oid_b), + new_oid=_oid(new_oid_b), + ) + ) + except (UnicodeDecodeError, ValueError) as error: + warnings.append( + ResolutionWarning("malformed_git_output", str(error), None) + ) + # Record boundaries are no longer trustworthy. Returning the + # successfully parsed prefix avoids inventing changes. + break + return parsed + + +def _mode(value: bytes) -> Optional[str]: + text = value.decode("ascii") + return None if not text or set(text) == {"0"} else text + + +def _oid(value: bytes) -> Optional[str]: + text = value.decode("ascii") + return None if not text or set(text) == {"0"} else text + + +def _exact_staged_entry( + raw: _RawEntry, warnings: List[ResolutionWarning] +) -> Optional[SnapshotEntry]: + if (raw.old_path is not None and raw.old_oid is None) or ( + raw.new_path is not None and raw.new_oid is None + ): + warnings.append( + ResolutionWarning( + "missing_object_id", + "Git did not provide an exact staged object ID", + raw.new_path or raw.old_path, + ) + ) + return None + return SnapshotEntry(**raw.__dict__) + + +def _exact_unstaged_entry( + root: str, raw: _RawEntry, warnings: List[ResolutionWarning] +) -> Optional[SnapshotEntry]: + if raw.old_path is not None and raw.old_oid is None: + warnings.append( + ResolutionWarning( + "missing_object_id", + "Git did not provide an exact index object ID", + raw.old_path, + ) + ) + return None + + new_oid = raw.new_oid + if raw.new_path is not None: + new_oid = _working_tree_oid(root, raw.new_path, raw.new_mode, warnings) + if new_oid is None: + return None + + return SnapshotEntry( + status=raw.status, + old_path=raw.old_path, + new_path=raw.new_path, + old_mode=raw.old_mode, + new_mode=raw.new_mode, + old_oid=raw.old_oid, + new_oid=new_oid, + ) + + +def _working_tree_oid( + root: str, + path: str, + mode: Optional[str], + warnings: List[ResolutionWarning], +) -> Optional[str]: + result = _working_tree_blob(root, path, mode, warnings) + return result[1] if result is not None else None + + +def _working_tree_blob( + root: str, + path: str, + mode: Optional[str], + warnings: List[ResolutionWarning], +) -> Optional[Tuple[bytes, str]]: + full_path = os.path.join(root, path) + try: + before = os.lstat(full_path) + if mode == "120000" and stat.S_ISLNK(before.st_mode): + content = os.fsencode(os.readlink(full_path)) + elif mode in ("100644", "100755") and stat.S_ISREG(before.st_mode): + with open(full_path, "rb") as handle: + content = handle.read() + opened = os.fstat(handle.fileno()) + if _stat_identity(opened) != _stat_identity(before): + raise OSError("working-tree file changed while it was opened") + else: + warnings.append( + ResolutionWarning( + "unsupported_worktree_entry", + "Cannot derive a Git blob ID for working-tree mode {}".format(mode), + path, + ) + ) + return None + after = os.lstat(full_path) + if _stat_identity(before) != _stat_identity(after): + raise OSError("working-tree file changed while it was hashed") + except OSError as error: + warnings.append(ResolutionWarning("worktree_read_failed", str(error), path)) + return None + + command = ["git", "hash-object", "--stdin"] + if mode != "120000": + command.append("--path={}".format(path)) + output = _run( + command, + root, + warnings, + "hash_object_failed", + input_bytes=content, + path=path, + ) + if output is None: + return None + oid = os.fsdecode(output).strip() + if not oid or any(character not in "0123456789abcdef" for character in oid): + warnings.append( + ResolutionWarning("malformed_hash_object_output", "Git returned an invalid object ID", path) + ) + return None + return content, oid + + +def read_worktree_blob( + root: str, path: str, mode: Optional[str], expected_oid: Optional[str] = None +) -> bytes: + """Read exact regular-file or symlink bytes and verify their Git identity.""" + + warnings: List[ResolutionWarning] = [] + result = _working_tree_blob(root, path, mode, warnings) + if result is None: + warning = warnings[0] if warnings else ResolutionWarning( + "worktree_read_failed", "working-tree content could not be read", path + ) + raise GitSnapshotError("{}: {}".format(warning.code, warning.message)) + content, oid = result + if expected_oid is not None and oid != expected_oid: + raise GitSnapshotError( + "working-tree content no longer matches resolved Git identity" + ) + return content + + +def _stat_identity(value: os.stat_result) -> Tuple[int, int, int, int, int]: + return ( + value.st_mode, + value.st_dev, + value.st_ino, + value.st_size, + value.st_mtime_ns, + ) + + +def _entry_sort_key(entry: SnapshotEntry) -> Tuple[bytes, bytes, str]: + return ( + os.fsencode(entry.old_path or ""), + os.fsencode(entry.new_path or ""), + entry.status, + ) diff --git a/diffgraph/schema/diffgraph-v2.schema.json b/diffgraph/schema/diffgraph-v2.schema.json index 7ae06a6..083e57d 100644 --- a/diffgraph/schema/diffgraph-v2.schema.json +++ b/diffgraph/schema/diffgraph-v2.schema.json @@ -386,7 +386,22 @@ "properties": { "code": { "type": "string", - "enum": ["PARSE_FAILURE", "UNSUPPORTED_LANGUAGE", "PARTIAL_ANALYSIS", "LLM_TIMEOUT", "LLM_ERROR", "UNKNOWN"], + "enum": [ + "PARSE_FAILURE", + "UNSUPPORTED_LANGUAGE", + "PARTIAL_ANALYSIS", + "LLM_TIMEOUT", + "LLM_ERROR", + "UNKNOWN", + "not_a_git_repository", + "git_diff_failed", + "malformed_git_output", + "missing_object_id", + "unsupported_worktree_entry", + "worktree_read_failed", + "hash_object_failed", + "malformed_hash_object_output" + ], "description": "Machine-readable warning code. Consumers can surface these to the user." }, "file": { "type": "string" }, diff --git a/diffgraph/structural.py b/diffgraph/structural.py new file mode 100644 index 0000000..0d00258 --- /dev/null +++ b/diffgraph/structural.py @@ -0,0 +1,445 @@ +"""Deterministic, local DiffGraph v2 extraction from exact Git snapshots. + +This first baseline intentionally supports Python only. Other languages remain in +``files`` and produce scoped ``UNSUPPORTED_LANGUAGE`` warnings; no capability is +inferred from a filename beyond that explicit boundary. +""" +from __future__ import annotations + +import hashlib +import json +import threading +import time +from dataclasses import dataclass +from datetime import datetime, timezone +from functools import lru_cache +from importlib.metadata import PackageNotFoundError, version +from pathlib import Path +from typing import Dict, List, Optional, Sequence, Tuple + +from diffgraph import __version__ as package_version +from diffgraph.git_snapshot import ( + GitSnapshotError, + ResolutionWarning, + SnapshotEntry, + read_worktree_blob, + repository_root, + resolve_staged, + resolve_unstaged, + run_git, +) + +ANALYZER = "diffgraph-python-tree-sitter" +QUERY_VERSION = "python-structure-v1" +_PARSER_STATE = threading.local() + + +class StructuralDependencyError(ImportError): + """A required local structural parser dependency is unavailable.""" + + +@dataclass(frozen=True) +class _Symbol: + name: str + qualified_name: str + kind: str + parent: Optional[str] + start_line: int + end_line: int + text_hash: str + + +@dataclass(frozen=True) +class _Import: + module: str + line: int + snippet: str + + +def _blob(repository: str, oid: Optional[str]) -> Optional[bytes]: + if oid is None: + return None + return run_git(repository, "cat-file", "blob", oid) + + +def _worktree_bytes(repository: str, entry: SnapshotEntry) -> Optional[bytes]: + if entry.new_path is None: + return None + return read_worktree_blob( + repository, entry.new_path, entry.new_mode, expected_oid=entry.new_oid + ) + + +def _parser(): + parser = getattr(_PARSER_STATE, "python_parser", None) + if parser is not None: + return parser + try: + import tree_sitter + import tree_sitter_language_pack + except ImportError as error: + raise StructuralDependencyError( + "Python structural analysis requires tree-sitter and " + "tree-sitter-language-pack" + ) from error + + # Construct the official parser directly so byte offsets and byte input + # remain exact across language-pack releases. + parser = tree_sitter.Parser(tree_sitter_language_pack.get_language("python")) + _PARSER_STATE.python_parser = parser + return parser + + +def _node_text(source: bytes, node) -> str: + return source[node.start_byte:node.end_byte].decode("utf-8") + + +def _name_child(node): + child = node.child_by_field_name("name") + if child is not None: + return child + return next((item for item in node.children if item.type == "identifier"), None) + + +def _parse_python(content: bytes) -> Tuple[List[_Symbol], List[_Import]]: + # Do not silently replace undecodable source: the warning must identify the + # exact side that could not be structurally analyzed. + content.decode("utf-8") + tree = _parser().parse(content) + if tree.root_node.has_error: + raise ValueError("Tree-sitter reported a syntax error") + + symbols: List[_Symbol] = [] + imports: List[_Import] = [] + symbol_occurrences: Dict[str, int] = {} + + def visit(node, parents: Tuple[Tuple[str, str], ...] = ()) -> None: + next_parents = parents + if node.type in ("class_definition", "function_definition"): + name_node = _name_child(node) + if name_node is not None: + name = _node_text(content, name_node) + parent = parents[-1][0] if parents else None + is_method = bool(parents and parents[-1][1] == "class") + kind = ( + "class" + if node.type == "class_definition" + else "method" + if is_method + else "function" + ) + base_qname = "{}.{}".format(parent, name) if parent else name + occurrence = symbol_occurrences.get(base_qname, 0) + symbol_occurrences[base_qname] = occurrence + 1 + qname = ( + base_qname + if occurrence == 0 + else "{}#{}".format(base_qname, occurrence) + ) + body = content[node.start_byte:node.end_byte] + symbols.append( + _Symbol( + name, + qname, + kind, + parent, + node.start_point[0] + 1, + node.end_point[0] + 1, + hashlib.sha256(body).hexdigest(), + ) + ) + next_parents = (*parents, (qname, kind)) + elif node.type in ("import_statement", "import_from_statement"): + snippet = _node_text(content, node) + if node.type == "import_statement": + names = [c for c in node.children if c.type in ("dotted_name", "aliased_import")] + for item in names: + if item.type == "aliased_import": + name_node = item.child_by_field_name("name") + if name_node is None: + raise ValueError("aliased import has no name") + raw = _node_text(content, name_node) + else: + raw = _node_text(content, item) + imports.append(_Import(raw, node.start_point[0] + 1, snippet)) + else: + module_node = node.child_by_field_name("module_name") + if module_node is not None: + imports.append(_Import(_node_text(content, module_node), node.start_point[0] + 1, snippet)) + for child in node.children: + visit(child, next_parents) + + visit(tree.root_node) + return sorted(symbols, key=lambda s: (s.qualified_name, s.start_line)), sorted( + imports, key=lambda item: (item.line, item.module, item.snippet) + ) + + +def _change_kind(status: str, old_oid: Optional[str], new_oid: Optional[str]) -> str: + if status == "A": + return "added" + if status == "D": + return "deleted" + if status == "R": + return "renamed" if old_oid == new_oid else "renamed_modified" + return "modified" + + +def _provenance(entry: SnapshotEntry, old: Optional[bytes], new: Optional[bytes]) -> str: + values = { + "status": entry.status, "old_path": entry.old_path, "new_path": entry.new_path, + "old_mode": entry.old_mode, "new_mode": entry.new_mode, + "old_oid": entry.old_oid, "new_oid": entry.new_oid, + "old_sha256": hashlib.sha256(old).hexdigest() if old is not None else None, + "new_sha256": hashlib.sha256(new).hexdigest() if new is not None else None, + } + return json.dumps(values, sort_keys=True, separators=(",", ":")) + + +@lru_cache(maxsize=1) +def _parser_version() -> str: + try: + return version("tree-sitter-language-pack") + except PackageNotFoundError: + return "unknown" + + +def _parser_provenance(oid: Optional[str]) -> str: + return "analyzer={};parser=tree-sitter-language-pack@{};query={};blob={}".format( + ANALYZER, _parser_version(), QUERY_VERSION, oid or "absent" + ) + + +def _warning(code: str, path: Optional[str], detail: str) -> Dict[str, str]: + result = {"code": code, "detail": detail} + if path is not None: + result["file"] = path + return result + + +def _resolution_warning(item: ResolutionWarning) -> Dict[str, str]: + known_codes = { + "not_a_git_repository", + "git_diff_failed", + "malformed_git_output", + "missing_object_id", + "unsupported_worktree_entry", + "worktree_read_failed", + "hash_object_failed", + "malformed_hash_object_output", + } + code = item.code if item.code in known_codes else "UNKNOWN" + return _warning(code, item.path, "{}: {}".format(item.code, item.message)) + + +def _symbol_id(path: str, qualified_name: str) -> str: + return "sym::{}::{}".format(path, qualified_name) + + +def _evidence(path: str, symbol: _Symbol, oid: Optional[str]) -> List[Dict]: + return [{ + "kind": "ast_parse", "file": path, "line_start": symbol.start_line, + "line_end": symbol.end_line, "detail": _parser_provenance(oid), + }] + + +def _keyed_imports(items: List[_Import]) -> Dict[Tuple[str, int], _Import]: + occurrences: Dict[str, int] = {} + result: Dict[Tuple[str, int], _Import] = {} + for import_item in items: + occurrence = occurrences.get(import_item.module, 0) + occurrences[import_item.module] = occurrence + 1 + result[(import_item.module, occurrence)] = import_item + return result + + +def analyze_local_diff( + repository: str = ".", *, staged: bool = False, + pathspecs: Optional[Sequence[str]] = None, wild_version: str = package_version, +) -> Dict: + """Build a schema-v2 structural artifact for HEAD→index or index→worktree.""" + started = time.monotonic() + root = repository_root(repository) + resolution = (resolve_staged if staged else resolve_unstaged)(repository, pathspecs) + warnings = [_resolution_warning(item) for item in resolution.warnings] + files: List[Dict] = [] + symbols: List[Dict] = [] + relationships: List[Dict] = [] + analyzed = skipped = 0 + + for entry in resolution.entries: + path = entry.new_path or entry.old_path + if path is None: + raise RuntimeError("snapshot entry has neither an old nor a new path") + try: + old = _blob(root, entry.old_oid) + new = _blob(root, entry.new_oid) if staged else _worktree_bytes(root, entry) + except (OSError, GitSnapshotError) as error: + old = new = None + warnings.append(_warning("PARTIAL_ANALYSIS", path, "snapshot read failed: {}".format(error))) + + file_entry = { + "id": "file::" + path, "path": path, + "old_path": entry.old_path if entry.status in ("R", "C") else None, + "language": "python" if Path(path).suffix.lower() == ".py" else None, + "change_kind": _change_kind(entry.status, entry.old_oid, entry.new_oid), + "analysis_source": "structural", + "evidence": [{"kind": "git_diff_name_status", "detail": _provenance(entry, old, new)}], + } + files.append(file_entry) + if file_entry["language"] != "python": + skipped += 1 + warnings.append(_warning("UNSUPPORTED_LANGUAGE", path, "Deterministic extraction currently supports Python (.py) only.")) + continue + if (old is None and entry.old_oid is not None) or ( + new is None and entry.new_oid is not None + ): + skipped += 1 + continue + + parser_errors = ( + UnicodeDecodeError, ValueError, RuntimeError, OSError, TypeError + ) + try: + old_symbols, old_imports = _parse_python(old) if old is not None else ([], []) + except parser_errors as error: + warnings.append(_warning("PARSE_FAILURE", entry.old_path or path, "pre-change: {}: {}".format(type(error).__name__, error))) + skipped += 1 + continue + try: + new_symbols, new_imports = _parse_python(new) if new is not None else ([], []) + except parser_errors as error: + warnings.append(_warning("PARSE_FAILURE", path, "post-change: {}: {}".format(type(error).__name__, error))) + skipped += 1 + continue + analyzed += 1 + old_map = {item.qualified_name: item for item in old_symbols} + new_map = {item.qualified_name: item for item in new_symbols} + output_path = path + for qname in sorted(set(old_map) | set(new_map)): + before, after = old_map.get(qname), new_map.get(qname) + current = after or before + if current is None: + raise RuntimeError("symbol comparison produced no symbol") + kind = "added" if before is None else "deleted" if after is None else ( + "unchanged" if before.text_hash == after.text_hash else "modified" + ) + oid = entry.new_oid if after is not None else entry.old_oid + location = None if after is None else { + "file": output_path, "line_start": current.start_line, "line_end": current.end_line, + } + symbols.append({ + "id": _symbol_id(output_path, qname), "name": current.name, + "qualified_name": qname, "file_id": "file::" + output_path, + "kind": current.kind, + "parent_id": _symbol_id(output_path, current.parent) if current.parent else None, + "change_kind": kind, "analysis_source": "structural", + "location": location, "evidence": _evidence(output_path, current, oid), + }) + + for qname, item in sorted(new_map.items()): + source = _symbol_id(output_path, qname) + if item.parent: + target = _symbol_id(output_path, item.parent) + relationships.append({ + "id": "rel::{}->{}".format(target, source), "kind": "contains", + "source_id": target, "target_id": source, "analysis_source": "structural", + "evidence": _evidence(output_path, item, entry.new_oid), + }) + elif item.kind in ("function", "class"): + relationships.append({ + "id": "rel::file::{}->{}".format(output_path, source), "kind": "defines", + "source_id": "file::" + output_path, "target_id": source, + "analysis_source": "structural", "evidence": _evidence(output_path, item, entry.new_oid), + }) + + old_import_map = _keyed_imports(old_imports) + new_import_map = _keyed_imports(new_imports) + for import_key in sorted(set(old_import_map) | set(new_import_map)): + before = old_import_map.get(import_key) + after = new_import_map.get(import_key) + item = after or before + if item is None: + raise RuntimeError("import comparison produced no import") + module, occurrence = import_key + suffix = "" if occurrence == 0 else "#{}".format(occurrence) + qualified_name = "import::{}{}".format(module, suffix) + target = _symbol_id(output_path, qualified_name) + oid = entry.new_oid if after is not None else entry.old_oid + evidence = [ + { + "kind": "import_statement", + "file": output_path, + "line_start": item.line, + "line_end": item.line, + "snippet": item.snippet, + "detail": _parser_provenance(oid), + } + ] + import_kind = ( + "added" + if before is None + else "deleted" + if after is None + else "unchanged" + if before.snippet == after.snippet + else "modified" + ) + location = ( + { + "file": output_path, + "line_start": item.line, + "line_end": item.line, + } + if after is not None + else None + ) + symbols.append( + { + "id": target, + "name": module, + "qualified_name": qualified_name, + "file_id": "file::" + output_path, + "kind": "import", + "parent_id": None, + "change_kind": import_kind, + "analysis_source": "structural", + "location": location, + "evidence": evidence, + } + ) + if after is not None: + relationships.append( + { + "id": "rel::file::{}->{}".format(output_path, target), + "kind": "imports", + "source_id": "file::" + output_path, + "target_id": target, + "analysis_source": "structural", + "evidence": evidence, + "label": "unresolved/external module: " + module, + } + ) + + files.sort(key=lambda item: item["path"]) + symbols.sort(key=lambda item: item["id"]) + 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", + "generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "wild_version": wild_version, + "diff_ref": { + "kind": "staged" if staged else "unstaged", "base_ref": "HEAD" if staged else None, + "head_ref": None, "pathspecs": list(pathspecs or []), "repo_root": root, + }, + "files": files, "symbols": symbols, "relationships": relationships, + "summary": None, + "metadata": { + "privacy_tier": "local", "cloud_providers_used": [], + "analysis_duration_ms": int((time.monotonic() - started) * 1000), + "languages_detected": ["python"] if any(f["language"] == "python" for f in files) else [], + "files_analyzed": analyzed, "files_skipped": skipped, "llm_calls": 0, + "llm_model": None, "tiers_used": ["structural"], "warnings": warnings, + }, + } diff --git a/requirements.txt b/requirements.txt index 54fc57b..851224b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,10 @@ click>=8.1.7 +jsonschema>=4.18 +tree-sitter-language-pack>=0.10.0,<2 +tree-sitter>=0.23,<1 openai-agents>=0.0.17 python-dotenv>=1.0.0 -networkx>=3.5 +networkx>=3.4.2,<3.5; python_version < "3.11" +networkx>=3.5; python_version >= "3.11" click-spinner>=0.1.10 -pyinstaller>=6.14.2 \ No newline at end of file +pyinstaller>=6.14.2 diff --git a/setup.py b/setup.py index 33e623f..7f6abff 100644 --- a/setup.py +++ b/setup.py @@ -2,17 +2,26 @@ setup( name="wild", - version="1.0.0", + version="1.1.0", packages=find_packages(), + package_data={"diffgraph": ["schema/*.json"]}, install_requires=[ "click>=8.1.7", + "click-spinner>=0.1.10", + "jsonschema>=4.18", + 'networkx>=3.4.2,<3.5; python_version < "3.11"', + 'networkx>=3.5; python_version >= "3.11"', + "openai-agents>=0.0.17", + "python-dotenv>=1.0.0", + "tree-sitter-language-pack>=0.10.0,<2", + "tree-sitter>=0.23,<1", ], entry_points={ "console_scripts": [ "wild=diffgraph.cli:main", ], }, - python_requires=">=3.7", + python_requires=">=3.10", author="WildestAI Team", description="AI-powered git-wrapper CLI tool with visualizing diffs", long_description=open("README.md").read(), @@ -22,9 +31,8 @@ "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", ], -) \ No newline at end of file +) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..5d88992 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,12 @@ +import os + +import pytest + + +@pytest.fixture(autouse=True) +def isolate_git_configuration(monkeypatch): + """Keep repository fixtures independent of user and system Git config.""" + + monkeypatch.setenv("GIT_CONFIG_GLOBAL", os.devnull) + monkeypatch.setenv("GIT_CONFIG_SYSTEM", os.devnull) + monkeypatch.setenv("GIT_CONFIG_NOSYSTEM", "1") diff --git a/tests/fixtures/python_topology.json b/tests/fixtures/python_topology.json new file mode 100644 index 0000000..13da081 --- /dev/null +++ b/tests/fixtures/python_topology.json @@ -0,0 +1,92 @@ +{ + "files": [ + { + "path": "add.py", + "old_path": null, + "change_kind": "added" + }, + { + "path": "delete.py", + "old_path": null, + "change_kind": "deleted" + }, + { + "path": "modify.py", + "old_path": null, + "change_kind": "modified" + }, + { + "path": "renamed.py", + "old_path": "rename.py", + "change_kind": "renamed_modified" + } + ], + "symbols": [ + { + "id": "sym::add.py::added", + "kind": "function", + "change_kind": "added" + }, + { + "id": "sym::add.py::import::external.pkg", + "kind": "import", + "change_kind": "added" + }, + { + "id": "sym::add.py::import::os", + "kind": "import", + "change_kind": "added" + }, + { + "id": "sym::delete.py::Deleted", + "kind": "class", + "change_kind": "deleted" + }, + { + "id": "sym::modify.py::created", + "kind": "function", + "change_kind": "added" + }, + { + "id": "sym::modify.py::removed", + "kind": "function", + "change_kind": "deleted" + }, + { + "id": "sym::modify.py::retained", + "kind": "function", + "change_kind": "modified" + }, + { + "id": "sym::renamed.py::moved", + "kind": "function", + "change_kind": "modified" + } + ], + "relationships": [ + { + "id": "rel::file::add.py->sym::add.py::added", + "kind": "defines" + }, + { + "id": "rel::file::add.py->sym::add.py::import::external.pkg", + "kind": "imports" + }, + { + "id": "rel::file::add.py->sym::add.py::import::os", + "kind": "imports" + }, + { + "id": "rel::file::modify.py->sym::modify.py::created", + "kind": "defines" + }, + { + "id": "rel::file::modify.py->sym::modify.py::retained", + "kind": "defines" + }, + { + "id": "rel::file::renamed.py->sym::renamed.py::moved", + "kind": "defines" + } + ] +} diff --git a/tests/test_git_snapshot.py b/tests/test_git_snapshot.py new file mode 100644 index 0000000..4037b02 --- /dev/null +++ b/tests/test_git_snapshot.py @@ -0,0 +1,290 @@ +import os +import subprocess + +from diffgraph.git_snapshot import resolve_staged, resolve_unstaged + + +def git(repo, *args, input_bytes=None): + completed = subprocess.run( + ["git"] + list(args), + cwd=str(repo), + input=input_bytes, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=True, + ) + return completed.stdout + + +def write(repo, path, content): + target = repo / path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(content) + + +def oid(repo, revision): + return git(repo, "rev-parse", revision).decode("ascii").strip() + + +def index_oid(repo, path): + output = git(repo, "ls-files", "-s", "--", path) + return output.split()[1].decode("ascii") + + +def commit_all(repo, message="baseline"): + git(repo, "add", "-A") + git(repo, "commit", "-m", message) + + +def make_repo(tmp_path): + repo = tmp_path / "repo" + repo.mkdir() + git(repo, "init") + git(repo, "config", "user.name", "Snapshot Tests") + git(repo, "config", "user.email", "snapshot@example.test") + return repo + + +def test_staged_add_modify_delete_and_rename_have_exact_identities(tmp_path): + repo = make_repo(tmp_path) + write(repo, "modify.txt", b"old modify\n") + write(repo, "delete.txt", b"old delete\n") + write(repo, "rename-old.txt", b"rename content\n" * 20) + commit_all(repo) + + old_modify = oid(repo, "HEAD:modify.txt") + old_delete = oid(repo, "HEAD:delete.txt") + old_rename = oid(repo, "HEAD:rename-old.txt") + + write(repo, "added.txt", b"new file\n") + write(repo, "modify.txt", b"new modify\n") + os.unlink(repo / "delete.txt") + git(repo, "mv", "rename-old.txt", "rename-new.txt") + git(repo, "add", "-A") + + result = resolve_staged(str(repo)) + entries = {entry.new_path or entry.old_path: entry for entry in result.entries} + + assert result.warnings == () + assert set(entries) == {"added.txt", "modify.txt", "delete.txt", "rename-new.txt"} + + added = entries["added.txt"] + assert (added.status, added.old_path, added.new_path) == ("A", None, "added.txt") + assert (added.old_mode, added.old_oid) == (None, None) + assert (added.new_mode, added.new_oid) == ("100644", index_oid(repo, "added.txt")) + + modified = entries["modify.txt"] + assert (modified.status, modified.old_path, modified.new_path) == ( + "M", + "modify.txt", + "modify.txt", + ) + assert modified.old_oid == old_modify + assert modified.new_oid == index_oid(repo, "modify.txt") + + deleted = entries["delete.txt"] + assert (deleted.status, deleted.old_path, deleted.new_path) == ( + "D", + "delete.txt", + None, + ) + assert (deleted.old_oid, deleted.new_oid) == (old_delete, None) + assert (deleted.old_mode, deleted.new_mode) == ("100644", None) + + renamed = entries["rename-new.txt"] + assert (renamed.status, renamed.old_path, renamed.new_path) == ( + "R", + "rename-old.txt", + "rename-new.txt", + ) + assert (renamed.old_oid, renamed.new_oid) == (old_rename, old_rename) + assert (renamed.old_mode, renamed.new_mode) == ("100644", "100644") + + +def test_unstaged_modify_and_delete_have_exact_identities(tmp_path): + repo = make_repo(tmp_path) + write(repo, "modify.txt", b"old\n") + write(repo, "delete.txt", b"delete me\n") + commit_all(repo) + old_modify = oid(repo, "HEAD:modify.txt") + old_delete = oid(repo, "HEAD:delete.txt") + + write(repo, "modify.txt", b"new working tree bytes\n") + os.unlink(repo / "delete.txt") + + result = resolve_unstaged(str(repo)) + entries = {entry.new_path or entry.old_path: entry for entry in result.entries} + + assert result.warnings == () + assert set(entries) == {"modify.txt", "delete.txt"} + modified = entries["modify.txt"] + assert modified.status == "M" + assert modified.old_oid == old_modify + assert modified.new_oid == git( + repo, "hash-object", "--stdin", "--path=modify.txt", input_bytes=b"new working tree bytes\n" + ).decode("ascii").strip() + assert len(modified.new_oid) == 40 + + deleted = entries["delete.txt"] + assert deleted.status == "D" + assert (deleted.old_oid, deleted.new_oid) == (old_delete, None) + assert (deleted.old_mode, deleted.new_mode) == ("100644", None) + + +def test_unstaged_hash_matches_git_add_clean_filter_semantics(tmp_path): + repo = make_repo(tmp_path) + write(repo, ".gitattributes", b"filtered.txt text eol=lf\n") + write(repo, "filtered.txt", b"old\n") + commit_all(repo) + + write(repo, "filtered.txt", b"line one\r\nline two\r\n") + result = resolve_unstaged(str(repo), ["filtered.txt"]) + assert result.warnings == () + assert len(result.entries) == 1 + resolved_oid = result.entries[0].new_oid + + git(repo, "add", "--", "filtered.txt") + assert resolved_oid == index_oid(repo, "filtered.txt") + + +def test_explicit_pathspec_scope_is_not_widened(tmp_path): + repo = make_repo(tmp_path) + write(repo, "inside/a.txt", b"old a\n") + write(repo, "outside/b.txt", b"old b\n") + commit_all(repo) + + write(repo, "inside/a.txt", b"staged a\n") + write(repo, "outside/b.txt", b"staged b\n") + git(repo, "add", "-A") + write(repo, "inside/a.txt", b"unstaged a\n") + write(repo, "outside/b.txt", b"unstaged b\n") + + staged = resolve_staged(str(repo), ["inside"]) + unstaged = resolve_unstaged(str(repo), ["inside"]) + no_match = resolve_staged(str(repo), ["does-not-exist"]) + + assert [entry.new_path for entry in staged.entries] == ["inside/a.txt"] + assert [entry.new_path for entry in unstaged.entries] == ["inside/a.txt"] + assert no_match.entries == () + assert no_match.warnings == () + + +def test_nul_parsing_preserves_tabs_and_newlines_in_paths(tmp_path): + repo = make_repo(tmp_path) + old_name = "old\tname\npart.txt" + new_name = "new\nname\tpart.txt" + write(repo, old_name, b"unusual path\n" * 10) + commit_all(repo) + + git(repo, "mv", old_name, new_name) + result = resolve_staged(str(repo)) + + assert result.warnings == () + assert len(result.entries) == 1 + entry = result.entries[0] + assert entry.status == "R" + assert entry.old_path == old_name + assert entry.new_path == new_name + assert entry.old_oid == entry.new_oid == oid(repo, "HEAD:" + old_name) + + +def test_repeated_resolution_is_deterministic(tmp_path): + repo = make_repo(tmp_path) + for name in ("z.txt", "a.txt", "middle.txt"): + write(repo, name, ("old " + name + "\n").encode("ascii")) + commit_all(repo) + for name in ("z.txt", "a.txt", "middle.txt"): + write(repo, name, ("new " + name + "\n").encode("ascii")) + + first = resolve_unstaged(str(repo)) + second = resolve_unstaged(str(repo)) + + assert first == second + assert [entry.new_path for entry in first.entries] == ["a.txt", "middle.txt", "z.txt"] + + +def test_git_failures_are_warnings_not_changes(tmp_path, monkeypatch): + monkeypatch.setenv("GIT_CEILING_DIRECTORIES", str(tmp_path.parent)) + result = resolve_staged(str(tmp_path)) + + assert result.entries == () + assert len(result.warnings) == 1 + assert result.warnings[0].code == "not_a_git_repository" + + +def test_unstaged_intent_to_add_and_rename_have_exact_identities(tmp_path): + repo = make_repo(tmp_path) + write(repo, "rename-old.py", b"def moved():\n return 1\n" * 20) + commit_all(repo) + old_oid = oid(repo, "HEAD:rename-old.py") + + os.rename(repo / "rename-old.py", repo / "rename-new.py") + write(repo, "added.py", b"def added():\n return 1\n") + # Intent-to-add lets Git represent working-tree additions without putting + # their content in the index; the resolver must still derive exact IDs. + git(repo, "add", "-N", "--", "rename-new.py", "added.py") + + result = resolve_unstaged(str(repo)) + entries = {entry.new_path or entry.old_path: entry for entry in result.entries} + assert result.warnings == () + assert set(entries) == {"rename-new.py", "added.py"} + + renamed = entries["rename-new.py"] + assert (renamed.status, renamed.old_path, renamed.new_path) == ( + "R", + "rename-old.py", + "rename-new.py", + ) + assert renamed.old_oid == old_oid + assert renamed.new_oid == git( + repo, + "hash-object", + "--stdin", + "--path=rename-new.py", + input_bytes=(repo / "rename-new.py").read_bytes(), + ).decode("ascii").strip() + + added = entries["added.py"] + assert (added.status, added.old_path, added.new_path) == ("A", None, "added.py") + assert added.old_oid is None + assert added.new_oid == git( + repo, + "hash-object", + "--stdin", + "--path=added.py", + input_bytes=(repo / "added.py").read_bytes(), + ).decode("ascii").strip() + + +def test_pathspecs_are_relative_to_the_calling_subdirectory(tmp_path): + repo = make_repo(tmp_path) + write(repo, "src/app.py", b"def value():\n return 1\n") + write(repo, "app.py", b"def root_value():\n return 1\n") + commit_all(repo) + write(repo, "src/app.py", b"def value():\n return 2\n") + write(repo, "app.py", b"def root_value():\n return 2\n") + + result = resolve_unstaged(str(repo / "src"), ["app.py"]) + + assert result.warnings == () + assert [entry.new_path for entry in result.entries] == ["src/app.py"] + + +def test_unstaged_symlink_hashes_raw_target_without_attributes(tmp_path): + repo = make_repo(tmp_path) + write(repo, ".gitattributes", b"*.py filter=decorate\n") + git(repo, "config", "filter.decorate.clean", "sed 's/^/filtered:/'") + os.symlink("original.py", repo / "link.py") + commit_all(repo) + + os.unlink(repo / "link.py") + os.symlink("replacement.py", repo / "link.py") + result = resolve_unstaged(str(repo), ["link.py"]) + + assert result.warnings == () + assert len(result.entries) == 1 + entry = result.entries[0] + assert entry.new_mode == "120000" + assert entry.new_oid == git( + repo, "hash-object", "--stdin", input_bytes=b"replacement.py" + ).decode("ascii").strip() diff --git a/tests/test_structural.py b/tests/test_structural.py new file mode 100644 index 0000000..9654a1e --- /dev/null +++ b/tests/test_structural.py @@ -0,0 +1,464 @@ +import json +import os +import subprocess +from pathlib import Path + +import jsonschema +import pytest + +from diffgraph.git_snapshot import GitSnapshotError, ResolutionWarning, SnapshotResolution +from diffgraph.structural import analyze_local_diff + +SCHEMA = json.loads((Path(__file__).parents[1] / "diffgraph/schema/diffgraph-v2.schema.json").read_text()) + + +def git(repo, *args, input_bytes=None): + return subprocess.run( + ["git", *args], + cwd=repo, + input=input_bytes, + check=True, + stdout=subprocess.PIPE, + ).stdout.decode().strip() + + +def write(repo, path, text): + target = repo / path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(text) + + +def repo(tmp_path): + root = tmp_path / "repo" + root.mkdir() + git(root, "init") + git(root, "config", "user.name", "Structural Tests") + git(root, "config", "user.email", "structural@example.test") + return root + + +def commit(root): + git(root, "add", "-A") + git(root, "commit", "-m", "snapshot") + + +def stable(artifact): + copy = json.loads(json.dumps(artifact)) + copy["generated_at"] = "" + copy["metadata"]["analysis_duration_ms"] = None + copy["diff_ref"]["repo_root"] = "" + return copy + + +def assert_valid(artifact): + jsonschema.validate(artifact, SCHEMA) + + +def test_staged_add_modify_delete_rename_import_is_schema_valid_and_golden(tmp_path): + root = repo(tmp_path) + write(root, "modify.py", "def retained():\n return 1\n\ndef removed():\n return 0\n") + write(root, "delete.py", "class Deleted:\n pass\n") + write(root, "rename.py", "def moved():\n return 1\n") + commit(root) + + write(root, "add.py", "import os\nfrom external.pkg import value\n\ndef added():\n return value\n") + write(root, "modify.py", "def retained():\n return 2\n\ndef created():\n return 3\n") + os.unlink(root / "delete.py") + git(root, "mv", "rename.py", "renamed.py") + write(root, "renamed.py", "def moved():\n return 2\n") + git(root, "add", "-A") + + artifact = analyze_local_diff(str(root), staged=True) + assert_valid(artifact) + assert [f["path"] for f in artifact["files"]] == ["add.py", "delete.py", "modify.py", "renamed.py"] + assert {f["path"]: f["change_kind"] for f in artifact["files"]} == { + "add.py": "added", "delete.py": "deleted", "modify.py": "modified", "renamed.py": "renamed_modified" + } + changes = {s["id"]: s["change_kind"] for s in artifact["symbols"]} + assert changes["sym::add.py::added"] == "added" + assert changes["sym::delete.py::Deleted"] == "deleted" + assert changes["sym::modify.py::retained"] == "modified" + assert changes["sym::modify.py::removed"] == "deleted" + assert changes["sym::modify.py::created"] == "added" + assert changes["sym::renamed.py::moved"] == "modified" + assert artifact["metadata"]["files_analyzed"] == 4 + imports = [r for r in artifact["relationships"] if r["kind"] == "imports"] + assert [r["label"] for r in imports] == [ + "unresolved/external module: external.pkg", "unresolved/external module: os" + ] + for file_entry in artifact["files"]: + provenance = json.loads(file_entry["evidence"][0]["detail"]) + assert provenance["old_oid"] is None or len(provenance["old_oid"]) == 40 + assert provenance["new_oid"] is None or len(provenance["new_oid"]) == 40 + assert provenance["old_sha256"] is None or len(provenance["old_sha256"]) == 64 + assert provenance["new_sha256"] is None or len(provenance["new_sha256"]) == 64 + + golden_path = Path(__file__).parent / "fixtures/python_topology.json" + expected = json.loads(golden_path.read_text()) + actual = { + "files": [{"path": f["path"], "old_path": f["old_path"], "change_kind": f["change_kind"]} for f in artifact["files"]], + "symbols": [{"id": s["id"], "kind": s["kind"], "change_kind": s["change_kind"]} for s in artifact["symbols"]], + "relationships": [{"id": r["id"], "kind": r["kind"]} for r in artifact["relationships"]], + } + if os.environ.get("UPDATE_GOLDEN"): + golden_path.write_text(json.dumps(actual, indent=2) + "\n") + pytest.skip("golden fixture regenerated") + assert actual == expected + + +def test_unstaged_uses_index_to_worktree_exact_identity_and_is_stable(tmp_path): + root = repo(tmp_path) + write(root, "app.py", "def value():\n return 1\n") + commit(root) + write(root, "app.py", "def value():\n return 2\n") + + first = analyze_local_diff(str(root)) + second = analyze_local_diff(str(root)) + assert_valid(first) + assert stable(first) == stable(second) + provenance = json.loads(first["files"][0]["evidence"][0]["detail"]) + expected = git(root, "hash-object", "--path=app.py", "app.py") + assert provenance["new_oid"] == expected + assert first["symbols"][0]["change_kind"] == "modified" + + +def test_pathspec_scope_is_not_widened(tmp_path): + root = repo(tmp_path) + write(root, "inside/a.py", "def a():\n return 1\n") + write(root, "outside/b.py", "def b():\n return 1\n") + commit(root) + write(root, "inside/a.py", "def a():\n return 2\n") + write(root, "outside/b.py", "def b():\n return 2\n") + artifact = analyze_local_diff(str(root), pathspecs=["inside"]) + assert [item["path"] for item in artifact["files"]] == ["inside/a.py"] + + +def test_unsupported_language_is_explicit_and_not_overclaimed(tmp_path): + root = repo(tmp_path) + write(root, "main.js", "export function value() { return 1; }\n") + commit(root) + write(root, "main.js", "export function value() { return 2; }\n") + artifact = analyze_local_diff(str(root)) + assert_valid(artifact) + assert artifact["symbols"] == [] + assert artifact["metadata"]["files_skipped"] == 1 + assert artifact["metadata"]["warnings"][0]["code"] == "UNSUPPORTED_LANGUAGE" + assert "Python" in artifact["metadata"]["warnings"][0]["detail"] + + +def test_parse_failure_is_scoped_and_does_not_invent_symbol_changes(tmp_path): + root = repo(tmp_path) + write(root, "broken.py", "def valid():\n return 1\n") + commit(root) + write(root, "broken.py", "def broken(:\n") + artifact = analyze_local_diff(str(root)) + assert_valid(artifact) + assert artifact["symbols"] == [] + assert artifact["relationships"] == [] + warning = artifact["metadata"]["warnings"][0] + assert warning["code"] == "PARSE_FAILURE" + assert warning["file"] == "broken.py" + assert warning["detail"].startswith("post-change:") + + +def test_no_network_calls(monkeypatch, tmp_path): + root = repo(tmp_path) + write(root, "a.py", "def a():\n pass\n") + git(root, "add", "a.py") + + import socket + monkeypatch.setattr(socket, "socket", lambda *a, **k: (_ for _ in ()).throw(AssertionError("network"))) + artifact = analyze_local_diff(str(root), staged=True) + assert_valid(artifact) + assert artifact["metadata"]["privacy_tier"] == "local" + assert artifact["metadata"]["llm_calls"] == 0 + + +def test_cli_structural_json_is_additive_and_stdout_is_valid_json(tmp_path, monkeypatch): + from click.testing import CliRunner + from diffgraph.cli import main + + root = repo(tmp_path) + write(root, "cli.py", "def old():\n return 1\n") + commit(root) + write(root, "cli.py", "def old():\n return 2\n") + monkeypatch.chdir(root) + + result = CliRunner().invoke(main, ["--structural-json", "-", "diff"]) + assert result.exit_code == 0, result.output + artifact = json.loads(result.output) + assert_valid(artifact) + assert artifact["symbols"][0]["change_kind"] == "modified" + + +def test_cli_structural_json_rejects_unimplemented_commit_ranges(tmp_path, monkeypatch): + from click.testing import CliRunner + from diffgraph.cli import main + + root = repo(tmp_path) + write(root, "cli.py", "def value():\n return 1\n") + commit(root) + monkeypatch.chdir(root) + result = CliRunner().invoke(main, ["--structural-json", "-", "diff", "HEAD~1..HEAD"]) + assert result.exit_code == 2 + assert "currently supports only unstaged" in result.output + + +def test_cli_structural_json_requires_separator_before_pathspecs(tmp_path, monkeypatch): + from click.testing import CliRunner + from diffgraph.cli import main + + root = repo(tmp_path) + write(root, "app.py", "def value():\n return 1\n") + commit(root) + write(root, "app.py", "def value():\n return 2\n") + monkeypatch.chdir(root) + + result = CliRunner().invoke( + main, ["--structural-json", "-", "diff", "app.py"] + ) + + assert result.exit_code == 2 + assert "put pathspecs after '--'" in result.output + + +def test_methods_nested_functions_and_deleted_imports_are_not_overclaimed(tmp_path): + root = repo(tmp_path) + write( + root, + "nested.py", + "import removed_pkg\n\nclass Container:\n def method(self):\n return 1\n\ndef outer():\n def inner():\n return 1\n return inner()\n", + ) + commit(root) + write( + root, + "nested.py", + "class Container:\n def method(self):\n return 2\n\ndef outer():\n def inner():\n return 2\n return inner()\n", + ) + + artifact = analyze_local_diff(str(root)) + assert_valid(artifact) + symbols = {item["qualified_name"]: item for item in artifact["symbols"]} + assert symbols["Container.method"]["kind"] == "method" + assert symbols["outer.inner"]["kind"] == "function" + assert symbols["import::removed_pkg"]["change_kind"] == "deleted" + assert symbols["import::removed_pkg"]["location"] is None + assert not any( + item["kind"] == "imports" + and item["target_id"] == symbols["import::removed_pkg"]["id"] + for item in artifact["relationships"] + ) + + +def test_duplicate_symbol_occurrences_are_preserved(tmp_path): + root = repo(tmp_path) + write( + root, + "properties.py", + "class Item:\n" + " @property\n" + " def value(self):\n" + " return 1\n\n" + " @value.setter\n" + " def value(self, new):\n" + " self._value = new\n", + ) + commit(root) + write( + root, + "properties.py", + "class Item:\n" + " @property\n" + " def value(self):\n" + " return 1\n\n" + " @value.setter\n" + " def value(self, new):\n" + " self._value = new + 1\n", + ) + + artifact = analyze_local_diff(str(root)) + assert_valid(artifact) + values = { + item["qualified_name"]: item + for item in artifact["symbols"] + if item["name"] == "value" + } + assert set(values) == {"Item.value", "Item.value#1"} + assert values["Item.value"]["change_kind"] == "unchanged" + assert values["Item.value#1"]["change_kind"] == "modified" + + +def test_aliased_import_uses_name_field_and_reports_alias_edit_as_modified(tmp_path): + root = repo(tmp_path) + write(root, "imports.py", "import package \\\n as old_alias\n") + commit(root) + write(root, "imports.py", "import package \\\n as new_alias\n") + + artifact = analyze_local_diff(str(root)) + assert_valid(artifact) + imported = next(item for item in artifact["symbols"] if item["kind"] == "import") + assert imported["name"] == "package" + assert imported["qualified_name"] == "import::package" + assert imported["change_kind"] == "modified" + + +def test_worktree_symlink_uses_exact_link_bytes_without_partial_warning(tmp_path): + root = repo(tmp_path) + os.symlink("original.py", root / "link.py") + commit(root) + os.unlink(root / "link.py") + os.symlink("replacement.py", root / "link.py") + + artifact = analyze_local_diff(str(root)) + assert_valid(artifact) + assert artifact["metadata"]["files_analyzed"] == 1 + assert not any( + warning["code"] == "PARTIAL_ANALYSIS" + for warning in artifact["metadata"]["warnings"] + ) + provenance = json.loads(artifact["files"][0]["evidence"][0]["detail"]) + assert provenance["new_oid"] == git( + root, "hash-object", "--stdin", input_bytes=b"replacement.py" + ) + + +def test_snapshot_read_failure_is_partial_analysis(monkeypatch, tmp_path): + root = repo(tmp_path) + write(root, "app.py", "def value():\n return 1\n") + commit(root) + write(root, "app.py", "def value():\n return 2\n") + + def fail_read(*args, **kwargs): + raise GitSnapshotError("simulated read race") + + monkeypatch.setattr("diffgraph.structural.read_worktree_blob", fail_read) + artifact = analyze_local_diff(str(root)) + assert_valid(artifact) + assert artifact["metadata"]["files_analyzed"] == 0 + assert artifact["metadata"]["files_skipped"] == 1 + warning = artifact["metadata"]["warnings"][0] + assert warning["code"] == "PARTIAL_ANALYSIS" + assert "simulated read race" in warning["detail"] + + +def test_resolution_warning_preserves_machine_readable_code(monkeypatch, tmp_path): + root = repo(tmp_path) + warning = ResolutionWarning("hash_object_failed", "simulated failure", "app.py") + monkeypatch.setattr( + "diffgraph.structural.resolve_unstaged", + lambda repository, pathspecs: SnapshotResolution((), (warning,)), + ) + + artifact = analyze_local_diff(str(root)) + assert_valid(artifact) + assert artifact["metadata"]["warnings"] == [ + { + "code": "hash_object_failed", + "file": "app.py", + "detail": "hash_object_failed: simulated failure", + } + ] + + +def test_cli_structural_json_rejects_non_diff_command(): + from click.testing import CliRunner + from diffgraph.cli import main + + result = CliRunner().invoke(main, ["--structural-json", "out.json", "status"]) + assert result.exit_code == 2 + assert "can only be used with 'diff'" in result.output + + +def test_cli_pathspec_is_relative_to_calling_subdirectory(tmp_path, monkeypatch): + from click.testing import CliRunner + from diffgraph.cli import main + + root = repo(tmp_path) + write(root, "src/app.py", "def value():\n return 1\n") + write(root, "app.py", "def root_value():\n return 1\n") + commit(root) + write(root, "src/app.py", "def value():\n return 2\n") + write(root, "app.py", "def root_value():\n return 2\n") + monkeypatch.chdir(root / "src") + + result = CliRunner().invoke( + main, ["--structural-json", "-", "diff", "--", "app.py"] + ) + assert result.exit_code == 0, result.output + artifact = json.loads(result.output) + assert [item["path"] for item in artifact["files"]] == ["src/app.py"] + + +def test_cli_missing_structural_output_parent_is_a_click_error(tmp_path, monkeypatch): + from click.testing import CliRunner + from diffgraph.cli import main + + root = repo(tmp_path) + write(root, "app.py", "def value():\n return 1\n") + git(root, "add", "app.py") + monkeypatch.chdir(root) + output = root / "nested" / "artifact.json" + + result = CliRunner().invoke( + main, ["--structural-json", str(output), "diff", "--staged"] + ) + assert result.exit_code == 1 + assert "could not write" in result.output + assert "Traceback" not in result.output + assert not output.exists() + + +def test_schema_errors_become_click_errors(monkeypatch): + import jsonschema as jsonschema_module + from click import ClickException + from diffgraph.cli import _validate_structural_artifact + + def invalid_schema(*args, **kwargs): + raise jsonschema_module.SchemaError("invalid schema") + + monkeypatch.setattr(jsonschema_module, "validate", invalid_schema) + with pytest.raises(ClickException, match="structural artifact validation failed"): + _validate_structural_artifact({}) + + +def test_missing_parser_dependency_is_a_run_level_cli_error(tmp_path, monkeypatch): + from click.testing import CliRunner + from diffgraph.cli import main + from diffgraph.structural import StructuralDependencyError + + root = repo(tmp_path) + write(root, "app.py", "def value():\n return 1\n") + git(root, "add", "app.py") + monkeypatch.chdir(root) + + def unavailable(): + raise StructuralDependencyError("parser dependency is unavailable") + + monkeypatch.setattr("diffgraph.structural._parser", unavailable) + result = CliRunner().invoke( + main, ["--structural-json", "-", "diff", "--staged"] + ) + assert result.exit_code == 1 + assert "parser dependency is unavailable" in result.output + assert "PARSE_FAILURE" not in result.output + + +def test_missing_ai_dependency_is_a_click_error(tmp_path, monkeypatch): + import builtins + from click.testing import CliRunner + from diffgraph.cli import main + + root = repo(tmp_path) + monkeypatch.chdir(root) + real_import = builtins.__import__ + + def without_spinner(name, *args, **kwargs): + if name == "click_spinner": + raise ImportError("simulated missing spinner") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", without_spinner) + result = CliRunner().invoke(main, ["diff"]) + assert result.exit_code == 1 + assert "requires additional dependencies" in result.output + assert "Traceback" not in result.output