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] 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