- Remove sys.path.insert() blocks from test files (E402) — package is installed via pip install -e . so path manipulation is unnecessary - Move import importlib.util and import subprocess to top of file (E402) - Remove unused sys import from test_seedgo_core.py (F401) - Fix undefined _src_path reference in test_seedgo_cli.py (F821) - Remove unused result variable assignment in TestCLIInit (F841) - Remove extraneous f prefix from f-string in cli.py (F541) All 249 tests pass. Zero ruff violations. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
766 lines
28 KiB
Python
766 lines
28 KiB
Python
"""
|
|
Seed Go Plugin Tests — Phase 3
|
|
|
|
Tests for all 5 starter plugins:
|
|
- no-bare-except
|
|
- type-hints-required
|
|
- docstring-coverage
|
|
- function-length
|
|
- file-structure
|
|
|
|
Each plugin has:
|
|
- A clean-file test (should pass)
|
|
- A violation test (should fail)
|
|
- Edge case tests (empty files, comments, binary-safe reads)
|
|
- Configurable option tests where applicable
|
|
|
|
Uses tmp_path for isolated test directories.
|
|
"""
|
|
|
|
import importlib.util
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
_repo_root = Path(__file__).parent.parent
|
|
|
|
def _load_plugin(plugin_filename: str | Path):
|
|
"""Load a plugin module by filename from the plugins directory."""
|
|
plugin_path = _repo_root / "src" / "seedgo" / "plugins" / plugin_filename
|
|
spec = importlib.util.spec_from_file_location(Path(plugin_filename).stem, plugin_path)
|
|
assert spec is not None and spec.loader is not None
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Plugin: no-bare-except
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestNoBareExcept:
|
|
@pytest.fixture
|
|
def plugin(self):
|
|
return _load_plugin(Path("no_bare_except.py"))
|
|
|
|
def test_plugin_name(self, plugin):
|
|
assert plugin.PLUGIN_NAME == "no-bare-except"
|
|
|
|
def test_plugin_has_description(self, plugin):
|
|
assert isinstance(plugin.PLUGIN_DESCRIPTION, str)
|
|
assert len(plugin.PLUGIN_DESCRIPTION) > 0
|
|
|
|
def test_plugin_has_file_types(self, plugin):
|
|
assert plugin.FILE_TYPES == ["*.py"]
|
|
|
|
def test_clean_file_passes(self, plugin, tmp_path):
|
|
f = tmp_path / "clean.py"
|
|
f.write_text(
|
|
"try:\n"
|
|
" do_something()\n"
|
|
"except Exception:\n"
|
|
" pass\n"
|
|
)
|
|
result = plugin.check(str(f))
|
|
assert result.passed is True
|
|
assert result.plugin == "no-bare-except"
|
|
failed = [c for c in result.checks if not c.passed]
|
|
assert len(failed) == 0
|
|
|
|
def test_bare_except_fails(self, plugin, tmp_path):
|
|
f = tmp_path / "bad.py"
|
|
f.write_text(
|
|
"try:\n"
|
|
" do_something()\n"
|
|
"except:\n"
|
|
" pass\n"
|
|
)
|
|
result = plugin.check(str(f))
|
|
assert result.passed is False
|
|
failed = [c for c in result.checks if not c.passed]
|
|
assert len(failed) == 1
|
|
assert failed[0].line == 3
|
|
|
|
def test_multiple_bare_excepts(self, plugin, tmp_path):
|
|
f = tmp_path / "multi.py"
|
|
f.write_text(
|
|
"try:\n"
|
|
" x()\n"
|
|
"except:\n"
|
|
" pass\n"
|
|
"try:\n"
|
|
" y()\n"
|
|
"except:\n"
|
|
" pass\n"
|
|
)
|
|
result = plugin.check(str(f))
|
|
assert result.passed is False
|
|
failed = [c for c in result.checks if not c.passed]
|
|
assert len(failed) == 2
|
|
|
|
def test_bare_except_in_comment_ignored(self, plugin, tmp_path):
|
|
f = tmp_path / "comment.py"
|
|
f.write_text(
|
|
"# except: <- this is just a comment\n"
|
|
"x = 1\n"
|
|
)
|
|
result = plugin.check(str(f))
|
|
assert result.passed is True
|
|
|
|
def test_bare_except_in_string_ignored(self, plugin, tmp_path):
|
|
f = tmp_path / "string.py"
|
|
f.write_text(
|
|
'x = """This shows except: usage"""\n'
|
|
"y = 1\n"
|
|
)
|
|
result = plugin.check(str(f))
|
|
assert result.passed is True
|
|
|
|
def test_empty_file_passes(self, plugin, tmp_path):
|
|
f = tmp_path / "empty.py"
|
|
f.write_text("")
|
|
result = plugin.check(str(f))
|
|
assert result.passed is True
|
|
|
|
def test_file_with_only_comments_passes(self, plugin, tmp_path):
|
|
f = tmp_path / "comments.py"
|
|
f.write_text(
|
|
"# This is a comment\n"
|
|
"# except: not a real bare except\n"
|
|
)
|
|
result = plugin.check(str(f))
|
|
assert result.passed is True
|
|
|
|
def test_nonexistent_file_returns_passing(self, plugin, tmp_path):
|
|
result = plugin.check(str(tmp_path / "does_not_exist.py"))
|
|
assert result.passed is True
|
|
assert result.metadata.get("skipped") is True
|
|
|
|
def test_bare_except_with_inline_comment_still_flagged(self, plugin, tmp_path):
|
|
f = tmp_path / "inline.py"
|
|
f.write_text(
|
|
"try:\n"
|
|
" x()\n"
|
|
"except: # bad practice\n"
|
|
" pass\n"
|
|
)
|
|
result = plugin.check(str(f))
|
|
assert result.passed is False
|
|
|
|
def test_specific_exception_passes(self, plugin, tmp_path):
|
|
f = tmp_path / "specific.py"
|
|
f.write_text(
|
|
"try:\n"
|
|
" x()\n"
|
|
"except (ValueError, TypeError):\n"
|
|
" pass\n"
|
|
)
|
|
result = plugin.check(str(f))
|
|
assert result.passed is True
|
|
|
|
def test_result_has_file_path(self, plugin, tmp_path):
|
|
f = tmp_path / "test.py"
|
|
f.write_text("x = 1\n")
|
|
result = plugin.check(str(f))
|
|
assert result.file_path == str(f)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Plugin: type-hints-required
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestTypeHintsRequired:
|
|
@pytest.fixture
|
|
def plugin(self):
|
|
return _load_plugin(Path("type_hints_required.py"))
|
|
|
|
def test_plugin_name(self, plugin):
|
|
assert plugin.PLUGIN_NAME == "type-hints-required"
|
|
|
|
def test_clean_file_passes(self, plugin, tmp_path):
|
|
f = tmp_path / "clean.py"
|
|
f.write_text(
|
|
"def greet(name: str) -> str:\n"
|
|
" return f'Hello {name}'\n"
|
|
"\n"
|
|
"def add(a: int, b: int) -> int:\n"
|
|
" return a + b\n"
|
|
)
|
|
result = plugin.check(str(f))
|
|
assert result.passed is True
|
|
failed = [c for c in result.checks if not c.passed]
|
|
assert len(failed) == 0
|
|
|
|
def test_missing_return_type_fails(self, plugin, tmp_path):
|
|
f = tmp_path / "bad.py"
|
|
f.write_text(
|
|
"def greet(name: str):\n"
|
|
" return f'Hello {name}'\n"
|
|
)
|
|
result = plugin.check(str(f))
|
|
assert result.passed is False
|
|
failed = [c for c in result.checks if not c.passed]
|
|
assert len(failed) == 1
|
|
assert "greet" in failed[0].message
|
|
|
|
def test_private_function_skipped(self, plugin, tmp_path):
|
|
f = tmp_path / "private.py"
|
|
f.write_text(
|
|
"def _helper():\n"
|
|
" return 42\n"
|
|
"\n"
|
|
"def __dunder():\n"
|
|
" return 42\n"
|
|
)
|
|
result = plugin.check(str(f))
|
|
assert result.passed is True
|
|
|
|
def test_init_skipped(self, plugin, tmp_path):
|
|
f = tmp_path / "cls.py"
|
|
f.write_text(
|
|
"class Foo:\n"
|
|
" def __init__(self, x: int):\n"
|
|
" self.x = x\n"
|
|
)
|
|
result = plugin.check(str(f))
|
|
assert result.passed is True
|
|
|
|
def test_dunder_methods_skipped(self, plugin, tmp_path):
|
|
f = tmp_path / "dunders.py"
|
|
f.write_text(
|
|
"class Foo:\n"
|
|
" def __str__(self):\n"
|
|
" return 'Foo'\n"
|
|
" def __repr__(self):\n"
|
|
" return 'Foo()'\n"
|
|
" def __len__(self):\n"
|
|
" return 0\n"
|
|
)
|
|
result = plugin.check(str(f))
|
|
assert result.passed is True
|
|
|
|
def test_empty_file_passes(self, plugin, tmp_path):
|
|
f = tmp_path / "empty.py"
|
|
f.write_text("")
|
|
result = plugin.check(str(f))
|
|
assert result.passed is True
|
|
|
|
def test_async_function_checked(self, plugin, tmp_path):
|
|
f = tmp_path / "async_fn.py"
|
|
f.write_text(
|
|
"async def fetch_data(url: str):\n"
|
|
" pass\n"
|
|
" pass\n"
|
|
" pass\n"
|
|
)
|
|
result = plugin.check(str(f))
|
|
assert result.passed is False
|
|
failed = [c for c in result.checks if not c.passed]
|
|
assert any("fetch_data" in c.message for c in failed)
|
|
|
|
def test_async_function_with_return_type_passes(self, plugin, tmp_path):
|
|
f = tmp_path / "async_ok.py"
|
|
f.write_text(
|
|
"async def fetch_data(url: str) -> bytes:\n"
|
|
" return b''\n"
|
|
)
|
|
result = plugin.check(str(f))
|
|
assert result.passed is True
|
|
|
|
def test_nonexistent_file_returns_passing(self, plugin, tmp_path):
|
|
result = plugin.check(str(tmp_path / "ghost.py"))
|
|
assert result.passed is True
|
|
assert result.metadata.get("skipped") is True
|
|
|
|
def test_syntax_error_skipped(self, plugin, tmp_path):
|
|
f = tmp_path / "bad_syntax.py"
|
|
f.write_text("def broken(\n x:\n")
|
|
result = plugin.check(str(f))
|
|
assert result.passed is True
|
|
assert result.metadata.get("skipped") is True
|
|
|
|
def test_violation_has_line_number(self, plugin, tmp_path):
|
|
f = tmp_path / "line.py"
|
|
f.write_text(
|
|
"x = 1\n"
|
|
"y = 2\n"
|
|
"def my_func(a: int):\n"
|
|
" return a\n"
|
|
)
|
|
result = plugin.check(str(f))
|
|
failed = [c for c in result.checks if not c.passed]
|
|
assert any(c.line == 3 for c in failed)
|
|
|
|
def test_violation_has_fix_hint(self, plugin, tmp_path):
|
|
f = tmp_path / "hint.py"
|
|
f.write_text(
|
|
"def process(data: list):\n"
|
|
" return data\n"
|
|
)
|
|
result = plugin.check(str(f))
|
|
failed = [c for c in result.checks if not c.passed]
|
|
assert len(failed) > 0
|
|
assert failed[0].fix_hint is not None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Plugin: docstring-coverage
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestDocstringCoverage:
|
|
@pytest.fixture
|
|
def plugin(self):
|
|
return _load_plugin(Path("docstring_coverage.py"))
|
|
|
|
def test_plugin_name(self, plugin):
|
|
assert plugin.PLUGIN_NAME == "docstring-coverage"
|
|
|
|
def test_clean_function_passes(self, plugin, tmp_path):
|
|
f = tmp_path / "clean.py"
|
|
f.write_text(
|
|
'def greet(name: str) -> str:\n'
|
|
' """Greet the user by name."""\n'
|
|
' return f"Hello {name}"\n'
|
|
' return f"Hello {name}"\n'
|
|
)
|
|
result = plugin.check(str(f))
|
|
failed = [c for c in result.checks if not c.passed]
|
|
assert len(failed) == 0
|
|
|
|
def test_missing_docstring_flagged(self, plugin, tmp_path):
|
|
f = tmp_path / "no_doc.py"
|
|
f.write_text(
|
|
"def process(data: list) -> list:\n"
|
|
" result = []\n"
|
|
" for item in data:\n"
|
|
" result.append(item)\n"
|
|
" return result\n"
|
|
)
|
|
result = plugin.check(str(f))
|
|
failed = [c for c in result.checks if not c.passed]
|
|
assert len(failed) == 1
|
|
assert "process" in failed[0].message
|
|
|
|
def test_info_severity(self, plugin, tmp_path):
|
|
f = tmp_path / "info.py"
|
|
f.write_text(
|
|
"def process(data: list) -> list:\n"
|
|
" result = []\n"
|
|
" for item in data:\n"
|
|
" result.append(item)\n"
|
|
" return result\n"
|
|
)
|
|
result = plugin.check(str(f))
|
|
from seedgo.models import Severity
|
|
failed = [c for c in result.checks if not c.passed]
|
|
assert all(c.severity == Severity.INFO for c in failed)
|
|
|
|
def test_missing_docstring_still_passes_overall(self, plugin, tmp_path):
|
|
"""INFO violations should not cause overall failure."""
|
|
f = tmp_path / "no_doc.py"
|
|
f.write_text(
|
|
"def process(data: list) -> list:\n"
|
|
" result = []\n"
|
|
" for item in data:\n"
|
|
" result.append(item)\n"
|
|
" return result\n"
|
|
)
|
|
result = plugin.check(str(f))
|
|
# Overall passed=True even when there are INFO violations
|
|
assert result.passed is True
|
|
|
|
def test_private_function_skipped(self, plugin, tmp_path):
|
|
f = tmp_path / "private.py"
|
|
f.write_text(
|
|
"def _helper(x: int) -> int:\n"
|
|
" y = x + 1\n"
|
|
" z = y * 2\n"
|
|
" return z\n"
|
|
)
|
|
result = plugin.check(str(f))
|
|
failed = [c for c in result.checks if not c.passed]
|
|
assert len(failed) == 0
|
|
|
|
def test_short_function_skipped(self, plugin, tmp_path):
|
|
"""Functions with < 3 body statements are exempt."""
|
|
f = tmp_path / "short.py"
|
|
f.write_text(
|
|
"def tiny(x):\n"
|
|
" return x\n"
|
|
)
|
|
result = plugin.check(str(f))
|
|
failed = [c for c in result.checks if not c.passed]
|
|
assert len(failed) == 0
|
|
|
|
def test_class_without_docstring_flagged(self, plugin, tmp_path):
|
|
f = tmp_path / "class_no_doc.py"
|
|
f.write_text(
|
|
"class MyService:\n"
|
|
" pass\n"
|
|
)
|
|
result = plugin.check(str(f))
|
|
failed = [c for c in result.checks if not c.passed]
|
|
assert len(failed) == 1
|
|
assert "MyService" in failed[0].message
|
|
|
|
def test_class_with_docstring_passes(self, plugin, tmp_path):
|
|
f = tmp_path / "class_doc.py"
|
|
f.write_text(
|
|
'class MyService:\n'
|
|
' """Service for handling requests."""\n'
|
|
' pass\n'
|
|
)
|
|
result = plugin.check(str(f))
|
|
failed = [c for c in result.checks if not c.passed]
|
|
# MyService class should pass
|
|
class_fails = [c for c in failed if "MyService" in c.message]
|
|
assert len(class_fails) == 0
|
|
|
|
def test_empty_file_passes(self, plugin, tmp_path):
|
|
f = tmp_path / "empty.py"
|
|
f.write_text("")
|
|
result = plugin.check(str(f))
|
|
assert result.passed is True
|
|
|
|
def test_nonexistent_file_passes(self, plugin, tmp_path):
|
|
result = plugin.check(str(tmp_path / "ghost.py"))
|
|
assert result.passed is True
|
|
|
|
def test_syntax_error_skipped(self, plugin, tmp_path):
|
|
f = tmp_path / "bad.py"
|
|
f.write_text("def broken(\n")
|
|
result = plugin.check(str(f))
|
|
assert result.passed is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Plugin: function-length
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestFunctionLength:
|
|
@pytest.fixture
|
|
def plugin(self):
|
|
return _load_plugin(Path("function_length.py"))
|
|
|
|
def test_plugin_name(self, plugin):
|
|
assert plugin.PLUGIN_NAME == "function-length"
|
|
|
|
def test_short_function_passes(self, plugin, tmp_path):
|
|
f = tmp_path / "short.py"
|
|
lines = ["def small_func() -> None:\n"]
|
|
lines += [f" x_{i} = {i}\n" for i in range(10)]
|
|
f.write_text("".join(lines))
|
|
result = plugin.check(str(f))
|
|
assert result.passed is True
|
|
|
|
def test_long_function_fails(self, plugin, tmp_path):
|
|
f = tmp_path / "long.py"
|
|
lines = ["def huge_func() -> None:\n"]
|
|
lines += [f" x_{i} = {i}\n" for i in range(55)]
|
|
f.write_text("".join(lines))
|
|
result = plugin.check(str(f))
|
|
assert result.passed is False
|
|
failed = [c for c in result.checks if not c.passed]
|
|
assert len(failed) == 1
|
|
assert "huge_func" in failed[0].message
|
|
|
|
def test_default_max_lines_is_50(self, plugin, tmp_path):
|
|
"""A function of exactly 50 lines should pass."""
|
|
f = tmp_path / "border.py"
|
|
lines = ["def border_func() -> None:\n"]
|
|
lines += [f" x_{i} = {i}\n" for i in range(49)]
|
|
f.write_text("".join(lines))
|
|
result = plugin.check(str(f))
|
|
assert result.passed is True
|
|
|
|
def test_function_at_51_lines_fails(self, plugin, tmp_path):
|
|
f = tmp_path / "just_over.py"
|
|
lines = ["def over_func() -> None:\n"]
|
|
lines += [f" x_{i} = {i}\n" for i in range(50)]
|
|
f.write_text("".join(lines))
|
|
result = plugin.check(str(f))
|
|
assert result.passed is False
|
|
|
|
def test_custom_max_lines_config(self, plugin, tmp_path):
|
|
"""With max_lines=10, a 15-line function should fail."""
|
|
f = tmp_path / "custom.py"
|
|
lines = ["def medium_func() -> None:\n"]
|
|
lines += [f" x_{i} = {i}\n" for i in range(15)]
|
|
f.write_text("".join(lines))
|
|
result = plugin.check(str(f), config={"max_lines": 10})
|
|
assert result.passed is False
|
|
|
|
def test_custom_max_lines_pass(self, plugin, tmp_path):
|
|
"""With max_lines=100, a 50-line function should pass."""
|
|
f = tmp_path / "custom_pass.py"
|
|
lines = ["def medium_func() -> None:\n"]
|
|
lines += [f" x_{i} = {i}\n" for i in range(50)]
|
|
f.write_text("".join(lines))
|
|
result = plugin.check(str(f), config={"max_lines": 100})
|
|
assert result.passed is True
|
|
|
|
def test_multiple_long_functions_all_reported(self, plugin, tmp_path):
|
|
f = tmp_path / "multi.py"
|
|
lines = []
|
|
for fn in ["func_a", "func_b"]:
|
|
lines.append(f"def {fn}() -> None:\n")
|
|
lines += [f" x_{i} = {i}\n" for i in range(55)]
|
|
f.write_text("".join(lines))
|
|
result = plugin.check(str(f))
|
|
assert result.passed is False
|
|
failed = [c for c in result.checks if not c.passed]
|
|
assert len(failed) == 2
|
|
|
|
def test_empty_file_passes(self, plugin, tmp_path):
|
|
f = tmp_path / "empty.py"
|
|
f.write_text("")
|
|
result = plugin.check(str(f))
|
|
assert result.passed is True
|
|
|
|
def test_nonexistent_file_passes(self, plugin, tmp_path):
|
|
result = plugin.check(str(tmp_path / "ghost.py"))
|
|
assert result.passed is True
|
|
|
|
def test_syntax_error_skipped(self, plugin, tmp_path):
|
|
f = tmp_path / "bad.py"
|
|
f.write_text("def broken(\n")
|
|
result = plugin.check(str(f))
|
|
assert result.passed is True
|
|
|
|
def test_violation_has_line_number(self, plugin, tmp_path):
|
|
f = tmp_path / "line.py"
|
|
lines = ["def long_fn() -> None:\n"]
|
|
lines += [f" x_{i} = {i}\n" for i in range(55)]
|
|
f.write_text("".join(lines))
|
|
result = plugin.check(str(f))
|
|
failed = [c for c in result.checks if not c.passed]
|
|
assert len(failed) > 0
|
|
assert failed[0].line == 1
|
|
|
|
def test_metadata_contains_max_lines(self, plugin, tmp_path):
|
|
f = tmp_path / "meta.py"
|
|
f.write_text("x = 1\n")
|
|
result = plugin.check(str(f), config={"max_lines": 30})
|
|
assert result.metadata.get("max_lines") == 30
|
|
|
|
def test_violation_has_fix_hint(self, plugin, tmp_path):
|
|
f = tmp_path / "hint.py"
|
|
lines = ["def big_func() -> None:\n"]
|
|
lines += [f" x_{i} = {i}\n" for i in range(55)]
|
|
f.write_text("".join(lines))
|
|
result = plugin.check(str(f))
|
|
failed = [c for c in result.checks if not c.passed]
|
|
assert len(failed) > 0
|
|
assert failed[0].fix_hint is not None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Plugin: file-structure
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestFileStructure:
|
|
@pytest.fixture
|
|
def plugin(self):
|
|
return _load_plugin(Path("file_structure.py"))
|
|
|
|
@pytest.fixture
|
|
def project_with_seedgo(self, tmp_path):
|
|
"""Create a minimal project with .seedgo marker."""
|
|
(tmp_path / ".seedgo").mkdir()
|
|
return tmp_path
|
|
|
|
def test_plugin_name(self, plugin):
|
|
assert plugin.PLUGIN_NAME == "file-structure"
|
|
|
|
def test_source_file_in_package_passes(self, plugin, project_with_seedgo):
|
|
"""A .py file inside a proper package (with __init__.py) should pass."""
|
|
pkg = project_with_seedgo / "src" / "mypackage"
|
|
pkg.mkdir(parents=True)
|
|
(pkg / "__init__.py").write_text("")
|
|
src = pkg / "module.py"
|
|
src.write_text("x = 1\n")
|
|
result = plugin.check(str(src))
|
|
failed = [c for c in result.checks if not c.passed]
|
|
# Should not have missing-init-py violation
|
|
init_fails = [c for c in failed if c.name == "missing-init-py"]
|
|
assert len(init_fails) == 0
|
|
|
|
def test_test_file_in_tests_dir_passes(self, plugin, project_with_seedgo):
|
|
"""test_*.py inside tests/ should pass the placement check."""
|
|
tests_dir = project_with_seedgo / "tests"
|
|
tests_dir.mkdir()
|
|
test_file = tests_dir / "test_module.py"
|
|
test_file.write_text("def test_something(): pass\n")
|
|
result = plugin.check(str(test_file))
|
|
failed = [c for c in result.checks if not c.passed]
|
|
placement_fails = [c for c in failed if c.name == "test-file-placement"]
|
|
assert len(placement_fails) == 0
|
|
|
|
def test_test_file_at_root_fails(self, plugin, project_with_seedgo):
|
|
"""test_*.py at the project root should fail the placement check."""
|
|
test_file = project_with_seedgo / "test_something.py"
|
|
test_file.write_text("def test_foo(): pass\n")
|
|
result = plugin.check(str(test_file))
|
|
failed = [c for c in result.checks if not c.passed]
|
|
placement_fails = [c for c in failed if c.name == "test-file-placement"]
|
|
assert len(placement_fails) == 1
|
|
|
|
def test_source_file_at_root_fails(self, plugin, project_with_seedgo):
|
|
"""A regular .py file at project root (not in allowed_root_files) should fail."""
|
|
src = project_with_seedgo / "my_module.py"
|
|
src.write_text("x = 1\n")
|
|
result = plugin.check(str(src))
|
|
failed = [c for c in result.checks if not c.passed]
|
|
root_fails = [c for c in failed if c.name == "root-python-file"]
|
|
assert len(root_fails) == 1
|
|
|
|
def test_allowed_root_file_passes(self, plugin, project_with_seedgo):
|
|
"""setup.py at root is allowed by default."""
|
|
setup = project_with_seedgo / "setup.py"
|
|
setup.write_text("from setuptools import setup\nsetup()\n")
|
|
result = plugin.check(str(setup))
|
|
failed = [c for c in result.checks if not c.passed]
|
|
root_fails = [c for c in failed if c.name == "root-python-file"]
|
|
assert len(root_fails) == 0
|
|
|
|
def test_custom_allowed_root_files(self, plugin, project_with_seedgo):
|
|
"""Custom allowed_root_files config should be respected."""
|
|
custom = project_with_seedgo / "fabfile.py"
|
|
custom.write_text("x = 1\n")
|
|
# With default config, fabfile.py is already in DEFAULT_ALLOWED_ROOT_FILES
|
|
# so let's test a truly custom file
|
|
myfile = project_with_seedgo / "myapp.py"
|
|
myfile.write_text("x = 1\n")
|
|
# Without config — should fail
|
|
result_no_config = plugin.check(str(myfile))
|
|
failed_no = [c for c in result_no_config.checks if not c.passed and c.name == "root-python-file"]
|
|
assert len(failed_no) == 1
|
|
# With custom config allowing myapp.py — should pass
|
|
result_with_config = plugin.check(str(myfile), config={"allowed_root_files": ["myapp.py"]})
|
|
failed_yes = [c for c in result_with_config.checks if not c.passed and c.name == "root-python-file"]
|
|
assert len(failed_yes) == 0
|
|
|
|
def test_missing_init_py_flagged(self, plugin, project_with_seedgo):
|
|
"""A package directory without __init__.py should be flagged."""
|
|
pkg = project_with_seedgo / "src" / "mypackage"
|
|
pkg.mkdir(parents=True)
|
|
# Note: no __init__.py
|
|
src = pkg / "module.py"
|
|
src.write_text("x = 1\n")
|
|
# Also create another .py so the dir clearly has sources
|
|
(pkg / "other.py").write_text("y = 1\n")
|
|
result = plugin.check(str(src))
|
|
failed = [c for c in result.checks if not c.passed]
|
|
init_fails = [c for c in failed if c.name == "missing-init-py"]
|
|
assert len(init_fails) == 1
|
|
|
|
def test_nonexistent_file_passes(self, plugin, tmp_path):
|
|
result = plugin.check(str(tmp_path / "ghost.py"))
|
|
assert result.passed is True
|
|
|
|
def test_conftest_at_root_passes(self, plugin, project_with_seedgo):
|
|
"""conftest.py is a standard root-level file."""
|
|
conf = project_with_seedgo / "conftest.py"
|
|
conf.write_text("import pytest\n")
|
|
result = plugin.check(str(conf))
|
|
failed = [c for c in result.checks if not c.passed]
|
|
root_fails = [c for c in failed if c.name == "root-python-file"]
|
|
assert len(root_fails) == 0
|
|
|
|
def test_result_has_plugin_name(self, plugin, tmp_path):
|
|
f = tmp_path / "x.py"
|
|
f.write_text("")
|
|
result = plugin.check(str(f))
|
|
assert result.plugin == "file-structure"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Plugin discovery integration tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestPluginDiscovery:
|
|
def test_all_plugins_discoverable(self):
|
|
"""All 5 plugins should be discovered as built-ins."""
|
|
from seedgo.discovery import discover_plugins
|
|
plugins = discover_plugins()
|
|
names = {p["name"] for p in plugins}
|
|
assert "no-bare-except" in names
|
|
assert "type-hints-required" in names
|
|
assert "docstring-coverage" in names
|
|
assert "function-length" in names
|
|
assert "file-structure" in names
|
|
|
|
def test_all_plugins_have_required_attributes(self):
|
|
"""Every discovered plugin must have PLUGIN_NAME, check(), and FILE_TYPES."""
|
|
from seedgo.discovery import discover_plugins
|
|
plugins = discover_plugins()
|
|
for p in plugins:
|
|
module = p["module"]
|
|
assert isinstance(getattr(module, "PLUGIN_NAME", None), str)
|
|
assert callable(getattr(module, "check", None))
|
|
assert isinstance(getattr(module, "FILE_TYPES", None), list)
|
|
|
|
def test_plugins_return_check_result(self, tmp_path):
|
|
"""Every plugin's check() must return a CheckResult instance."""
|
|
from seedgo.discovery import discover_plugins
|
|
from seedgo.models import CheckResult
|
|
|
|
# Create a minimal Python file to run checks on
|
|
test_file = tmp_path / "test_subject.py"
|
|
test_file.write_text("x = 1\n")
|
|
|
|
plugins = discover_plugins()
|
|
for p in plugins:
|
|
module = p["module"]
|
|
result = module.check(str(test_file))
|
|
assert isinstance(result, CheckResult), (
|
|
f"Plugin {p['name']} returned {type(result)} instead of CheckResult"
|
|
)
|
|
|
|
def test_runner_executes_all_plugins(self, tmp_path):
|
|
"""run_checks should execute all enabled plugins."""
|
|
import json
|
|
from seedgo.runner import run_checks
|
|
|
|
# Set up minimal project
|
|
seedgo_dir = tmp_path / ".seedgo"
|
|
seedgo_dir.mkdir()
|
|
(seedgo_dir / "plugins").mkdir()
|
|
|
|
config = {
|
|
"version": "1.0.0",
|
|
"plugins": {
|
|
"enabled": [
|
|
"no-bare-except",
|
|
"type-hints-required",
|
|
"docstring-coverage",
|
|
"function-length",
|
|
"file-structure",
|
|
],
|
|
"disabled": [],
|
|
"config": {},
|
|
},
|
|
"scoring": {"threshold": 75},
|
|
"paths": {"include": ["."], "exclude": []},
|
|
"overrides": [],
|
|
}
|
|
(seedgo_dir / "config.json").write_text(json.dumps(config))
|
|
|
|
# Create a simple Python source file
|
|
src = tmp_path / "hello.py"
|
|
src.write_text(
|
|
'"""A simple module."""\n'
|
|
"\n"
|
|
"def hello() -> str:\n"
|
|
' """Say hello."""\n'
|
|
" return 'Hello'\n"
|
|
)
|
|
|
|
results, overall = run_checks(str(tmp_path), files=[str(src)])
|
|
assert isinstance(results, list)
|
|
assert len(results) > 0
|
|
|
|
plugin_names_run = {r.plugin for r in results}
|
|
# At least some of our plugins should have run
|
|
assert len(plugin_names_run) > 0
|