Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@
*.egg-info/
dist/
*.pre-exo
# ExoProtocol: audit logs excluded (privacy.commit_logs=false)
.exo/logs/
6 changes: 6 additions & 0 deletions exo/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)

Expand Down
6 changes: 6 additions & 0 deletions exo/mcp_server.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from __future__ import annotations

import contextlib
import sys
from pathlib import Path
from typing import Any

Expand Down Expand Up @@ -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]")

Expand Down
50 changes: 46 additions & 4 deletions exo/stdlib/triage.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

from __future__ import annotations

import contextlib
import subprocess
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
32 changes: 32 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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
71 changes: 71 additions & 0 deletions tests/test_cli_encoding.py
Original file line number Diff line number Diff line change
@@ -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")
124 changes: 122 additions & 2 deletions tests/test_triage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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)

Expand All @@ -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()
Expand Down Expand Up @@ -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()
Loading