Some checks failed
Test / pytest (pull_request) Failing after 9s
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
223 lines
8.2 KiB
Python
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")
|
|
|