diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7d0822690..80e5965ee 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -47,6 +47,21 @@ jobs: OPENAI_API_KEY: "" NOUS_API_KEY: "" + lint-paths: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Install Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Check for hardcoded ~/.hermes paths + run: python3 scripts/lint_hardcoded_paths.py + e2e: runs-on: ubuntu-latest timeout-minutes: 10 diff --git a/scripts/lint_hardcoded_paths.py b/scripts/lint_hardcoded_paths.py new file mode 100644 index 000000000..92488c8a0 --- /dev/null +++ b/scripts/lint_hardcoded_paths.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +"""Lint for hardcoded ~/.hermes paths. + +Detects patterns that break profile isolation by hardcoding ~/.hermes +instead of using get_hermes_home() from hermes_constants. + +Usage: + python3 scripts/lint_hardcoded_paths.py # check all + python3 scripts/lint_hardcoded_paths.py --fix # suggest fixes + python3 scripts/lint_hardcoded_paths.py --json # JSON output +""" + +from __future__ import annotations + +import json +import os +import re +import sys +from dataclasses import dataclass, asdict +from pathlib import Path +from typing import List + +REPO_ROOT = Path(__file__).resolve().parent.parent + +# Patterns that indicate hardcoded ~/.hermes paths +_PATTERNS = [ + (r'Path\.home\(\)\s*/\s*[\"\']\.hermes[\"\']', "Path.home() / '.hermes'"), + (r'Path\.home\(\)\s*/\s*\"\.hermes\"', 'Path.home() / ".hermes"'), + (r'[\"\']~[/\\]\.hermes[/\\]', "hardcoded ~/.hermes string"), + (r'os\.path\.expanduser\([\"\']~[/\\]\.hermes', "expanduser('~/.hermes')"), + (r'os\.path\.join\(.*expanduser.*\.hermes', "os.path.join with expanduser"), + (r'HOME[\"\']\s*\+\s*[\"\'][/\\]\.hermes', "$HOME + .hermes concatenation"), +] + +# Files to skip +_SKIP_DIRS = { + ".git", "__pycache__", ".venv", "venv", "node_modules", + ".mypy_cache", ".pytest_cache", "dist", "build", +} +_SKIP_FILES = { + "hermes_constants.py", # source of truth +} +_SKIP_EXTENSIONS = {".md", ".rst", ".txt", ".json", ".yaml", ".yml", ".toml"} + + +@dataclass +class Finding: + file: str + line: int + pattern: str + content: str + severity: str = "error" + + +def scan_file(filepath: Path) -> List[Finding]: + """Scan a single file for hardcoded path patterns.""" + findings = [] + + try: + content = filepath.read_text(encoding="utf-8", errors="replace") + except Exception: + return findings + + for line_num, line in enumerate(content.split("\n"), 1): + # Skip comments and docstrings (rough heuristic) + stripped = line.strip() + if stripped.startswith("#") or stripped.startswith('"""') or stripped.startswith("'''"): + continue + + for pattern, description in _PATTERNS: + if re.search(pattern, line): + findings.append(Finding( + file=str(filepath.relative_to(REPO_ROOT)), + line=line_num, + pattern=description, + content=stripped[:120], + )) + break # One finding per line + + return findings + + +def scan_repo(root: Path = None) -> List[Finding]: + """Scan the entire repo for hardcoded paths.""" + root = root or REPO_ROOT + findings = [] + + for path in root.rglob("*.py"): + # Skip directories + rel = path.relative_to(root) + parts = rel.parts + if any(p in _SKIP_DIRS for p in parts): + continue + if path.name in _SKIP_FILES: + continue + if path.suffix in _SKIP_EXTENSIONS: + continue + + findings.extend(scan_file(path)) + + return findings + + +def format_findings(findings: List[Finding]) -> str: + """Format findings as readable report.""" + if not findings: + return "OK: No hardcoded ~/.hermes paths found." + + lines = [ + f"FAIL: Found {len(findings)} hardcoded ~/.hermes path(s):", + "", + ] + for f in findings: + lines.append(f" {f.file}:{f.line} [{f.severity}]") + lines.append(f" Pattern: {f.pattern}") + lines.append(f" Line: {f.content}") + lines.append("") + + lines.append("Fix: Use get_hermes_home() from hermes_constants instead.") + return "\n".join(lines) + + +def main(): + import argparse + parser = argparse.ArgumentParser(description="Lint for hardcoded ~/.hermes paths") + parser.add_argument("--json", action="store_true", help="JSON output") + parser.add_argument("--fix", action="store_true", help="Show fix suggestions") + args = parser.parse_args() + + findings = scan_repo() + + if args.json: + print(json.dumps([asdict(f) for f in findings], indent=2)) + elif args.fix and findings: + print(format_findings(findings)) + print("\nSuggested fix pattern:") + print(" from hermes_constants import get_hermes_home") + print(" hermes_home = get_hermes_home()") + else: + print(format_findings(findings)) + + return 1 if findings else 0 + + +if __name__ == "__main__": + sys.exit(main())