From 12971bfe623d5021398cd835a7859b6f3488550f Mon Sep 17 00:00:00 2001 From: "Nia (Avikalp's assistant)" Date: Fri, 10 Jul 2026 22:05:18 +0530 Subject: [PATCH 1/8] feat(phase4): add terminal formatter with ranked review path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TerminalFormatter renders a schema v2 DiffGraph dict as a priority-ranked terminal output. This is the main user-facing change in DiffGraph v2: 'wild diff' stops opening a browser and instead prints a ranked review path to the terminal. Key features: - Three-bucket ranking: REVIEW FIRST (imported by changed files), REVIEW NEXT (isolated changes), CONTEXT (unchanged symbols in touched files) - Score = importer_count * 3 + lines_changed; evidence-based, LLM-free - Symbol truncation cap (default 10) with --all override - ANSI color with NO_COLOR and TTY auto-detection - --compact flag omits CONTEXT section - File-level fallback when symbol extraction unavailable - Footer shows analysis tier, languages, duration, diff context - 14 unit tests; all pass; pure functions, no git/network Wires as 'wild diff --format terminal'. Default swap (browser → terminal) pending B1 answer from Avikalp. Can ship as --format terminal opt-in today. Spec: docs/DiffGraph-CLI/design/TERMINAL-FORMATTER.md --- diffgraph/formatters/__init__.py | 10 + diffgraph/formatters/terminal.py | 430 +++++++++++++++++++++++++++++++ tests/test_terminal_formatter.py | 411 +++++++++++++++++++++++++++++ 3 files changed, 851 insertions(+) create mode 100644 diffgraph/formatters/__init__.py create mode 100644 diffgraph/formatters/terminal.py create mode 100644 tests/test_terminal_formatter.py diff --git a/diffgraph/formatters/__init__.py b/diffgraph/formatters/__init__.py new file mode 100644 index 0000000..d085a8f --- /dev/null +++ b/diffgraph/formatters/__init__.py @@ -0,0 +1,10 @@ +""" +DiffGraph formatters — render a schema v2 DiffGraph dict to various output formats. + +Formatters are pure consumers of the schema v2 dict produced by processors. +They know nothing about git, tree-sitter, or LLMs. +""" + +from .terminal import TerminalFormatter + +__all__ = ["TerminalFormatter"] diff --git a/diffgraph/formatters/terminal.py b/diffgraph/formatters/terminal.py new file mode 100644 index 0000000..c8e5a50 --- /dev/null +++ b/diffgraph/formatters/terminal.py @@ -0,0 +1,430 @@ +""" +Terminal formatter for DiffGraph v2 output. + +Renders a schema v2 DiffGraph dict as a ranked review path in the terminal. +Pure consumer — knows nothing about git, tree-sitter, or LLMs. + +Usage: + formatter = TerminalFormatter(diffgraph_v2_dict) + formatter.render(sys.stdout) + +Flags (set via constructor): + compact Omit the CONTEXT section (unchanged symbols) + max_items Cap on REVIEW FIRST + REVIEW NEXT (None = --all, no limit) + color Override auto-detection (True/False); default: detect from TTY + NO_COLOR +""" + +from __future__ import annotations + +import os +import shutil +import sys +from dataclasses import dataclass, field +from typing import Optional + + +# --------------------------------------------------------------------------- +# Color / terminal helpers +# --------------------------------------------------------------------------- + +def use_color(out=None) -> bool: + """Return True if ANSI color codes should be emitted.""" + if "NO_COLOR" in os.environ: + return False + stream = out if out is not None else sys.stdout + if not hasattr(stream, "isatty"): + return False + return stream.isatty() + + +def _is_dumb_terminal() -> bool: + return os.environ.get("TERM", "") == "dumb" + + +# ANSI escape helpers +def _ansi(code: str, text: str, color: bool) -> str: + if not color: + return text + return f"\033[{code}m{text}\033[0m" + + +def bold(text: str, color: bool) -> str: + return _ansi("1", text, color) + + +def dim(text: str, color: bool) -> str: + return _ansi("2", text, color) + + +def yellow(text: str, color: bool) -> str: + return _ansi("33", text, color) + + +def green(text: str, color: bool) -> str: + return _ansi("32", text, color) + + +def red(text: str, color: bool) -> str: + return _ansi("31", text, color) + + +def bold_yellow(text: str, color: bool) -> str: + return _ansi("1;33", text, color) + + +def dim_red(text: str, color: bool) -> str: + return _ansi("2;31", text, color) + + +def _change_label(change_kind: str, color: bool) -> str: + """Return colored [label] for a change_kind value.""" + labels = { + "modified": ("[modified]", yellow), + "added": ("[added]", green), + "deleted": ("[deleted]", red), + "unchanged": ("[unchanged]", dim), + } + label, colorize = labels.get(change_kind, ("[unknown]", lambda t, c: t)) + return colorize(label, color) + + +# --------------------------------------------------------------------------- +# Ranking data structures +# --------------------------------------------------------------------------- + +@dataclass +class _RankedSymbol: + """Enriched symbol entry ready for rendering.""" + symbol: dict + importer_paths: list[str] = field(default_factory=list) + lines_changed: int = 0 + score: int = 0 + + +@dataclass +class RankedSymbols: + """Three-bucket output from _rank_symbols().""" + review_first: list[_RankedSymbol] = field(default_factory=list) + review_next: list[_RankedSymbol] = field(default_factory=list) + context: list[_RankedSymbol] = field(default_factory=list) + + +# --------------------------------------------------------------------------- +# TerminalFormatter +# --------------------------------------------------------------------------- + +class TerminalFormatter: + """ + Render a schema v2 DiffGraph dict as a ranked terminal review path. + + Args: + diffgraph: Schema v2 dict (from TreeSitterProcessor.analyze_changes() or any v2 source) + compact: Omit the CONTEXT section when True + max_items: Cap per section (REVIEW FIRST, REVIEW NEXT); None = no cap + color: Force color on/off; None = auto-detect from TTY + NO_COLOR + """ + + DEFAULT_MAX_ITEMS = 10 + + def __init__( + self, + diffgraph: dict, + *, + compact: bool = False, + max_items: Optional[int] = DEFAULT_MAX_ITEMS, + color: Optional[bool] = None, + ): + self.dg = diffgraph + self.compact = compact + self.max_items = max_items + self._color_override = color + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def render(self, out=None) -> None: + """Write the full ranked review path to `out` (default: sys.stdout).""" + if out is None: + out = sys.stdout + color = self._color_override if self._color_override is not None else use_color(out) + + ranked = self._rank_symbols() + self._write_header(out, color) + self._write_warnings(out, color) + 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: + self._write_section("CONTEXT", ranked.context, out, color, section_style="dim") + self._write_footer(out, color) + + # ------------------------------------------------------------------ + # Ranking (pure function — no I/O) + # ------------------------------------------------------------------ + + def _rank_symbols(self) -> RankedSymbols: + """ + Pure function: DiffGraph v2 dict → RankedSymbols. + No I/O, no side effects. Directly testable. + """ + symbols = self.dg.get("symbols", []) + relationships = self.dg.get("relationships", []) + files = self.dg.get("files", []) + + # Build lookup: file_id → file path + file_id_to_path: dict[str, str] = { + f["id"]: f.get("path", f["id"]) for f in files + } + + # Changed file ids (for filtering importers to only those in the diff) + changed_file_ids = { + f["id"] for f in files if f.get("change_kind", "unchanged") != "unchanged" + } + + # Build import relationship index: target_file_id → set of source_file_ids + # (only structural/derived relationships, source must be in the diff) + import_index: dict[str, set[str]] = {} + for rel in relationships: + if ( + rel.get("kind") == "imports" + and rel.get("analysis_source") in ("structural", "derived") + and rel.get("source_id") in changed_file_ids + ): + target = rel.get("target_id", "") + if target: + import_index.setdefault(target, set()).add(rel["source_id"]) + + ranked = RankedSymbols() + + for sym in symbols: + change_kind = sym.get("change_kind", "unchanged") + + if change_kind == "unchanged": + ranked.context.append(_RankedSymbol(symbol=sym)) + continue + + # Compute importer paths (files in the diff that import this symbol's file) + sym_file_id = sym.get("file_id", "") + importer_file_ids = import_index.get(sym_file_id, set()) + importer_paths = [ + file_id_to_path.get(fid, fid) + for fid in sorted(importer_file_ids) + ] + + # Lines changed (only meaningful for "modified") + lines_changed = 0 + if change_kind == "modified" and sym.get("location"): + loc = sym["location"] + lines_changed = max(0, loc.get("line_end", 0) - loc.get("line_start", 0) + 1) + + score = len(importer_paths) * 3 + lines_changed + + rs = _RankedSymbol( + symbol=sym, + importer_paths=importer_paths, + lines_changed=lines_changed, + score=score, + ) + + if importer_paths: + ranked.review_first.append(rs) + else: + ranked.review_next.append(rs) + + # Sort each bucket + ranked.review_first.sort(key=lambda r: r.score, reverse=True) + ranked.review_next.sort( + key=lambda r: (-r.lines_changed, r.symbol.get("name", "")) + ) + ranked.context.sort( + key=lambda r: ( + file_id_to_path.get(r.symbol.get("file_id", ""), ""), + r.symbol.get("location", {}).get("line_start", 0), + ) + ) + + return ranked + + # ------------------------------------------------------------------ + # Rendering helpers + # ------------------------------------------------------------------ + + def _write_header(self, out, color: bool) -> None: + files = self.dg.get("files", []) + symbols = self.dg.get("symbols", []) + + changed_files = sum(1 for f in files if f.get("change_kind", "unchanged") != "unchanged") + modified = sum(1 for s in symbols if s.get("change_kind") == "modified") + added = sum(1 for s in symbols if s.get("change_kind") == "added") + deleted = sum(1 for s in symbols if s.get("change_kind") == "deleted") + + # Detect fallback (no symbols extracted) + if not symbols and files: + parts = [f"{changed_files} file{'s' if changed_files != 1 else ''} changed"] + parts.append("symbol extraction unavailable") + line = bold("wild diff", color) + " — " + " · ".join(parts) + else: + sym_parts = [] + if modified: + sym_parts.append(f"{modified} symbol{'s' if modified != 1 else ''} modified") + if added: + sym_parts.append(f"{added} added") + if deleted: + sym_parts.append(f"{deleted} deleted") + + counts = f"{changed_files} file{'s' if changed_files != 1 else ''} changed" + if sym_parts: + counts += " · " + " · ".join(sym_parts) + + line = bold("wild diff", color) + " — " + counts + + out.write(line + "\n\n") + + def _write_warnings(self, out, color: bool) -> None: + """Write any per-file warnings (e.g. unsupported language fallbacks).""" + metadata = self.dg.get("metadata", {}) + warnings = metadata.get("warnings", []) + for w in warnings: + out.write(dim_red("⚠", color) + f" {w}\n") + if warnings: + out.write("\n") + + # File-level fallback section when no symbols + files = self.dg.get("files", []) + symbols = self.dg.get("symbols", []) + if not symbols and files: + changed_files = [f for f in files if f.get("change_kind", "unchanged") != "unchanged"] + if changed_files: + 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) + out.write(f" {path} +{additions} / -{deletions}\n") + out.write("\n") + + def _write_section( + self, + title: str, + items: list[_RankedSymbol], + out, + color: bool, + section_style: str = "bold", + ) -> None: + if not items: + return # Never show empty sections + + # Apply cap + total = len(items) + capped = items if self.max_items is None else items[: self.max_items] + hidden = total - len(capped) + + # Section header + prefix = "▶ " if not _is_dumb_terminal() else "> " + header_text = f"{prefix}{title}" + if title == "REVIEW FIRST": + header = bold_yellow(header_text, color) + elif title == "CONTEXT": + header = dim(header_text, color) + else: + header = bold(header_text, color) + + if hidden > 0: + hint = f" (showing top {len(capped)} of {total} · run: wild diff --all to see all)" + out.write(header + dim(hint, color) + "\n") + else: + out.write(header + "\n") + + for rs in capped: + self._write_symbol_entry(rs, out, color, title) + + out.write("\n") + + def _write_symbol_entry( + self, rs: _RankedSymbol, out, color: bool, section_title: str + ) -> None: + sym = rs.symbol + name = sym.get("name", "") + change_kind = sym.get("change_kind", "unknown") + file_id = sym.get("file_id", "") + + # Resolve file path from files[] + files = self.dg.get("files", []) + file_path = next( + (f.get("path", f.get("id", file_id)) for f in files if f["id"] == file_id), + file_id, + ) + + change_label = _change_label(change_kind, color) + + # Main line: " file/path.py SymbolName [modified] · 29 lines" + line = f" {file_path} {bold(name, color)} {change_label}" + if rs.lines_changed: + line += f" · {rs.lines_changed} lines" + elif change_kind in ("added", "deleted") and rs.symbol.get("location"): + loc = rs.symbol["location"] + loc_lines = max(0, loc.get("line_end", 0) - loc.get("line_start", 0) + 1) + if loc_lines: + line += f" · {loc_lines} lines" + out.write(line + "\n") + + # Importer line (REVIEW FIRST only) + if rs.importer_paths and section_title == "REVIEW FIRST": + importers = rs.importer_paths + max_inline = 3 + shown = importers[:max_inline] + extra = len(importers) - len(shown) + importer_str = ", ".join(shown) + if extra: + importer_str += f" (+{extra} more)" + arrow = "↳" if not _is_dumb_terminal() else "+->" + out.write( + " " + dim(f"{arrow} imported by: {importer_str}", color) + "\n" + ) + + def _write_footer(self, out, color: bool) -> None: + metadata = self.dg.get("metadata", {}) + analysis_source = metadata.get("analysis_source", "structural") + duration_ms = metadata.get("analysis_duration_ms") + diff_ref = self.dg.get("diff_ref", {}) + diff_kind = diff_ref.get("kind", "unstaged") + + # Detect languages from files[] + files = self.dg.get("files", []) + langs = sorted({f.get("language") for f in files if f.get("language")}) + lang_str = " · ".join(langs) if langs else "" + + # Diff context + diff_context_map = { + "unstaged": "unstaged changes", + "staged": "staged changes", + } + if diff_kind in diff_context_map: + diff_context = diff_context_map[diff_kind] + elif diff_kind == "commit_range": + base = diff_ref.get("base_ref", "") + head = diff_ref.get("head_ref", "") + diff_context = f"{base}..{head}" if base and head else "commit range" + elif diff_kind == "file_scope": + pathspec = diff_ref.get("pathspec", "") + diff_context = pathspec or "file scope" + else: + diff_context = diff_kind + + parts = [analysis_source] + if lang_str: + parts.append(lang_str) + if duration_ms is not None: + parts.append(f"{duration_ms}ms") + parts.append(diff_context) + + sep = "─" * 64 if not _is_dumb_terminal() else "-" * 64 + out.write(dim(sep, color) + "\n") + out.write(dim("Analysis: " + " · ".join(parts), color) + "\n") + + # Optional LLM upgrade hint (shown only for local-structural analysis) + privacy_tier = metadata.get("privacy_tier", "local") + if privacy_tier == "local": + hint = "Optional: wild diff --llm openai (adds LLM summary · diff sent to OpenAI)" + out.write(dim(hint, color) + "\n") diff --git a/tests/test_terminal_formatter.py b/tests/test_terminal_formatter.py new file mode 100644 index 0000000..6452a94 --- /dev/null +++ b/tests/test_terminal_formatter.py @@ -0,0 +1,411 @@ +""" +Unit tests for TerminalFormatter. + +All tests operate on fixture DiffGraph v2 dicts — no git subprocess, no network, +no tree-sitter. The formatter is a pure consumer of the JSON schema. + +Test cases from TERMINAL-FORMATTER.md: + 1. test_rank_symbols_empty_diff — no symbols → all buckets empty + 2. test_rank_symbols_no_relationships — all changed → REVIEW NEXT + 3. test_rank_symbols_with_importers — symbol w/ importers → REVIEW FIRST + 4. test_rank_symbols_unchanged — unchanged symbols → CONTEXT + 5. test_rank_symbols_deleted_file — file deletion → REVIEW FIRST if imported + 6. test_rank_symbols_truncation — >10 symbols → truncated w/ hint + 7. test_terminal_formatter_no_color — NO_COLOR=1 → no ANSI codes + 8. test_terminal_formatter_piped — stdout not TTY → no ANSI codes (via color=False) + 9. test_terminal_formatter_compact — --compact → CONTEXT section absent + 10. test_rank_symbols_score_ordering — higher score → earlier in REVIEW FIRST + 11. test_render_no_symbols_file_fallback — no symbols but files → FILES CHANGED fallback + 12. test_render_footer_shows_duration — analysis_duration_ms present → shows in footer +""" + +import io +import os +import pytest + +from diffgraph.formatters.terminal import TerminalFormatter, RankedSymbols + + +# --------------------------------------------------------------------------- +# Shared fixtures +# --------------------------------------------------------------------------- + +def _make_file(file_id: str, path: str, change_kind: str = "modified", language: str = "Python") -> dict: + return { + "id": file_id, + "path": path, + "change_kind": change_kind, + "language": language, + "stats": {"additions": 10, "deletions": 3}, + } + + +def _make_symbol( + sym_id: str, + name: str, + file_id: str, + change_kind: str = "modified", + line_start: int = 1, + line_end: int = 10, +) -> dict: + return { + "id": sym_id, + "name": name, + "file_id": file_id, + "kind": "function", + "change_kind": change_kind, + "location": {"line_start": line_start, "line_end": line_end}, + } + + +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, + "kind": "imports", + "analysis_source": "structural", + } + + +def _make_diffgraph( + files: list = None, + symbols: list = None, + relationships: list = None, + metadata: dict = None, + diff_ref: dict = None, +) -> dict: + return { + "schema_version": "2.0", + "generated_at": "2026-07-10T16:30:00Z", + "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, + }, + } + + +# --------------------------------------------------------------------------- +# 1. Empty diff — no symbols +# --------------------------------------------------------------------------- + +def test_rank_symbols_empty_diff(): + dg = _make_diffgraph() + fmt = TerminalFormatter(dg) + ranked = fmt._rank_symbols() + assert ranked.review_first == [] + assert ranked.review_next == [] + assert ranked.context == [] + + +# --------------------------------------------------------------------------- +# 2. No relationships — all changed symbols → REVIEW NEXT +# --------------------------------------------------------------------------- + +def test_rank_symbols_no_relationships(): + files = [_make_file("f1", "auth/validator.py", change_kind="modified")] + symbols = [ + _make_symbol("s1", "validate_token", "f1", "modified", 1, 29), + _make_symbol("s2", "TokenCache", "f1", "added", 31, 50), + ] + dg = _make_diffgraph(files=files, symbols=symbols) + fmt = TerminalFormatter(dg) + ranked = fmt._rank_symbols() + assert ranked.review_first == [] + assert len(ranked.review_next) == 2 + assert ranked.context == [] + # Sorted by lines desc: validate_token (29 lines) before TokenCache (20 lines) + assert ranked.review_next[0].symbol["name"] == "validate_token" + + +# --------------------------------------------------------------------------- +# 3. Symbol with importers → REVIEW FIRST +# --------------------------------------------------------------------------- + +def test_rank_symbols_with_importers(): + files = [ + _make_file("f_validator", "auth/validator.py", change_kind="modified"), + _make_file("f_routes", "api/routes.py", change_kind="modified"), + ] + symbols = [ + _make_symbol("s_validate", "validate_token", "f_validator", "modified", 1, 29), + _make_symbol("s_route", "list_users", "f_routes", "modified", 5, 15), + ] + relationships = [ + _make_import_rel("r1", "f_routes", "f_validator"), # routes imports validator + ] + dg = _make_diffgraph(files=files, symbols=symbols, relationships=relationships) + fmt = TerminalFormatter(dg) + ranked = fmt._rank_symbols() + + # validate_token is imported by routes → REVIEW FIRST + assert len(ranked.review_first) == 1 + assert ranked.review_first[0].symbol["name"] == "validate_token" + assert "api/routes.py" in ranked.review_first[0].importer_paths + + # list_users has no importers → REVIEW NEXT + assert len(ranked.review_next) == 1 + assert ranked.review_next[0].symbol["name"] == "list_users" + + +# --------------------------------------------------------------------------- +# 4. Unchanged symbols → CONTEXT +# --------------------------------------------------------------------------- + +def test_rank_symbols_unchanged(): + files = [_make_file("f1", "auth/validator.py", change_kind="modified")] + symbols = [ + _make_symbol("s1", "validate_token", "f1", "modified", 1, 5), + _make_symbol("s2", "TokenCache", "f1", "unchanged", 10, 30), # unchanged + ] + dg = _make_diffgraph(files=files, symbols=symbols) + fmt = TerminalFormatter(dg) + ranked = fmt._rank_symbols() + assert len(ranked.context) == 1 + assert ranked.context[0].symbol["name"] == "TokenCache" + assert len(ranked.review_next) == 1 + assert ranked.review_next[0].symbol["name"] == "validate_token" + + +# --------------------------------------------------------------------------- +# 5. Deleted file — if imported by modified file → REVIEW FIRST +# --------------------------------------------------------------------------- + +def test_rank_symbols_deleted_file(): + files = [ + _make_file("f_legacy", "auth/legacy_auth.py", change_kind="deleted"), + _make_file("f_routes", "api/routes.py", change_kind="modified"), + ] + symbols = [ + _make_symbol("s_old", "legacy_verify", "f_legacy", "deleted", 1, 20), + _make_symbol("s_route", "get_user", "f_routes", "modified", 5, 10), + ] + relationships = [ + _make_import_rel("r1", "f_routes", "f_legacy"), # routes imports legacy_auth + ] + dg = _make_diffgraph(files=files, symbols=symbols, relationships=relationships) + fmt = TerminalFormatter(dg) + ranked = fmt._rank_symbols() + + # legacy_verify is imported by modified routes → REVIEW FIRST + assert len(ranked.review_first) == 1 + assert ranked.review_first[0].symbol["name"] == "legacy_verify" + assert "api/routes.py" in ranked.review_first[0].importer_paths + + # get_user has no importers in the diff → REVIEW NEXT + assert len(ranked.review_next) == 1 + assert ranked.review_next[0].symbol["name"] == "get_user" + + +# --------------------------------------------------------------------------- +# 6. Truncation — >10 symbols → show top 10, hint in header +# --------------------------------------------------------------------------- + +def test_rank_symbols_truncation(): + # 15 changed symbols, no relationships → all go to REVIEW NEXT + files = [_make_file("f1", "big_module.py", change_kind="modified")] + symbols = [ + _make_symbol(f"s{i}", f"func_{i}", "f1", "added", i * 3, i * 3 + 2) + for i in range(15) + ] + dg = _make_diffgraph(files=files, symbols=symbols) + fmt = TerminalFormatter(dg, max_items=10) + ranked = fmt._rank_symbols() + # All 15 are ranked (ranking is pure — truncation happens at render time) + assert len(ranked.review_next) == 15 + + # Render and check for truncation hint + out = io.StringIO() + fmt.render(out) + output = out.getvalue() + assert "showing top 10 of 15" in output + assert "wild diff --all" in output + + +# --------------------------------------------------------------------------- +# 7. NO_COLOR=1 → no ANSI codes in output +# --------------------------------------------------------------------------- + +def test_terminal_formatter_no_color(monkeypatch): + monkeypatch.setenv("NO_COLOR", "1") + files = [_make_file("f1", "auth/validator.py", change_kind="modified")] + symbols = [_make_symbol("s1", "validate_token", "f1", "modified", 1, 10)] + dg = _make_diffgraph(files=files, symbols=symbols) + fmt = TerminalFormatter(dg) + out = io.StringIO() + fmt.render(out) + output = out.getvalue() + assert "\033[" not in output + + +# --------------------------------------------------------------------------- +# 8. color=False (piped stdout) → no ANSI codes +# --------------------------------------------------------------------------- + +def test_terminal_formatter_piped(): + files = [_make_file("f1", "auth/validator.py", change_kind="modified")] + symbols = [_make_symbol("s1", "validate_token", "f1", "modified", 1, 10)] + dg = _make_diffgraph(files=files, symbols=symbols) + fmt = TerminalFormatter(dg, color=False) + out = io.StringIO() + fmt.render(out) + output = out.getvalue() + assert "\033[" not in output + + +# --------------------------------------------------------------------------- +# 9. --compact → CONTEXT section absent +# --------------------------------------------------------------------------- + +def test_terminal_formatter_compact(): + files = [_make_file("f1", "auth/validator.py", change_kind="modified")] + symbols = [ + _make_symbol("s1", "validate_token", "f1", "modified", 1, 10), + _make_symbol("s2", "TokenCache", "f1", "unchanged", 20, 40), + ] + dg = _make_diffgraph(files=files, symbols=symbols) + + # Without compact — CONTEXT section should be present + fmt = TerminalFormatter(dg, color=False) + out = io.StringIO() + fmt.render(out) + assert "CONTEXT" in out.getvalue() + assert "TokenCache" in out.getvalue() + + # With compact — CONTEXT section should be absent + fmt_compact = TerminalFormatter(dg, compact=True, color=False) + out_compact = io.StringIO() + fmt_compact.render(out_compact) + assert "CONTEXT" not in out_compact.getvalue() + assert "TokenCache" not in out_compact.getvalue() + + +# --------------------------------------------------------------------------- +# 10. Score ordering — higher score symbol appears first in REVIEW FIRST +# --------------------------------------------------------------------------- + +def test_rank_symbols_score_ordering(): + """ + Symbol A: 2 importers, 5 lines → score = 2*3 + 5 = 11 + Symbol B: 1 importer, 20 lines → score = 1*3 + 20 = 23 + B should appear first in REVIEW FIRST despite A having more importers. + """ + files = [ + _make_file("f_a", "module_a.py", change_kind="modified"), + _make_file("f_b", "module_b.py", change_kind="modified"), + _make_file("f_importer1", "consumer1.py", change_kind="modified"), + _make_file("f_importer2", "consumer2.py", change_kind="modified"), + ] + symbols = [ + _make_symbol("s_a", "small_func", "f_a", "modified", 1, 5), # 5 lines + _make_symbol("s_b", "big_func", "f_b", "modified", 1, 20), # 20 lines + ] + relationships = [ + _make_import_rel("r1", "f_importer1", "f_a"), # importer1 → A + _make_import_rel("r2", "f_importer2", "f_a"), # importer2 → A (A has 2 importers) + _make_import_rel("r3", "f_importer1", "f_b"), # importer1 → B (B has 1 importer) + ] + dg = _make_diffgraph(files=files, symbols=symbols, relationships=relationships) + fmt = TerminalFormatter(dg) + ranked = fmt._rank_symbols() + + assert len(ranked.review_first) == 2 + # small_func score: 2*3 + 5 = 11; big_func score: 1*3 + 20 = 23 + # big_func should come first + assert ranked.review_first[0].symbol["name"] == "big_func" + assert ranked.review_first[1].symbol["name"] == "small_func" + + +# --------------------------------------------------------------------------- +# 11. No symbols, files present → FILES CHANGED fallback section +# --------------------------------------------------------------------------- + +def test_render_no_symbols_file_fallback(): + files = [ + _make_file("f1", "legacy/mystery.py", change_kind="modified"), + _make_file("f2", "legacy/helper.py", change_kind="modified"), + ] + dg = _make_diffgraph(files=files, symbols=[]) + fmt = TerminalFormatter(dg, color=False) + out = io.StringIO() + fmt.render(out) + output = out.getvalue() + assert "FILES CHANGED" in output + assert "legacy/mystery.py" in output + assert "legacy/helper.py" in output + assert "symbol extraction unavailable" in output + + +# --------------------------------------------------------------------------- +# 12. analysis_duration_ms present → shows in footer +# --------------------------------------------------------------------------- + +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, + } + dg = _make_diffgraph(files=files, symbols=symbols, metadata=metadata) + fmt = TerminalFormatter(dg, color=False) + out = io.StringIO() + fmt.render(out) + assert "1234ms" in out.getvalue() + + +# --------------------------------------------------------------------------- +# 13. Importer display — max 3 inline, collapse rest with (+N more) +# --------------------------------------------------------------------------- + +def test_importer_display_collapse(): + """5 importers → show 3 inline + (+2 more).""" + importer_files = [ + _make_file(f"f_consumer_{i}", f"consumers/c{i}.py", change_kind="modified") + for i in range(5) + ] + target_file = _make_file("f_target", "core/engine.py", change_kind="modified") + files = importer_files + [target_file] + symbols = [_make_symbol("s1", "process", "f_target", "modified", 1, 10)] + symbols += [ + _make_symbol(f"s_c{i}", f"use_engine_{i}", f"f_consumer_{i}", "modified", 1, 5) + for i in range(5) + ] + relationships = [ + _make_import_rel(f"r{i}", f"f_consumer_{i}", "f_target") + for i in range(5) + ] + dg = _make_diffgraph(files=files, symbols=symbols, relationships=relationships) + fmt = TerminalFormatter(dg, color=False) + out = io.StringIO() + fmt.render(out) + output = out.getvalue() + # Should show "(+2 more)" since we show max 3 importers inline + assert "(+2 more)" in output + + +# --------------------------------------------------------------------------- +# 14. --all flag disables truncation +# --------------------------------------------------------------------------- + +def test_rank_symbols_all_flag(): + files = [_make_file("f1", "big_module.py", change_kind="modified")] + symbols = [ + _make_symbol(f"s{i}", f"func_{i}", "f1", "added", i * 3, i * 3 + 2) + for i in range(15) + ] + dg = _make_diffgraph(files=files, symbols=symbols) + fmt = TerminalFormatter(dg, max_items=None, color=False) # max_items=None = --all + out = io.StringIO() + fmt.render(out) + output = out.getvalue() + # All 15 functions should be listed; no truncation hint + for i in range(15): + assert f"func_{i}" in output + assert "showing top" not in output From 40ebc9550a00620044d37bff4bf7410d98063672 Mon Sep 17 00:00:00 2001 From: nia-sg-bot Date: Wed, 5 Aug 2026 15:33:24 +0530 Subject: [PATCH 2/8] fix(formatter): reject unsupported schema majors --- diffgraph/formatters/terminal.py | 19 +++++++++++++++++++ tests/test_terminal_formatter.py | 24 ++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/diffgraph/formatters/terminal.py b/diffgraph/formatters/terminal.py index c8e5a50..e46c9f6 100644 --- a/diffgraph/formatters/terminal.py +++ b/diffgraph/formatters/terminal.py @@ -17,6 +17,7 @@ from __future__ import annotations import os +import re import shutil import sys from dataclasses import dataclass, field @@ -125,6 +126,7 @@ class TerminalFormatter: """ DEFAULT_MAX_ITEMS = 10 + SUPPORTED_SCHEMA_MAJOR = 2 def __init__( self, @@ -134,11 +136,28 @@ def __init__( max_items: Optional[int] = DEFAULT_MAX_ITEMS, color: Optional[bool] = None, ): + self._validate_schema_version(diffgraph.get("schema_version")) 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 # ------------------------------------------------------------------ diff --git a/tests/test_terminal_formatter.py b/tests/test_terminal_formatter.py index 6452a94..8b68e62 100644 --- a/tests/test_terminal_formatter.py +++ b/tests/test_terminal_formatter.py @@ -90,6 +90,30 @@ def _make_diffgraph( } +@pytest.mark.parametrize("schema_version", [None, "2", "v2", "2.0.0", 2]) +def test_terminal_formatter_rejects_malformed_schema_version(schema_version): + dg = _make_diffgraph() + dg["schema_version"] = schema_version + + with pytest.raises(ValueError, match="MAJOR.MINOR"): + TerminalFormatter(dg) + + +def test_terminal_formatter_rejects_unsupported_schema_major(): + dg = _make_diffgraph() + dg["schema_version"] = "3.0" + + with pytest.raises(ValueError, match="Unsupported DiffGraph schema major 3"): + TerminalFormatter(dg) + + +def test_terminal_formatter_accepts_additive_schema_minor(): + dg = _make_diffgraph() + dg["schema_version"] = "2.7" + + TerminalFormatter(dg) + + # --------------------------------------------------------------------------- # 1. Empty diff — no symbols # --------------------------------------------------------------------------- From d0cdf8767a2f74e2d7549620a995f0e59d877963 Mon Sep 17 00:00:00 2001 From: nia-sg-bot Date: Thu, 6 Aug 2026 07:32:31 +0530 Subject: [PATCH 3/8] test: match schema format literally --- tests/test_terminal_formatter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_terminal_formatter.py b/tests/test_terminal_formatter.py index 8b68e62..7ad2a82 100644 --- a/tests/test_terminal_formatter.py +++ b/tests/test_terminal_formatter.py @@ -95,7 +95,7 @@ def test_terminal_formatter_rejects_malformed_schema_version(schema_version): dg = _make_diffgraph() dg["schema_version"] = schema_version - with pytest.raises(ValueError, match="MAJOR.MINOR"): + with pytest.raises(ValueError, match=r"MAJOR\.MINOR"): TerminalFormatter(dg) From 93564e5a88fbc992ac975103624a0e0bee589e12 Mon Sep 17 00:00:00 2001 From: nia-sg-bot Date: Thu, 6 Aug 2026 21:33:36 +0530 Subject: [PATCH 4/8] feat(cli): wire local terminal formatter --- diffgraph/cli.py | 54 ++++++++++++++++++++++++++++++---------- tests/test_structural.py | 45 +++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 13 deletions(-) diff --git a/diffgraph/cli.py b/diffgraph/cli.py index c2a586f..08f5927 100644 --- a/diffgraph/cli.py +++ b/diffgraph/cli.py @@ -98,7 +98,7 @@ def parse_args(self, 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"} + value_options = {"--api-key", "--output", "-o", "--structural-json", "--format"} index = 0 while index < len(raw_args): argument = raw_args[index] @@ -209,6 +209,14 @@ def load_file_contents(changed_files: List[Dict[str, str]], diff_args: List[str] @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( + '--format', + 'output_format', + type=click.Choice(['html', 'terminal'], case_sensitive=False), + default='html', + show_default=True, + help='Render the legacy HTML report or a local structural terminal review', +) @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') @click.option( @@ -216,11 +224,23 @@ def load_file_contents(changed_files: List[Dict[str, str]], diff_args: List[str] 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): +def main( + args, + api_key: str, + output: str, + output_format: 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'") + 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") # Check if this is a diff command if args and args[0] == 'diff': @@ -236,7 +256,7 @@ def main(args, api_key: str, output: str, no_open: bool, debug_env: bool, struct click.echo("❌ Error: Not a git repository", err=True) sys.exit(1) - if structural_json is not None: + if structural_json is not None or output_format == "terminal": raw_args = click.get_current_context().meta.get("raw_args", ()) staged, pathspecs = _structural_scope( diff_args, separator_present=_separator_follows_diff(raw_args) @@ -248,17 +268,25 @@ def main(args, api_key: str, output: str, no_open: bool, debug_env: bool, struct 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: + if output_format == "terminal": + from diffgraph.formatters.terminal import TerminalFormatter + 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) + TerminalFormatter(artifact).render() + 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) == "-": + 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 diff --git a/tests/test_structural.py b/tests/test_structural.py index 9654a1e..d9541de 100644 --- a/tests/test_structural.py +++ b/tests/test_structural.py @@ -191,6 +191,51 @@ def test_cli_structural_json_is_additive_and_stdout_is_valid_json(tmp_path, monk assert artifact["symbols"][0]["change_kind"] == "modified" +def test_cli_terminal_format_renders_validated_local_artifact(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) + write(root, "cli.py", "def value():\n return 2\n") + monkeypatch.chdir(root) + + result = CliRunner().invoke(main, ["diff", "--format", "terminal"]) + + assert result.exit_code == 0, result.output + assert "wild diff" in result.output + assert "cli.py" in result.output + assert "value" in result.output + assert "Analysis: structural" in result.output + + +def test_cli_default_format_keeps_legacy_html_path(monkeypatch): + import sys + from types import ModuleType + + from click.testing import CliRunner + import diffgraph.cli as cli + + spinner_module = ModuleType("click_spinner") + spinner_module.spinner = object() + ai_module = ModuleType("diffgraph.ai_analysis") + ai_module.CodeAnalysisAgent = object + html_module = ModuleType("diffgraph.html_report") + html_module.generate_html_report = lambda *args, **kwargs: None + html_module.AnalysisResult = object + monkeypatch.setitem(sys.modules, "click_spinner", spinner_module) + monkeypatch.setitem(sys.modules, "diffgraph.ai_analysis", ai_module) + monkeypatch.setitem(sys.modules, "diffgraph.html_report", html_module) + monkeypatch.setattr(cli, "is_git_repo", lambda: True) + monkeypatch.setattr(cli, "get_changed_files", lambda diff_args: []) + + result = CliRunner().invoke(cli.main, ["diff"]) + + assert result.exit_code == 0, result.output + assert "No changes to analyze" in result.output + + def test_cli_structural_json_rejects_unimplemented_commit_ranges(tmp_path, monkeypatch): from click.testing import CliRunner from diffgraph.cli import main From dbfafea4967dd9841015441cdb74b77f37883765 Mon Sep 17 00:00:00 2001 From: nia-sg-bot Date: Thu, 6 Aug 2026 23:34:31 +0530 Subject: [PATCH 5/8] fix(cli): wire terminal display flags --- diffgraph/cli.py | 12 +++++++++++- tests/test_structural.py | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/diffgraph/cli.py b/diffgraph/cli.py index 08f5927..85eb52c 100644 --- a/diffgraph/cli.py +++ b/diffgraph/cli.py @@ -217,6 +217,8 @@ def load_file_contents(changed_files: List[Dict[str, str]], diff_args: List[str] show_default=True, help='Render the legacy HTML report or a local structural terminal review', ) +@click.option('--compact', is_flag=True, help='Hide terminal CONTEXT output') +@click.option('--all', 'show_all', is_flag=True, help='Show all terminal review items') @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') @click.option( @@ -229,6 +231,8 @@ def main( api_key: str, output: str, output_format: str, + compact: bool, + show_all: bool, no_open: bool, debug_env: bool, structural_json: Path, @@ -272,7 +276,13 @@ def main( from diffgraph.formatters.terminal import TerminalFormatter try: - TerminalFormatter(artifact).render() + TerminalFormatter( + artifact, + compact=compact, + max_items=( + None if show_all else TerminalFormatter.DEFAULT_MAX_ITEMS + ), + ).render() except ValueError as error: raise click.ClickException(str(error)) from error else: diff --git a/tests/test_structural.py b/tests/test_structural.py index d9541de..11cc523 100644 --- a/tests/test_structural.py +++ b/tests/test_structural.py @@ -210,6 +210,42 @@ def test_cli_terminal_format_renders_validated_local_artifact(tmp_path, monkeypa assert "Analysis: structural" in result.output +def test_cli_terminal_compact_hides_context(tmp_path, monkeypatch): + from click.testing import CliRunner + from diffgraph.cli import main + + root = repo(tmp_path) + write(root, "cli.py", "def changed():\n return 1\n\ndef context():\n return 1\n") + commit(root) + write(root, "cli.py", "def changed():\n return 2\n\ndef context():\n return 1\n") + monkeypatch.chdir(root) + + result = CliRunner().invoke(main, ["diff", "--format", "terminal", "--compact"]) + + assert result.exit_code == 0, result.output + assert "REVIEW NEXT" in result.output + assert "CONTEXT" not in result.output + + +def test_cli_terminal_all_disables_review_item_cap(tmp_path, monkeypatch): + from click.testing import CliRunner + from diffgraph.cli import main + + root = repo(tmp_path) + before = "\n\n".join(f"def item_{index}():\n return 1" for index in range(11)) + "\n" + after = "\n\n".join(f"def item_{index}():\n return 2" for index in range(11)) + "\n" + write(root, "cli.py", before) + commit(root) + write(root, "cli.py", after) + monkeypatch.chdir(root) + + result = CliRunner().invoke(main, ["diff", "--format", "terminal", "--all"]) + + assert result.exit_code == 0, result.output + assert "item_10" in result.output + assert "more" not in result.output + + def test_cli_default_format_keeps_legacy_html_path(monkeypatch): import sys from types import ModuleType From f1dfe488249821e4f198adcc34d3c388d250e686 Mon Sep 17 00:00:00 2001 From: nia-sg-bot Date: Fri, 7 Aug 2026 01:35:05 +0530 Subject: [PATCH 6/8] fix(cli): preserve git passthrough display flags --- diffgraph/cli.py | 23 +++++++++++++++++++---- tests/test_structural.py | 23 +++++++++++++++++++++-- 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/diffgraph/cli.py b/diffgraph/cli.py index 85eb52c..f6fe21e 100644 --- a/diffgraph/cli.py +++ b/diffgraph/cli.py @@ -95,6 +95,21 @@ def parse_args(self, ctx, args): return super().parse_args(ctx, args) +def _terminal_options(diff_args): + """Remove terminal-only display flags from a ``diff`` invocation.""" + remaining = [] + compact = False + show_all = False + for arg in diff_args: + if arg == "--compact": + compact = True + elif arg == "--all": + show_all = True + else: + remaining.append(arg) + return remaining, compact, show_all + + def _separator_follows_diff(raw_args) -> bool: """Return whether the raw CLI placed ``--`` after the ``diff`` operand.""" @@ -217,8 +232,6 @@ def load_file_contents(changed_files: List[Dict[str, str]], diff_args: List[str] show_default=True, help='Render the legacy HTML report or a local structural terminal review', ) -@click.option('--compact', is_flag=True, help='Hide terminal CONTEXT output') -@click.option('--all', 'show_all', is_flag=True, help='Show all terminal review items') @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') @click.option( @@ -231,8 +244,6 @@ def main( api_key: str, output: str, output_format: str, - compact: bool, - show_all: bool, no_open: bool, debug_env: bool, structural_json: Path, @@ -261,6 +272,10 @@ def main( sys.exit(1) if structural_json is not None or output_format == "terminal": + compact = False + show_all = False + if output_format == "terminal": + diff_args, compact, show_all = _terminal_options(diff_args) raw_args = click.get_current_context().meta.get("raw_args", ()) staged, pathspecs = _structural_scope( diff_args, separator_present=_separator_follows_diff(raw_args) diff --git a/tests/test_structural.py b/tests/test_structural.py index 11cc523..80b92bd 100644 --- a/tests/test_structural.py +++ b/tests/test_structural.py @@ -232,8 +232,9 @@ def test_cli_terminal_all_disables_review_item_cap(tmp_path, monkeypatch): from diffgraph.cli import main root = repo(tmp_path) - before = "\n\n".join(f"def item_{index}():\n return 1" for index in range(11)) + "\n" - after = "\n\n".join(f"def item_{index}():\n return 2" for index in range(11)) + "\n" + names = [f"item_{index:02d}" for index in range(11)] + before = "\n\n".join(f"def {name}():\n return 1" for name in names) + "\n" + after = "\n\n".join(f"def {name}():\n return 2" for name in names) + "\n" write(root, "cli.py", before) commit(root) write(root, "cli.py", after) @@ -246,6 +247,24 @@ def test_cli_terminal_all_disables_review_item_cap(tmp_path, monkeypatch): assert "more" not in result.output +def test_cli_git_passthrough_preserves_all(monkeypatch): + from click.testing import CliRunner + import diffgraph.cli as cli + + calls = [] + + def run(command, *args, **kwargs): + calls.append(command) + return type("Result", (), {"returncode": 0})() + + monkeypatch.setattr(cli.subprocess, "run", run) + + result = CliRunner().invoke(cli.main, ["branch", "--all"]) + + assert result.exit_code == 0, result.output + assert calls == [["git", "branch", "--all"]] + + def test_cli_default_format_keeps_legacy_html_path(monkeypatch): import sys from types import ModuleType From 85272827ac4ff031f63ebec3fccfb899d15114e8 Mon Sep 17 00:00:00 2001 From: nia-sg-bot Date: Fri, 7 Aug 2026 03:35:55 +0530 Subject: [PATCH 7/8] fix(formatter): avoid false changed-line ranking --- diffgraph/formatters/terminal.py | 17 ++++++------ tests/test_terminal_formatter.py | 44 +++++++++++++++++--------------- 2 files changed, 32 insertions(+), 29 deletions(-) diff --git a/diffgraph/formatters/terminal.py b/diffgraph/formatters/terminal.py index e46c9f6..b2605ca 100644 --- a/diffgraph/formatters/terminal.py +++ b/diffgraph/formatters/terminal.py @@ -230,13 +230,12 @@ def _rank_symbols(self) -> RankedSymbols: for fid in sorted(importer_file_ids) ] - # Lines changed (only meaningful for "modified") + # DiffGraph v2 currently exposes declaration spans, not per-symbol + # changed-line evidence. Do not present or rank a declaration's size + # as though every line changed; importer count is the honest signal + # available to this pure artifact consumer. lines_changed = 0 - if change_kind == "modified" and sym.get("location"): - loc = sym["location"] - lines_changed = max(0, loc.get("line_end", 0) - loc.get("line_start", 0) + 1) - - score = len(importer_paths) * 3 + lines_changed + score = len(importer_paths) * 3 rs = _RankedSymbol( symbol=sym, @@ -251,10 +250,10 @@ def _rank_symbols(self) -> RankedSymbols: ranked.review_next.append(rs) # Sort each bucket - ranked.review_first.sort(key=lambda r: r.score, reverse=True) - ranked.review_next.sort( - key=lambda r: (-r.lines_changed, r.symbol.get("name", "")) + ranked.review_first.sort( + key=lambda r: (-r.score, r.symbol.get("name", "")) ) + ranked.review_next.sort(key=lambda r: r.symbol.get("name", "")) ranked.context.sort( key=lambda r: ( file_id_to_path.get(r.symbol.get("file_id", ""), ""), diff --git a/tests/test_terminal_formatter.py b/tests/test_terminal_formatter.py index 7ad2a82..21d43dd 100644 --- a/tests/test_terminal_formatter.py +++ b/tests/test_terminal_formatter.py @@ -143,8 +143,12 @@ def test_rank_symbols_no_relationships(): assert ranked.review_first == [] assert len(ranked.review_next) == 2 assert ranked.context == [] - # Sorted by lines desc: validate_token (29 lines) before TokenCache (20 lines) - assert ranked.review_next[0].symbol["name"] == "validate_token" + # With no importer signal, use a deterministic name order rather than + # treating declaration spans as changed-line counts. + assert [item.symbol["name"] for item in ranked.review_next] == [ + "TokenCache", + "validate_token", + ] # --------------------------------------------------------------------------- @@ -310,15 +314,11 @@ def test_terminal_formatter_compact(): # --------------------------------------------------------------------------- -# 10. Score ordering — higher score symbol appears first in REVIEW FIRST +# 10. Score ordering — declaration spans are not changed-line evidence # --------------------------------------------------------------------------- -def test_rank_symbols_score_ordering(): - """ - Symbol A: 2 importers, 5 lines → score = 2*3 + 5 = 11 - Symbol B: 1 importer, 20 lines → score = 1*3 + 20 = 23 - B should appear first in REVIEW FIRST despite A having more importers. - """ +def test_rank_symbols_uses_importers_not_declaration_span(): + """A large declaration must not outrank a more widely imported symbol.""" files = [ _make_file("f_a", "module_a.py", change_kind="modified"), _make_file("f_b", "module_b.py", change_kind="modified"), @@ -326,23 +326,27 @@ def test_rank_symbols_score_ordering(): _make_file("f_importer2", "consumer2.py", change_kind="modified"), ] symbols = [ - _make_symbol("s_a", "small_func", "f_a", "modified", 1, 5), # 5 lines - _make_symbol("s_b", "big_func", "f_b", "modified", 1, 20), # 20 lines + _make_symbol("s_a", "small_func", "f_a", "modified", 1, 5), + _make_symbol("s_b", "big_func", "f_b", "modified", 1, 200), ] relationships = [ - _make_import_rel("r1", "f_importer1", "f_a"), # importer1 → A - _make_import_rel("r2", "f_importer2", "f_a"), # importer2 → A (A has 2 importers) - _make_import_rel("r3", "f_importer1", "f_b"), # importer1 → B (B has 1 importer) + _make_import_rel("r1", "f_importer1", "f_a"), + _make_import_rel("r2", "f_importer2", "f_a"), + _make_import_rel("r3", "f_importer1", "f_b"), ] dg = _make_diffgraph(files=files, symbols=symbols, relationships=relationships) - fmt = TerminalFormatter(dg) + fmt = TerminalFormatter(dg, color=False) ranked = fmt._rank_symbols() - assert len(ranked.review_first) == 2 - # small_func score: 2*3 + 5 = 11; big_func score: 1*3 + 20 = 23 - # big_func should come first - assert ranked.review_first[0].symbol["name"] == "big_func" - assert ranked.review_first[1].symbol["name"] == "small_func" + assert [item.symbol["name"] for item in ranked.review_first] == [ + "small_func", + "big_func", + ] + assert all(item.lines_changed == 0 for item in ranked.review_first) + + out = io.StringIO() + fmt.render(out) + assert "200 lines" not in out.getvalue() # --------------------------------------------------------------------------- From aa470dfb97b27ae60792410856838805b7d3477c Mon Sep 17 00:00:00 2001 From: nia-sg-bot Date: Fri, 7 Aug 2026 19:36:59 +0530 Subject: [PATCH 8/8] fix(formatter): honor requested section style --- diffgraph/formatters/terminal.py | 15 +++++++++------ tests/test_terminal_formatter.py | 18 ++++++++++++++++++ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/diffgraph/formatters/terminal.py b/diffgraph/formatters/terminal.py index b2605ca..c6a20da 100644 --- a/diffgraph/formatters/terminal.py +++ b/diffgraph/formatters/terminal.py @@ -77,6 +77,13 @@ def dim_red(text: str, color: bool) -> str: return _ansi("2;31", text, color) +_SECTION_STYLE_FNS = { + "bold": bold, + "bold_yellow": bold_yellow, + "dim": dim, +} + + def _change_label(change_kind: str, color: bool) -> str: """Return colored [label] for a change_kind value.""" labels = { @@ -341,12 +348,8 @@ def _write_section( # Section header prefix = "▶ " if not _is_dumb_terminal() else "> " header_text = f"{prefix}{title}" - if title == "REVIEW FIRST": - header = bold_yellow(header_text, color) - elif title == "CONTEXT": - header = dim(header_text, color) - else: - header = bold(header_text, color) + style_fn = _SECTION_STYLE_FNS.get(section_style, bold) + header = style_fn(header_text, color) if hidden > 0: hint = f" (showing top {len(capped)} of {total} · run: wild diff --all to see all)" diff --git a/tests/test_terminal_formatter.py b/tests/test_terminal_formatter.py index 21d43dd..8aa7347 100644 --- a/tests/test_terminal_formatter.py +++ b/tests/test_terminal_formatter.py @@ -286,6 +286,24 @@ def test_terminal_formatter_piped(): assert "\033[" not in output +def test_write_section_uses_requested_header_style(): + files = [_make_file("f1", "auth/validator.py", change_kind="modified")] + symbols = [_make_symbol("s1", "validate_token", "f1", "modified", 1, 10)] + fmt = TerminalFormatter(_make_diffgraph(files=files, symbols=symbols), color=True) + ranked = fmt._rank_symbols() + out = io.StringIO() + + fmt._write_section( + "CUSTOM", + ranked.review_next, + out, + color=True, + section_style="dim", + ) + + assert out.getvalue().startswith("\033[2m▶ CUSTOM\033[0m\n") + + # --------------------------------------------------------------------------- # 9. --compact → CONTEXT section absent # ---------------------------------------------------------------------------