Some checks failed
Forge CI / smoke-and-build (pull_request) Failing after 35s
Added error-proofing to prevent hardcoded ~/.hermes paths that break
profile isolation. This is a poka-yoke (mistake-proofing) measure.
Changes:
1. Added .githooks/check_hardcoded_paths.py - pre-commit hook that detects:
- Path.home() / '.hermes' patterns
- '~/.hermes' in string literals
- os.path.expanduser('~/.hermes') patterns
- os.path.join(expanduser('~'), '.hermes') patterns
2. Updated .githooks/pre-commit.py to run the hardcoded path check
3. Added CI job in .github/workflows/tests.yml to check for hardcoded paths
4. Added comprehensive tests in tests/test_hardcoded_paths.py:
- Tests for pattern detection
- Tests for get_hermes_home() and display_hermes_home() functions
- Tests for profile isolation
- Integration tests for pre-commit hook
The hook ignores:
- hermes_constants.py (source of truth)
- Test files (can mock/test behavior)
- Documentation files (.md, README, etc.)
- Comments and docstrings
This prevents the recurring pattern of hardcoded paths that break
profile isolation, as mentioned in issue #293.
Fixes #293
176 lines
7.0 KiB
Python
176 lines
7.0 KiB
Python
"""
|
|
Tests for hardcoded ~/.hermes path detection (poka-yoke).
|
|
|
|
These tests verify that the pre-commit hook correctly detects hardcoded
|
|
paths and that the codebase uses get_hermes_home() correctly.
|
|
"""
|
|
|
|
import os
|
|
import tempfile
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
# Import the scanner
|
|
import sys
|
|
sys.path.insert(0, str(Path(__file__).parent.parent / ".githooks"))
|
|
from check_hardcoded_paths import scan_line_for_hardcoded_paths, Finding
|
|
|
|
|
|
class TestHardcodedPathDetection:
|
|
"""Test the hardcoded path detection logic."""
|
|
|
|
def test_detects_path_home_hermes(self):
|
|
"""Detect Path.home() / '.hermes' pattern."""
|
|
line = ' home = Path.home() / ".hermes"'
|
|
findings = list(scan_line_for_hardcoded_paths(line, "test.py", 1))
|
|
assert len(findings) == 1
|
|
assert "Path.home()" in findings[0].message
|
|
|
|
def test_detects_path_home_hermes_subpath(self):
|
|
"""Detect Path.home() / '.hermes' / 'subdir' pattern."""
|
|
line = ' config_dir = Path.home() / ".hermes" / "config"'
|
|
findings = list(scan_line_for_hardcoded_paths(line, "test.py", 1))
|
|
assert len(findings) == 1
|
|
|
|
def test_detects_tilde_hermes_in_string(self):
|
|
"""Detect '~/.hermes' in string literals."""
|
|
line = ' path = "~/.hermes/config.yaml"'
|
|
findings = list(scan_line_for_hardcoded_paths(line, "test.py", 1))
|
|
assert len(findings) == 1
|
|
|
|
def test_detects_expanduser_hermes(self):
|
|
"""Detect os.path.expanduser('~/.hermes') pattern."""
|
|
line = ' home = os.path.expanduser("~/.hermes")'
|
|
findings = list(scan_line_for_hardcoded_paths(line, "test.py", 1))
|
|
assert len(findings) == 1
|
|
|
|
def test_detects_join_expanduser(self):
|
|
"""Detect os.path.join(expanduser('~'), '.hermes') pattern."""
|
|
line = ' home = os.path.join(os.path.expanduser("~"), ".hermes")'
|
|
findings = list(scan_line_for_hardcoded_paths(line, "test.py", 1))
|
|
assert len(findings) == 1
|
|
|
|
def test_ignores_comments(self):
|
|
"""Ignore hardcoded paths in comments."""
|
|
line = ' # This is ~/.hermes in a comment'
|
|
findings = list(scan_line_for_hardcoded_paths(line, "test.py", 1))
|
|
assert len(findings) == 0
|
|
|
|
def test_ignores_docstrings(self):
|
|
"""Ignore hardcoded paths in docstrings."""
|
|
line = ' """This mentions ~/.hermes in a docstring."""'
|
|
findings = list(scan_line_for_hardcoded_paths(line, "test.py", 1))
|
|
assert len(findings) == 0
|
|
|
|
def test_ignores_hermes_constants(self):
|
|
"""hermes_constants.py is allowed to have hardcoded paths."""
|
|
line = ' return Path.home() / ".hermes"'
|
|
findings = list(scan_line_for_hardcoded_paths(line, "hermes_constants.py", 1))
|
|
assert len(findings) == 0
|
|
|
|
def test_ignores_test_files(self):
|
|
"""Test files can have hardcoded paths for testing."""
|
|
line = ' home = Path.home() / ".hermes"'
|
|
findings = list(scan_line_for_hardcoded_paths(line, "test_something.py", 1))
|
|
assert len(findings) == 0
|
|
|
|
def test_ignores_markdown_files(self):
|
|
"""Markdown files can have hardcoded paths in examples."""
|
|
line = ' home = Path.home() / ".hermes"'
|
|
findings = list(scan_line_for_hardcoded_paths(line, "README.md", 1))
|
|
assert len(findings) == 0
|
|
|
|
def test_ignores_empty_lines(self):
|
|
"""Empty lines should not produce findings."""
|
|
line = ""
|
|
findings = list(scan_line_for_hardcoded_paths(line, "test.py", 1))
|
|
assert len(findings) == 0
|
|
|
|
|
|
class TestHermesHomeUsage:
|
|
"""Test that the codebase uses get_hermes_home() correctly."""
|
|
|
|
def test_hermes_constants_has_get_hermes_home(self):
|
|
"""hermes_constants.py should export get_hermes_home()."""
|
|
from hermes_constants import get_hermes_home
|
|
assert callable(get_hermes_home)
|
|
|
|
def test_hermes_constants_has_display_hermes_home(self):
|
|
"""hermes_constants.py should export display_hermes_home()."""
|
|
from hermes_constants import display_hermes_home
|
|
assert callable(display_hermes_home)
|
|
|
|
def test_get_hermes_home_returns_path(self):
|
|
"""get_hermes_home() should return a Path object."""
|
|
from hermes_constants import get_hermes_home
|
|
result = get_hermes_home()
|
|
assert isinstance(result, Path)
|
|
|
|
def test_get_hermes_home_honors_env_var(self):
|
|
"""get_hermes_home() should honor HERMES_HOME env var."""
|
|
from hermes_constants import get_hermes_home
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
with patch.dict(os.environ, {"HERMES_HOME": tmpdir}):
|
|
result = get_hermes_home()
|
|
assert result == Path(tmpdir)
|
|
|
|
def test_display_hermes_home_returns_string(self):
|
|
"""display_hermes_home() should return a string."""
|
|
from hermes_constants import display_hermes_home
|
|
result = display_hermes_home()
|
|
assert isinstance(result, str)
|
|
|
|
def test_display_hermes_home_uses_tilde_shorthand(self):
|
|
"""display_hermes_home() should use ~/ shorthand for home directory."""
|
|
from hermes_constants import display_hermes_home, get_hermes_home
|
|
|
|
# If HERMES_HOME is under home directory, should use ~/
|
|
home = get_hermes_home()
|
|
if home.is_relative_to(Path.home()):
|
|
result = display_hermes_home()
|
|
assert result.startswith("~/")
|
|
|
|
def test_profile_isolation_with_env_var(self):
|
|
"""Each profile should have its own HERMES_HOME."""
|
|
from hermes_constants import get_hermes_home
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir1, tempfile.TemporaryDirectory() as tmpdir2:
|
|
# Profile 1
|
|
with patch.dict(os.environ, {"HERMES_HOME": tmpdir1}):
|
|
home1 = get_hermes_home()
|
|
|
|
# Profile 2
|
|
with patch.dict(os.environ, {"HERMES_HOME": tmpdir2}):
|
|
home2 = get_hermes_home()
|
|
|
|
assert home1 != home2
|
|
assert home1 == Path(tmpdir1)
|
|
assert home2 == Path(tmpdir2)
|
|
|
|
|
|
class TestPreCommitHookIntegration:
|
|
"""Integration tests for the pre-commit hook."""
|
|
|
|
def test_hook_script_exists(self):
|
|
"""The check_hardcoded_paths.py script should exist."""
|
|
hook_path = Path(__file__).parent.parent / ".githooks" / "check_hardcoded_paths.py"
|
|
assert hook_path.exists()
|
|
|
|
def test_hook_script_is_executable(self):
|
|
"""The check_hardcoded_paths.py script should be executable."""
|
|
hook_path = Path(__file__).parent.parent / ".githooks" / "check_hardcoded_paths.py"
|
|
assert hook_path.stat().st_mode & 0o111 # Check executable bits
|
|
|
|
def test_pre_commit_calls_hardcoded_check(self):
|
|
"""pre-commit.py should call the hardcoded path check."""
|
|
pre_commit_path = Path(__file__).parent.parent / ".githooks" / "pre-commit.py"
|
|
content = pre_commit_path.read_text()
|
|
assert "check_hardcoded_paths.py" in content
|
|
|
|
|
|
if __name__ == "__main__":
|
|
pytest.main([__file__, "-v"])
|