Files
compounding-intelligence/tests/test_linter_runner.py
Alexander Payne a9c652e576
Some checks failed
Test / pytest (pull_request) Failing after 9s
feat(linter): add linter runner for multi-language repo scanning (#155)
Implements #155 — a language-aware linter runner that detects repository
languages and runs appropriate linters (pylint, eslint, shellcheck, yamllint,
etc.), collects violations, and outputs structured reports.

Acceptance criteria:
  [x] Detects language per repo — based on file extensions
  [x] Runs: pylint (Python), eslint (JS/TS), shellcheck (Shell), yamllint (YAML)
  [x] Collects violations: file, line, column, message, severity, linter, code
  [x] Output: lint report per repo in console or JSON (--format json)

Files added:
- scripts/linter_runner.py — CLI tool + library functions
- tests/test_linter_runner.py — unit tests (language detection, parsing)

Usage:
  python3 scripts/linter_runner.py --repo .
  python3 scripts/linter_runner.py --repo . --format json
  python3 scripts/linter_runner.py --repo . --fail-on error

Output example:
  === Lint Report: my-repo ===
    python: 3 issues (1 errors, 2 warnings)
    shell:  1 issues (1 errors)
    Total:  4 issues
2026-04-26 00:58:02 -04:00

223 lines
8.2 KiB
Python

#!/usr/bin/env python3
"""Tests for linter_runner module (Issue #155).
Tests cover:
- Language detection by file extension
- Linter result aggregation
- Violation parsing (JSON output formats)
- Exit code logic (fail-on)
- Report formatting (console/JSON)
"""
import json
import sys
import tempfile
from pathlib import Path
import pytest
# Add scripts to path
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
from linter_runner import (
Violation,
LinterResult,
detect_languages,
parse_linter_output,
_result_to_dict,
EXTENSION_TO_LANGUAGE,
LINTERS_BY_LANGUAGE,
)
class TestLanguageDetection:
"""Test detect_languages() identifies languages correctly."""
def test_detects_python_files(self, tmp_path: Path):
(tmp_path / "main.py").write_text("print('hello')")
(tmp_path / "lib" / "utils.py").mkdir(parents=True)
(tmp_path / "lib" / "utils.py").write_text("def foo(): pass")
result = detect_languages(tmp_path)
assert "python" in result
assert len(result["python"]) == 2
def test_detects_javascript_files(self, tmp_path: Path):
(tmp_path / "app.js").write_text("console.log('hi')")
(tmp_path / "component.jsx").write_text("<div/>")
result = detect_languages(tmp_path)
assert "javascript" in result
assert len(result["javascript"]) == 2
def test_detects_shell_files(self, tmp_path: Path):
(tmp_path / "setup.sh").write_text("#!/bin/bash\necho hi")
(tmp_path / "build.sh").write_text("make")
result = detect_languages(tmp_path)
assert "shell" in result
assert len(result["shell"]) == 2
def test_detects_yaml_files(self, tmp_path: Path):
(tmp_path / "config.yml").write_text("key: value")
(tmp_path / "env.yaml").write_text("env: test")
result = detect_languages(tmp_path)
assert "yaml" in result
assert len(result["yaml"]) == 2
def test_ignores_git_directory(self, tmp_path: Path):
git_dir = tmp_path / ".git"
git_dir.mkdir()
(git_dir / "config").write_text("placeholder")
(tmp_path / "script.py").write_text("print(1)")
result = detect_languages(tmp_path)
assert "python" in result
assert not any(".git" in str(f) for f in result.get("python", []))
def test_returns_empty_for_nonexistent_path(self):
result = detect_languages(Path("/nonexistent/path/xyz"))
assert result == {}
def test_mixed_languages(self, tmp_path: Path):
(tmp_path / "app.py").write_text("")
(tmp_path / "main.js").write_text("")
(tmp_path / "deploy.sh").write_text("")
result = detect_languages(tmp_path)
langs = set(result.keys())
assert {"python", "javascript", "shell"} <= langs
def test_limits_files_to_known_languages(self, tmp_path: Path):
(tmp_path / "readme.txt").write_text("text")
(tmp_path / "data.csv").write_text("a,b,c")
result = detect_languages(tmp_path)
assert len(result) == 0
class TestViolationParsing:
"""Test parse_linter_output parses various linter formats."""
def test_parses_pylint_json(self):
stdout = json.dumps([
{"type": "error", "module": "test.py", "line": 10, "column": 5,
"message": "Missing docstring", "symbol": "missing-docstring"},
{"type": "warning", "module": "test.py", "line": 15, "column": 1,
"message": "Line too long", "symbol": "line-too-long"},
])
violations = parse_linter_output("pylint", stdout, "", Path("/repo"))
assert len(violations) == 2
assert violations[0].severity == "error"
assert violations[0].message == "Missing docstring"
assert violations[1].severity == "warning"
assert violations[1].code == "line-too-long"
def test_parses_ruff_json(self):
stdout = json.dumps([
{"filename": "src/main.py", "location": {"row": 5, "column": 1},
"code": "E501", "message": "Line too long"},
])
violations = parse_linter_output("ruff", stdout, "", Path("/repo"))
assert len(violations) == 1
assert violations[0].file == "src/main.py"
assert violations[0].line == 5
assert violations[0].code == "E501"
def test_parses_eslint_json(self):
stdout = json.dumps([
{"fileName": "app.js", "range": {"start": {"line": 2, "column": 0}},
"message": "Unexpected console statement", "severity": 2, "ruleId": "no-console"},
])
violations = parse_linter_output("eslint", stdout, "", Path("/repo"))
assert len(violations) == 1
assert violations[0].severity == "error"
assert violations[0].code == "no-console"
def test_parses_shellcheck_json1(self):
stdout = json.dumps({
"issues": [
{"file": "script.sh", "line": 3, "column": 1,
"message": "Quote this to prevent word splitting", "level": "warning", "code": "SC2086"},
]
})
violations = parse_linter_output("shellcheck", stdout, "", Path("/repo"))
assert len(violations) == 1
assert violations[0].severity == "warning"
assert violations[0].code == "SC2086"
def test_parses_yamllint_parsable(self):
stdout = "config.yaml:3:1: [error] wrong document start (document-start)\n"
violations = parse_linter_output("yamllint", stdout, "", Path("/repo"))
assert len(violations) == 1
assert violations[0].file == "config.yaml"
assert violations[0].line == 3
assert violations[0].severity == "error"
assert violations[0].code == "document-start"
def test_returns_empty_on_invalid_json(self):
stdout = "Not valid JSON"
violations = parse_linter_output("pylint", stdout, "", Path("/repo"))
assert violations == []
def test_strips_leading_slash_from_paths(self):
stdout = json.dumps([{"type": "error", "module": "/repo/src/test.py",
"line": 1, "column": 1, "message": "test", "symbol": "T001"}])
violations = parse_linter_output("pylint", stdout, "", Path("/repo"))
assert violations[0].file == "src/test.py"
class TestLinterResult:
"""Test LinterResult and JSON serialization."""
def test_result_to_dict_roundtrip(self):
v = Violation(file="test.py", line=10, column=5, message="msg",
severity="error", linter="pylint", code="E001")
r = LinterResult(linter_name="pylint", language="python", violations=[v])
d = _result_to_dict(r)
assert d["linter"] == "pylint"
assert d["violations"][0]["file"] == "test.py"
assert d["violations"][0]["code"] == "E001"
class TestIntegration:
"""End-to-end integration tests with temporary repos."""
def test_linter_runner_accepts_repo_path(self, tmp_path: Path):
(tmp_path / "main.py").write_text("print('hello')")
(tmp_path / "bad.py").write_text("import unused_module\nx=1")
from linter_runner import detect_languages, run_linters_for_language
langs = detect_languages(tmp_path)
assert "python" in langs
result = run_linters_for_language("python", langs["python"][:1], tmp_path)
assert result.language == "python"
assert result.violations or result.error # either linter output or not-installed
def test_json_output_structure(self, tmp_path: Path):
(tmp_path / "script.py").write_text("print(1)")
from linter_runner import detect_languages, run_linters_for_language, _result_to_dict
langs = detect_languages(tmp_path)
if "python" not in langs:
pytest.skip("No Python files detected")
result = run_linters_for_language("python", langs["python"], tmp_path)
report = {
"repo": tmp_path.name,
"languages": {"python": _result_to_dict(result)},
"summary": {
"total_issues": len(result.violations),
"errors": sum(1 for v in result.violations if v.severity == "error"),
},
}
json.dumps(report) # should not raise
if __name__ == "__main__":
print("Run: pytest tests/test_linter_runner.py -v")