diff --git a/docs/superpowers/evidence/code-tutor-test-version-fix-2026-07-11.json b/docs/superpowers/evidence/code-tutor-test-version-fix-2026-07-11.json new file mode 100644 index 00000000..b7441f95 --- /dev/null +++ b/docs/superpowers/evidence/code-tutor-test-version-fix-2026-07-11.json @@ -0,0 +1,30 @@ +{ + "intent": "Fix a Python-version-fragile code_tutor test so testsAndMisc CI (Python 3.11) passes: the codebase_fingerprint OSError-branch test passed on 3.14 but failed on 3.11.", + "scope": [ + "python_pkg/code_tutor/tests/test_analyzer.py", + "Non-goal: any change to _analyzer.py source (behavior unchanged)." + ], + "changes": [ + "test_codebase_fingerprint_stat_oserror_raises now patches Path.is_file to return True for the target file (without calling stat) in addition to patching Path.stat to raise, so the loop deterministically reaches the guarded path.stat() call and hits the `except OSError` branch on every Python version.", + "Root cause: on 3.11/3.12 Path.is_file() calls stat() and re-raises OSErrors without a recognized errno, so the injected error escaped before the guarded stat() was reached; on 3.14 is_file() uses os.path.isfile and did not." + ], + "verification": [ + { + "command": "python3.11 -m pytest test_codebase_fingerprint_stat_oserror_raises -o addopts=''", + "result": "pass", + "evidence": "1 passed on 3.11.11 (the CI version that previously failed)" + }, + { + "command": "python -m pytest test_analyzer.py::...stat_oserror_raises --cov=python_pkg.code_tutor._analyzer", + "result": "pass", + "evidence": "1 passed on 3.14; _analyzer.py lines 386-387 (except OSError branch) covered" + } + ], + "risks": [ + "Patching is_file globally could mask a real is_file bug, but the mock delegates to the real is_file for all non-target paths." + ], + "rollback": [ + "git revert the commit; the prior test still covers the branch on 3.14 only.", + "After rollback, re-run on 3.11 to confirm the flake returns." + ] +} diff --git a/python_pkg/code_tutor/tests/test_analyzer.py b/python_pkg/code_tutor/tests/test_analyzer.py index 83b65cf3..be970cec 100644 --- a/python_pkg/code_tutor/tests/test_analyzer.py +++ b/python_pkg/code_tutor/tests/test_analyzer.py @@ -422,12 +422,15 @@ def test_code_item_defaults() -> None: def test_codebase_fingerprint_stat_oserror_raises(tmp_path: Path) -> None: """Skip a file whose ``stat`` raises ``OSError`` (except branch). - On this Python ``Path.is_file`` uses ``os.path.isfile`` rather than - ``Path.stat``, so a stat that always fails for the source file reaches - the ``except OSError`` handler that drops the unreadable entry. + ``is_file()`` is forced True for the target so the loop reaches the + explicit ``path.stat()`` at fingerprint time; that stat then raises and + the ``except OSError`` handler drops the unreadable entry. Patching both + keeps this deterministic across Python versions (on some, ``is_file`` + itself calls ``stat`` and would otherwise re-raise before line reached). """ (tmp_path / "source.py").write_text("x = 1", encoding="utf-8") real_stat = Path.stat + real_is_file = Path.is_file def failing_stat(self: Path, **kwargs: object) -> object: """Raise for the target file, delegate to the real stat otherwise.""" @@ -436,7 +439,14 @@ def test_codebase_fingerprint_stat_oserror_raises(tmp_path: Path) -> None: raise OSError(msg) return real_stat(self, **kwargs) - with patch.object(Path, "stat", failing_stat): + def always_file(self: Path) -> bool: + """Report the target as a regular file without calling stat.""" + return str(self).endswith("source.py") or real_is_file(self) + + with ( + patch.object(Path, "stat", failing_stat), + patch.object(Path, "is_file", always_file), + ): result = codebase_fingerprint(tmp_path) assert len(result) == 16