diff --git a/.gitignore b/.gitignore index 82fcfe7..5254a3c 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ *.egg-info/ dist/ *.pre-exo +# ExoProtocol: audit logs excluded (privacy.commit_logs=false) +.exo/logs/ diff --git a/exo/cli.py b/exo/cli.py index 9c14d84..0e3be04 100644 --- a/exo/cli.py +++ b/exo/cli.py @@ -2,8 +2,10 @@ from __future__ import annotations import argparse +import contextlib import json import os +import sys from pathlib import Path from typing import Any @@ -838,6 +840,10 @@ def _resolve_ticket_checks(repo: Path, user_checks: list[str]) -> list[str]: def main(argv: list[str] | None = None) -> int: + for _stream in (sys.stdout, sys.stderr): + with contextlib.suppress(AttributeError, OSError): + _stream.reconfigure(encoding="utf-8", errors="replace") + parser = _build_parser() args = parser.parse_args(argv) diff --git a/exo/mcp_server.py b/exo/mcp_server.py index c0d71f2..0d631ab 100644 --- a/exo/mcp_server.py +++ b/exo/mcp_server.py @@ -1,5 +1,7 @@ from __future__ import annotations +import contextlib +import sys from pathlib import Path from typing import Any @@ -2248,6 +2250,10 @@ def exo_install( def main() -> int: + for _stream in (sys.stdout, sys.stderr): + with contextlib.suppress(AttributeError, OSError): + _stream.reconfigure(encoding="utf-8", errors="replace") + if not FastMCP: raise SystemExit("MCP dependencies missing. Install with: pip install -e .[mcp]") diff --git a/exo/stdlib/triage.py b/exo/stdlib/triage.py index c2a714b..cb389a8 100644 --- a/exo/stdlib/triage.py +++ b/exo/stdlib/triage.py @@ -13,6 +13,7 @@ from __future__ import annotations +import contextlib import subprocess from dataclasses import dataclass, field from datetime import datetime, timedelta, timezone @@ -60,6 +61,22 @@ def _git(repo: Path, args: list[str]) -> subprocess.CompletedProcess[str]: ) +def _path_visible_to_outer_repo(repo: Path, path: Path) -> bool: + """Return False when path lives outside outer-repo blame visibility. + + A path is invisible if it is inside a registered submodule or not tracked + by the outer repo at all (untracked / runtime-generated / vendored). + """ + rel = str(path) + # Check submodule membership + sub = _git(repo, ["submodule", "status", rel]) + if sub.returncode == 0 and sub.stdout.strip(): + return False + # Check whether git tracks the file at all + ls = _git(repo, ["ls-files", "--error-unmatch", rel]) + return ls.returncode == 0 + + def _last_commit_for(repo: Path, path: Path) -> tuple[str, str, str]: """Return (sha, iso_timestamp, author) for the most recent commit touching *path*, or ('', '', '') if none. @@ -161,6 +178,16 @@ def triage_test( ) rel = Path(rel_test_path) + + if not _path_visible_to_outer_repo(repo, rel): + return TriageReport( + test_path=rel_test_path, + classification="unknown", + recommended_owner="", + evidence=["test lives outside outer-repo blame visibility (submodule / untracked / runtime-generated)"], + rationale="Cannot classify: test file is not visible to the outer repo's git history.", + ) + test_sha, test_iso, test_author = _last_commit_for(repo, rel) test_dt = _parse_iso(test_iso) now_dt = now or datetime.now(timezone.utc) @@ -197,11 +224,26 @@ def triage_test( ) recommended_owner = behavior_author or test_author elif test_dt and not behavior_dt and test_dt < window_start: - classification = "stale" - rationale = ( - f"Test predates the {window_days}-day window and no recent behavior " - f"changes were found — likely encoding obsolete behavior." + # Distinguish a quiet repo (no commits at all in the window) from a truly + # stale test (commits exist but none touched behaviour code). + rev_count = _git( + repo, ["rev-list", "--count", f"--since={window_start.astimezone(timezone.utc).isoformat()}", "HEAD"] ) + total_in_window = 0 + with contextlib.suppress(ValueError, AttributeError): + total_in_window = int(rev_count.stdout.strip()) + if total_in_window == 0: + classification = "ambiguous" + rationale = ( + f"Test predates the {window_days}-day window but the repo has zero commits " + f"in that period — repo is simply quiet, not the test stale." + ) + else: + classification = "stale" + rationale = ( + f"Test predates the {window_days}-day window and no recent behavior " + f"changes were found — likely encoding obsolete behavior." + ) elif test_dt and behavior_dt: delta = abs((test_dt - behavior_dt).total_seconds()) if delta < 24 * 3600: diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..f358860 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,32 @@ +"""Session-scoped pytest fixture: disable git commit signing for all tests. + +The CI environment configures commit.gpgsign=true with a remote signing +service that requires an authenticated source. Test repos do not need +signed commits, so this fixture points GIT_CONFIG_GLOBAL at a minimal +config that disables signing for every git subprocess spawned during the +test run. Without this, any test that creates a git repo and commits +files will silently produce 0 commits and fail with assertion errors. +""" + +from __future__ import annotations + +import os + +import pytest + + +@pytest.fixture(autouse=True, scope="session") +def _disable_git_signing(tmp_path_factory: pytest.TempPathFactory) -> None: + cfg_dir = tmp_path_factory.mktemp("git-global-cfg") + cfg_file = cfg_dir / "gitconfig" + cfg_file.write_text( + "[commit]\n\tgpgsign = false\n[user]\n\tname = Test\n\temail = test@exo.test\n", + encoding="utf-8", + ) + original = os.environ.get("GIT_CONFIG_GLOBAL") + os.environ["GIT_CONFIG_GLOBAL"] = str(cfg_file) + yield + if original is None: + os.environ.pop("GIT_CONFIG_GLOBAL", None) + else: + os.environ["GIT_CONFIG_GLOBAL"] = original diff --git a/tests/test_cli_encoding.py b/tests/test_cli_encoding.py new file mode 100644 index 0000000..c7ca1e8 --- /dev/null +++ b/tests/test_cli_encoding.py @@ -0,0 +1,71 @@ +"""Tests for Windows cp1252 / narrow-encoding stdout safety (Bug 2 community feedback). + +Without the fix, calling main() with a cp1252-encoded stdout raises +UnicodeEncodeError because the session banner and human formatters emit +box-drawing characters (╔ ║ ╚ ═) and arrows (→) that are not in cp1252. + +With the fix (stream.reconfigure(encoding='utf-8', errors='replace') at the +top of both cli.main() and mcp_server.main()), the call must complete without +raising; any unencodable characters are substituted with '?'. +""" + +from __future__ import annotations + +import io +import sys + +import pytest + + +class TestCliEncodingCp1252: + def test_help_survives_cp1252_stdout(self, monkeypatch: pytest.MonkeyPatch) -> None: + """main(['--help']) must not raise UnicodeEncodeError on a cp1252 stream.""" + narrow_buf = io.BytesIO() + narrow_stream = io.TextIOWrapper(narrow_buf, encoding="cp1252", errors="strict") + monkeypatch.setattr(sys, "stdout", narrow_stream) + monkeypatch.setattr(sys, "stderr", narrow_stream) + + from exo.cli import main + + # --help causes SystemExit(0); that's fine — we just must not get + # UnicodeEncodeError before argparse exits. + with pytest.raises(SystemExit) as exc_info: + main(["--help"]) + assert exc_info.value.code == 0 + + def test_help_box_chars_survive_utf8_stdout(self, monkeypatch: pytest.MonkeyPatch) -> None: + """On a utf-8 stream, reconfigure is a no-op and box chars are preserved.""" + utf8_buf = io.BytesIO() + utf8_stream = io.TextIOWrapper(utf8_buf, encoding="utf-8", errors="strict") + monkeypatch.setattr(sys, "stdout", utf8_stream) + monkeypatch.setattr(sys, "stderr", utf8_stream) + + from exo.cli import main + + with pytest.raises(SystemExit): + main(["--help"]) + + utf8_stream.flush() + output = utf8_buf.getvalue().decode("utf-8") + # argparse help output should be present and not contain replacement chars + # (box chars appear only in session-start banner, not --help output, so + # this test just verifies the output is valid UTF-8 with no corruption) + assert "exo" in output.lower() or len(output) > 0 + + def test_cp1252_replaces_not_crashes(self, monkeypatch: pytest.MonkeyPatch) -> None: + """On cp1252, box-drawing characters become '?' replacements, not exceptions.""" + narrow_buf = io.BytesIO() + narrow_stream = io.TextIOWrapper(narrow_buf, encoding="cp1252", errors="strict") + monkeypatch.setattr(sys, "stdout", narrow_stream) + monkeypatch.setattr(sys, "stderr", narrow_stream) + + from exo.cli import main + + # --help exits 0 cleanly; the main() reconfigure must have fired before + # argparse even parses args, which is exactly the fix we're testing. + try: + main(["--help"]) + except SystemExit as e: + assert e.code == 0 + except UnicodeEncodeError: + pytest.fail("UnicodeEncodeError raised on cp1252 stdout — the reconfigure fix is not working") diff --git a/tests/test_triage.py b/tests/test_triage.py index 46643f3..8087960 100644 --- a/tests/test_triage.py +++ b/tests/test_triage.py @@ -51,6 +51,11 @@ def _init_repo(tmp_path: Path) -> Path: repo = tmp_path / "repo" repo.mkdir() _git(repo, "init", "-b", "main") + # Disable commit signing for isolated test repos so commits succeed + # regardless of the global gpg/ssh signing configuration. + _git(repo, "config", "commit.gpgsign", "false") + _git(repo, "config", "user.email", "test@exo.test") + _git(repo, "config", "user.name", "Exo Test") return repo @@ -110,7 +115,13 @@ def test_regression_when_behavior_edited_after_test(self, tmp_path: Path) -> Non assert report.recommended_owner == "Author Two" def test_stale_when_test_predates_window_with_no_recent_behavior(self, tmp_path: Path) -> None: - """Test was last edited far in the past, no recent behavior change → stale.""" + """Test was last edited far in the past; there are recent non-behavior commits → stale. + + The repo has activity in the window (a governance or test-only commit), but no + non-test behavior change was found — so the test is stale, not ambiguous. + A quiet repo (zero commits in window) now returns ambiguous instead; see + TestTriageNestedAndRuntime.test_quiet_repo_returns_ambiguous_not_stale. + """ repo = _init_repo(tmp_path) now = datetime.now(timezone.utc) @@ -120,7 +131,14 @@ def test_stale_when_test_predates_window_with_no_recent_behavior(self, tmp_path: message="initial — long ago", when=now - timedelta(days=180), ) - # No recent (within window) behavior changes + # Add a recent governance commit so the repo is NOT quiet in the window. + # This is a non-behavior commit, so the test still ends up stale. + _commit_at( + repo, + files={".exo/config.yaml": "version: 1\n"}, + message="governance bump", + when=now - timedelta(days=2), + ) report = triage_test(repo, "tests/test_foo.py", window_days=30, now=now) assert report.classification == "stale" assert "obsolete" in report.rationale.lower() or "stale" in report.rationale.lower() @@ -265,3 +283,105 @@ def test_cli_classifies_and_prints(self, tmp_path: Path, capsys, monkeypatch) -> monkeypatch.chdir(repo) rc = cli_main_local(["test-triage", "tests/test_foo.py", "--window-days", "30"]) assert rc == 0 + + +class TestTriageNestedAndRuntime: + """Bug-fix tests: submodule / untracked / quiet-repo / regression-guard.""" + + def test_submodule_path_returns_unknown(self, tmp_path: Path) -> None: + """A test file inside a nested .git (simulated submodule) → unknown.""" + repo = _init_repo(tmp_path) + now = datetime.now(timezone.utc) + # Create an initial commit so HEAD exists + _commit_at( + repo, + files={"src/app.py": "x\n"}, + message="initial", + when=now - timedelta(days=5), + ) + # Simulate a submodule by creating a nested .git directory and registering + # it via git submodule add equivalent: write .gitmodules and nested .git + sub_dir = repo / "vendor" / "lib" + sub_dir.mkdir(parents=True) + nested_git = sub_dir / ".git" + nested_git.mkdir() + # Write a minimal .gitmodules so `git submodule status vendor/lib` returns output + gitmodules = repo / ".gitmodules" + gitmodules.write_text('[submodule "vendor/lib"]\n\tpath = vendor/lib\n\turl = https://example.com/lib\n') + _git(repo, "add", ".gitmodules") + _git( + repo, + "commit", + "-m", + "add submodule entry", + env_override={ + "GIT_AUTHOR_DATE": (now - timedelta(days=4)).astimezone(timezone.utc).isoformat(), + "GIT_COMMITTER_DATE": (now - timedelta(days=4)).astimezone(timezone.utc).isoformat(), + }, + ) + # Create a test file inside the faked submodule (not tracked by outer repo) + test_file = sub_dir / "test_lib.py" + test_file.write_text("def test_x(): pass\n") + report = triage_test(repo, "vendor/lib/test_lib.py", window_days=30, now=now) + assert report.classification == "unknown" + assert any("submodule" in ev or "untracked" in ev or "visibility" in ev for ev in report.evidence) + + def test_untracked_file_returns_unknown(self, tmp_path: Path) -> None: + """A test file that exists on disk but is not committed → unknown.""" + repo = _init_repo(tmp_path) + now = datetime.now(timezone.utc) + _commit_at( + repo, + files={"src/app.py": "x\n"}, + message="initial", + when=now - timedelta(days=5), + ) + # Create the test file without committing it + untracked = repo / "tests" / "test_generated.py" + untracked.parent.mkdir(parents=True, exist_ok=True) + untracked.write_text("def test_gen(): pass\n") + report = triage_test(repo, "tests/test_generated.py", window_days=30, now=now) + assert report.classification == "unknown" + rationale_lower = (report.rationale + " ".join(report.evidence)).lower() + assert "untracked" in rationale_lower or "visibility" in rationale_lower or "submodule" in rationale_lower + + def test_quiet_repo_returns_ambiguous_not_stale(self, tmp_path: Path) -> None: + """Test predates window AND zero commits in the window → ambiguous (repo is quiet).""" + repo = _init_repo(tmp_path) + now = datetime.now(timezone.utc) + # Single old commit, nothing recent + _commit_at( + repo, + files={"src/app.py": "x\n", "tests/test_app.py": "y\n"}, + message="initial — long ago", + when=now - timedelta(days=180), + ) + report = triage_test(repo, "tests/test_app.py", window_days=30, now=now) + assert report.classification == "ambiguous" + assert "quiet" in report.rationale.lower() or "zero" in report.rationale.lower() + + def test_truly_stale_still_classified_stale(self, tmp_path: Path) -> None: + """Test predates window; recent commits exist but are ALL test/governance → stale (regression guard). + + This guards against regressing the stale classification when a repo is + active (commits in window) but none of them touched behaviour code. + """ + repo = _init_repo(tmp_path) + now = datetime.now(timezone.utc) + _commit_at( + repo, + files={"src/app.py": "v1\n", "tests/test_app.py": "old test\n"}, + message="initial — long ago", + when=now - timedelta(days=180), + ) + # Recent commits within the window but they only touch test/governance files, + # not behaviour code — so the stale classification must survive. + _commit_at( + repo, + files={"tests/test_other.py": "z\n"}, + message="add another test", + when=now - timedelta(days=5), + ) + report = triage_test(repo, "tests/test_app.py", window_days=30, now=now) + assert report.classification == "stale" + assert "obsolete" in report.rationale.lower() or "stale" in report.rationale.lower()