feat(seedgo): add Seed Go portable standards framework v1.0.0

Complete implementation of a plugin-based code standards checker.

Core Framework:
- CheckResult/CheckItem/Severity data models
- Three-source plugin discovery (builtin, entry_points, local)
- Config system (.seedgo/config.json) with profiles and overrides
- Severity-weighted scoring (errors block, warnings degrade, info reports)

CLI:
- seedgo init — create project config
- seedgo check — run all enabled plugins
- seedgo list — show available plugins
- Three output formats: human, json, github (CI annotations)

5 Starter Plugins:
- no-bare-except, type-hints-required, docstring-coverage
- function-length (no linter equivalent)
- file-structure (no linter equivalent)

354 tests, all passing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
AIOSAI
2026-03-01 20:25:28 -08:00
co-authored by Claude Opus 4.6
parent eebd71780b
commit ba51e747cf
22 changed files with 5367 additions and 0 deletions
+42
View File
@@ -0,0 +1,42 @@
{
"version": "1.0.0",
"profile": null,
"plugins": {
"enabled": [
"no-bare-except",
"type-hints-required",
"docstring-coverage",
"function-length",
"file-structure"
],
"disabled": [],
"config": {
"function-length": {
"max_lines": 50
},
"file-structure": {
"allowed_root_files": [
"setup.py",
"conftest.py",
"manage.py"
]
}
}
},
"scoring": {
"threshold": 75,
"error_weight": 1.0,
"warning_weight": 0.5,
"info_weight": 0.0
},
"paths": {
"include": [
"src/seedgo/"
],
"exclude": [
"tests/",
"*.generated.py"
]
},
"overrides": []
}
+104
View File
@@ -114,6 +114,110 @@ except BranchNotFoundError as e:
print(f"Branch not found: {e}")
```
## Seed Go
> Linters enforce language rules. Seed Go enforces yours.
A portable, plugin-based code standards checker. Define your team's conventions
as simple Python functions — function length limits, docstring coverage, file
structure rules — and run them like any other linter. Zero external dependencies.
No API keys. Deterministic results.
### Quick Start
```bash
# Install
pip install aipass[seedgo]
# Initialize config in your project
seedgo init
# Run all enabled plugins
seedgo check
# List available plugins
seedgo list
```
### Write a Plugin in 60 Seconds
```python
# .seedgo/plugins/my_plugin.py
import re
from seedgo import CheckResult, CheckItem, Severity
PLUGIN_NAME = "no-bare-except"
PLUGIN_DESCRIPTION = "Flag bare except: clauses."
FILE_TYPES = ["*.py"]
_BARE_EXCEPT = re.compile(r"^\s*except\s*:\s*(#.*)?$")
def check(file_path: str, config: dict | None = None) -> CheckResult:
lines = open(file_path).readlines()
violations = [
CheckItem(
name="bare-except",
passed=False,
message=f"Bare except: at line {i+1}",
severity=Severity.WARNING,
line=i + 1,
fix_hint="Use `except Exception:` instead.",
)
for i, line in enumerate(lines)
if _BARE_EXCEPT.match(line)
]
return CheckResult(
plugin=PLUGIN_NAME,
passed=not violations,
checks=violations or [CheckItem(name="bare-except", passed=True,
message="No bare excepts found.",
severity=Severity.WARNING)],
file_path=file_path,
)
```
### Starter Plugins (5 Built-in)
| Plugin | What it checks | Linter equivalent? |
|---|---|---|
| `no-bare-except` | Bare `except:` clauses | ruff E722 (but with fix hints) |
| `type-hints-required` | Missing type annotations | mypy (but project-configurable) |
| `docstring-coverage` | Missing module/function docstrings | pydocstyle (but simpler) |
| `function-length` | Functions exceeding N lines | **No linter equivalent** |
| `file-structure` | Forbidden files in root/dirs | **No linter equivalent** |
Two of the five check things no standard linter can enforce.
### vs. Pre-Commit
Seed Go is complementary to pre-commit, not a replacement. Run it as a hook:
```yaml
# .pre-commit-config.yaml
- repo: local
hooks:
- id: seedgo
name: Seed Go standards check
entry: seedgo check
language: system
pass_filenames: false
```
Or standalone in CI:
```bash
seedgo check --format github # GitHub Actions annotations
seedgo check --format json # machine-readable output
```
### Honest About What It Is
- Deterministic checks — same input always produces same output
- No AI in the validation loop — no API keys, no network calls
- Plugin contract is stable: `check(file_path, config) -> CheckResult`
- Scores are weighted: errors block (weight 1.0), warnings degrade (0.5),
info is reported only (0.0). Threshold is configurable (default: 75/100).
## License
MIT
+7
View File
@@ -36,12 +36,19 @@ Repository = "https://github.com/AIOSAI/AIPass"
trinity = [
"trinity-pattern>=1.0.0",
]
seedgo = []
dev = [
"pytest",
"pytest-cov",
"ruff",
]
[project.scripts]
seedgo = "seedgo.cli:main"
[tool.hatch.build.targets.wheel]
packages = ["src/aipass", "src/seedgo"]
[tool.pytest.ini_options]
testpaths = ["tests"]
+6
View File
@@ -0,0 +1,6 @@
{
"extraPaths": ["src"],
"pythonVersion": "3.10",
"reportMissingImports": "error",
"reportAttributeAccessIssue": "error"
}
+49
View File
@@ -0,0 +1,49 @@
"""
Seed Go — Portable, Plugin-Based Code Standards Checker
A standalone framework for defining and enforcing code quality standards
via a plugin system. Zero AIPass dependencies — works with just:
pip install seed-go
Public API:
CheckResult — return type for all plugin check() functions
CheckItem — one individual check within a plugin result
Severity — ERROR / WARNING / INFO severity levels
discover_plugins() — find plugins from all sources
load_config() — load and resolve .seedgo/config.json
Plugin contract (minimal plugin in ~20 lines):
PLUGIN_NAME = "my-plugin"
PLUGIN_DESCRIPTION = "What this plugin checks"
FILE_TYPES = ["*.py"]
def check(file_path: str, config: dict | None = None) -> CheckResult:
...
Example:
from seedgo import discover_plugins, load_config, CheckResult
plugins = discover_plugins(project_root="/path/to/project")
config = load_config("/path/to/project")
"""
__version__ = "1.0.0"
__author__ = "AIPass"
from .models import CheckResult, CheckItem, Severity
from .discovery import discover_plugins
from .config import load_config
from .runner import run_checks
__all__ = [
"CheckResult",
"CheckItem",
"Severity",
"discover_plugins",
"load_config",
"run_checks",
"__version__",
]
+120
View File
@@ -0,0 +1,120 @@
"""
Seed Go Bypass Utility
Shared bypass logic extracted from Seed's ~15 duplicated is_bypassed() implementations.
Plugins call this from seedgo.bypass — no per-plugin copies needed.
Bypass rules live in .seedgo/bypass.json and support:
- Entire standard bypass for a file (no lines specified)
- Line-specific bypass for targeted suppressions
Bypass config format (.seedgo/bypass.json):
{
"version": "1.0.0",
"bypass": [
{
"file": "src/legacy.py",
"plugin": "type-hints-required",
"reason": "Legacy code — rewrite planned for Q2"
},
{
"file": "src/utils.py",
"plugin": "no-bare-except",
"lines": [42, 78],
"reason": "Generic handler for external API calls"
}
]
}
"""
import json
from pathlib import Path
def load_bypass_rules(project_root: str) -> list[dict]:
"""Load bypass rules from .seedgo/bypass.json.
Args:
project_root: Path to the project root (directory containing .seedgo/).
Returns:
List of bypass rule dicts. Empty list if bypass.json does not exist.
"""
bypass_path = Path(project_root) / ".seedgo" / "bypass.json"
if not bypass_path.exists():
return []
try:
with open(bypass_path, "r", encoding="utf-8") as f:
data = json.load(f)
except (json.JSONDecodeError, OSError):
return []
return data.get("bypass", [])
def is_bypassed(
file_path: str,
plugin: str,
line: int | None = None,
bypass_rules: list[dict] | None = None,
project_root: str | None = None,
) -> bool:
"""Check if a violation should be bypassed.
This is the single shared implementation — plugins import this instead of
maintaining their own copy. Eliminates the duplication pattern seen in Seed.
Matching logic:
- A rule matches when BOTH file and plugin match (or are omitted in the rule).
- If the rule has no lines[], it bypasses the entire plugin for that file.
- If the rule has lines[], it only bypasses if the given line is in the list.
Args:
file_path: Absolute (or relative) path to the file being checked.
plugin: Plugin name to check bypass for (e.g., "no-bare-except").
line: Optional line number for line-specific bypass. If None, only
whole-file bypass rules are matched.
bypass_rules: Pre-loaded list of bypass rule dicts from load_bypass_rules().
If None or empty, returns False (nothing bypassed).
project_root: Optional project root for computing relative paths. When
provided, file_path is compared as a relative path against
the rule's "file" field.
Returns:
True if the violation should be suppressed, False otherwise.
"""
if not bypass_rules:
return False
# Compute relative path for matching against rule "file" fields
rel_path = file_path
if project_root:
try:
rel_path = str(Path(file_path).relative_to(project_root))
except ValueError:
pass # file_path not under project_root — use as-is
for rule in bypass_rules:
# Plugin match: rule must name this plugin (or have no plugin filter)
rule_plugin = rule.get("plugin", "")
if rule_plugin and rule_plugin != plugin:
continue
# File match: rule must match this file (or have no file filter)
rule_file = rule.get("file", "")
if rule_file and rule_file != rel_path:
continue
# Line-specific bypass
rule_lines = rule.get("lines", [])
if rule_lines:
if line is not None and line in rule_lines:
return True
# Has line restrictions but our line doesn't match — keep looking
continue
# No line restriction — entire plugin/file is bypassed
return True
return False
+276
View File
@@ -0,0 +1,276 @@
"""
Seed Go Command-Line Interface
Entry point: `seedgo` (wired via pyproject.toml console_scripts to cli.main).
Commands:
seedgo init [--profile NAME]
Initialize Seed Go in the current directory. Creates .seedgo/config.json
and .seedgo/plugins/ via create_default_config(). Safe to run from any
directory — does NOT require an existing .seedgo/ directory.
seedgo check [FILE ...] [--format FORMAT] [--threshold N]
Run checks on specified files, or on all project files if none given.
Exits 0 if all checks pass, 1 if any fail.
--format: human (default), json, github
--threshold: Override the pass threshold (0-100)
seedgo list
List all discovered plugins (from all sources). Shows name, description,
source, and file types. Useful for verifying plugins are loaded correctly.
All commands use find_project_root() to locate the nearest .seedgo/ directory
walking up from cwd. `init` is the only command that works without one.
Zero external dependencies — stdlib only (argparse, sys, os, pathlib).
"""
import argparse
import os
import sys
from pathlib import Path
from .config import ConfigError, create_default_config, find_project_root
from .discovery import discover_plugins
from .reporter import report_results
from .runner import run_checks
def main() -> None:
"""Entry point for the `seedgo` CLI command.
Parses command-line arguments and dispatches to the appropriate handler.
All errors are caught and reported cleanly — never shows a raw traceback.
Exit codes:
0 — success (or checks passed)
1 — failure (checks failed, config error, or usage error)
"""
parser = argparse.ArgumentParser(
prog="seedgo",
description="Portable, plugin-based code standards checker.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=(
"Examples:\n"
" seedgo init # Set up .seedgo/ in current directory\n"
" seedgo init --profile python-basic\n"
" seedgo check # Check all project files\n"
" seedgo check src/main.py # Check a specific file\n"
" seedgo check --format json # Machine-readable output\n"
" seedgo check --format github # GitHub Actions annotations\n"
" seedgo list # Show available plugins\n"
),
)
subparsers = parser.add_subparsers(dest="command", metavar="COMMAND")
# -----------------------------------------------------------------------
# seedgo init
# -----------------------------------------------------------------------
init_parser = subparsers.add_parser(
"init",
help="Initialize Seed Go in the current directory.",
description="Creates .seedgo/config.json and .seedgo/plugins/ in the current directory.",
)
init_parser.add_argument(
"--profile",
default=None,
metavar="NAME",
help="Starter profile name to embed in config (e.g., python-basic, python-strict).",
)
# -----------------------------------------------------------------------
# seedgo check
# -----------------------------------------------------------------------
check_parser = subparsers.add_parser(
"check",
help="Run checks on files (or all project files if none specified).",
description="Run all discovered plugins against the specified files.",
)
check_parser.add_argument(
"files",
nargs="*",
metavar="FILE",
help="Files to check. If omitted, checks all files under the project root.",
)
check_parser.add_argument(
"--format",
choices=["human", "json", "github"],
default="human",
help="Output format: human (default), json, or github (Actions annotations).",
)
check_parser.add_argument(
"--threshold",
type=int,
default=None,
metavar="N",
help="Override the pass threshold (0-100). Defaults to value in config (75).",
)
check_parser.add_argument(
"--plugin",
action="append",
metavar="NAME",
dest="plugins",
help="Run only this plugin (may be repeated for multiple plugins).",
)
# -----------------------------------------------------------------------
# seedgo list
# -----------------------------------------------------------------------
subparsers.add_parser(
"list",
help="List all available plugins discovered from all sources.",
description="Discovers and lists plugins from built-ins, installed packages, and .seedgo/plugins/.",
)
# -----------------------------------------------------------------------
# Dispatch
# -----------------------------------------------------------------------
args = parser.parse_args()
if args.command is None:
parser.print_help()
sys.exit(0)
if args.command == "init":
_cmd_init(args)
elif args.command == "check":
_cmd_check(args)
elif args.command == "list":
_cmd_list(args)
else:
parser.print_help()
sys.exit(1)
# ---------------------------------------------------------------------------
# Command implementations
# ---------------------------------------------------------------------------
def _cmd_init(args: argparse.Namespace) -> None:
"""Handle `seedgo init [--profile NAME]`.
Creates .seedgo/config.json and .seedgo/plugins/ in the current working
directory. Prints a success message. Exits 1 on error.
"""
project_root = os.getcwd()
profile = getattr(args, "profile", None)
try:
config_path = create_default_config(project_root, profile=profile)
print(f"Seed Go initialized.")
print(f" Config: {config_path}")
print(f" Plugins: {str(Path(config_path).parent / 'plugins')}")
if profile:
print(f" Profile: {profile}")
print("")
print("Next steps:")
print(" 1. Add plugins to .seedgo/plugins/")
print(" 2. Run: seedgo check")
except ConfigError as exc:
print(f"Error: {exc}", file=sys.stderr)
sys.exit(1)
def _cmd_check(args: argparse.Namespace) -> None:
"""Handle `seedgo check [FILE ...] [--format FORMAT] [--threshold N]`.
Locates the project root, runs all applicable checks, formats and prints
the output. Exits 0 if checks pass, 1 if they fail.
"""
cwd = os.getcwd()
# Locate project root (walk up from cwd looking for .seedgo/)
project_root = find_project_root(cwd)
if project_root is None:
print(
"Error: No .seedgo/ directory found. Run `seedgo init` first.",
file=sys.stderr,
)
sys.exit(1)
# Resolve explicit file arguments to absolute paths
files: list[str] | None = None
if args.files:
files = []
for f in args.files:
fp = Path(f)
if not fp.is_absolute():
fp = Path(cwd) / fp
if not fp.exists():
print(f"Warning: File not found: {fp}", file=sys.stderr)
continue
files.append(str(fp.resolve()))
if not files:
print("Error: None of the specified files exist.", file=sys.stderr)
sys.exit(1)
try:
results, overall = run_checks(
project_root=project_root,
files=files,
plugins=getattr(args, "plugins", None),
)
# Apply CLI threshold override AFTER run_checks (re-evaluate pass)
if args.threshold is not None:
overall["threshold"] = args.threshold
error_count = overall.get("error_count", 0)
overall["passed"] = overall["overall_score"] >= args.threshold and error_count == 0
output = report_results(results, overall, format=args.format)
print(output)
except ConfigError as exc:
print(f"Config error: {exc}", file=sys.stderr)
sys.exit(1)
except Exception as exc:
print(f"Unexpected error: {exc}", file=sys.stderr)
sys.exit(1)
sys.exit(0 if overall.get("passed", True) else 1)
def _cmd_list(_args: argparse.Namespace) -> None:
"""Handle `seedgo list`.
Discovers all plugins and prints a formatted table showing:
name, description, source, file types.
Exits 0 always (listing is informational).
"""
cwd = os.getcwd()
project_root = find_project_root(cwd)
# Discover plugins (project_root may be None if no .seedgo/ found)
plugins = discover_plugins(project_root)
if not plugins:
print("No plugins found.")
print("")
print("Add plugins to .seedgo/plugins/ or install plugin packages.")
sys.exit(0)
print(f"Found {len(plugins)} plugin(s):\n")
col_name = max(len(p["name"]) for p in plugins) + 2
col_source = max(len(p["source"]) for p in plugins) + 2
header = (
f" {'NAME':<{col_name}}{'SOURCE':<{col_source}}{'FILE TYPES':<20} DESCRIPTION"
)
print(header)
print(" " + "-" * (len(header) - 2))
for plugin in plugins:
module = plugin["module"]
name = plugin["name"]
source = plugin["source"]
description = getattr(module, "PLUGIN_DESCRIPTION", "")
file_types = getattr(module, "FILE_TYPES", ["*"])
types_str = ", ".join(file_types)
print(f" {name:<{col_name}}{source:<{col_source}}{types_str:<20} {description}")
sys.exit(0)
+236
View File
@@ -0,0 +1,236 @@
"""
Seed Go Configuration System
Handles loading, merging, and resolving .seedgo/config.json project configs.
Provides defaults for all settings so seedgo works with zero configuration.
Config resolution order (later sources win):
1. DEFAULT_CONFIG (hardcoded here)
2. Profile plugins (named preset, if configured)
3. Project .seedgo/config.json
4. Per-directory overrides (overrides[] entries matched by path prefix)
5. CLI flags (applied by CLI layer, not here)
"""
import json
from pathlib import Path
from .exceptions import ConfigError
DEFAULT_CONFIG: dict = {
"version": "1.0.0",
"profile": None,
"plugins": {
"enabled": [],
"disabled": [],
"config": {},
},
"scoring": {
"threshold": 75,
"error_weight": 1.0,
"warning_weight": 0.5,
"info_weight": 0.0,
},
"paths": {
"include": ["."],
"exclude": [],
},
"overrides": [],
}
def load_config(project_root: str) -> dict:
"""Load and resolve config from .seedgo/config.json.
Merges user config over defaults. If a profile is specified, the profile
is merged between defaults and user config so user config always wins.
Args:
project_root: Path to the project root (directory containing .seedgo/).
Returns:
Fully resolved config dict ready for use.
Raises:
ConfigError: If .seedgo/config.json exists but cannot be parsed.
"""
import copy
config_path = Path(project_root) / ".seedgo" / "config.json"
if not config_path.exists():
return copy.deepcopy(DEFAULT_CONFIG)
try:
with open(config_path, "r", encoding="utf-8") as f:
user_config = json.load(f)
except json.JSONDecodeError as e:
raise ConfigError(f"Invalid JSON in {config_path}: {e}") from e
except OSError as e:
raise ConfigError(f"Cannot read {config_path}: {e}") from e
# Start from defaults
resolved = copy.deepcopy(DEFAULT_CONFIG)
_deep_merge(resolved, user_config)
# Profile: merge between defaults and user config so user always wins
if resolved.get("profile"):
try:
profile = _load_profile(resolved["profile"], project_root)
# Re-start: defaults -> profile -> user config
resolved = copy.deepcopy(DEFAULT_CONFIG)
_deep_merge(resolved, profile)
_deep_merge(resolved, user_config)
except ConfigError:
# Profile not found — continue without it
pass
return resolved
def resolve_file_config(config: dict, file_path: str, project_root: str) -> dict:
"""Apply per-directory overrides for a specific file.
Matches the file's relative path against each override's paths list.
All matching overrides are applied in order (last match wins for conflicts).
Args:
config: Fully resolved project config from load_config().
file_path: Absolute path to the file being checked.
project_root: Project root for computing relative paths.
Returns:
Config dict with applicable overrides merged in.
"""
import copy
try:
rel_path = str(Path(file_path).relative_to(project_root))
except ValueError:
rel_path = file_path
resolved = copy.deepcopy(config)
for override in config.get("overrides", []):
paths = override.get("paths", [])
if any(rel_path.startswith(p) for p in paths):
_deep_merge(resolved, override)
return resolved
def find_project_root(start_path: str) -> str | None:
"""Walk up the directory tree looking for a .seedgo/ directory.
Args:
start_path: Starting file or directory path to search from.
Returns:
Absolute path string of the project root directory, or None if not found.
"""
current = Path(start_path).resolve()
# If start_path is a file, begin from its parent directory
if current.is_file():
current = current.parent
for directory in [current, *current.parents]:
if (directory / ".seedgo").is_dir():
return str(directory)
return None
def create_default_config(project_root: str, profile: str | None = None) -> str:
"""Create .seedgo/config.json with defaults (for `seedgo init`).
Creates the .seedgo/ directory and plugins/ subdirectory if they do not exist.
Args:
project_root: Directory where .seedgo/ will be created.
profile: Optional profile name to embed in the new config.
Returns:
Path to the created config file as a string.
Raises:
ConfigError: If the config file already exists or cannot be written.
"""
import copy
seedgo_dir = Path(project_root) / ".seedgo"
plugins_dir = seedgo_dir / "plugins"
config_path = seedgo_dir / "config.json"
if config_path.exists():
raise ConfigError(f"Config already exists at {config_path}. Remove it first or edit it directly.")
try:
seedgo_dir.mkdir(parents=True, exist_ok=True)
plugins_dir.mkdir(exist_ok=True)
except OSError as e:
raise ConfigError(f"Cannot create .seedgo/ directory: {e}") from e
config = copy.deepcopy(DEFAULT_CONFIG)
if profile:
config["profile"] = profile
try:
with open(config_path, "w", encoding="utf-8") as f:
json.dump(config, f, indent=2)
f.write("\n")
except OSError as e:
raise ConfigError(f"Cannot write config to {config_path}: {e}") from e
return str(config_path)
def _deep_merge(base: dict, override: dict) -> None:
"""Merge override into base in-place. Dicts are merged recursively; other types replace.
Args:
base: The dict to merge into (modified in-place).
override: The dict whose values take priority.
"""
for key, value in override.items():
if key in base and isinstance(base[key], dict) and isinstance(value, dict):
_deep_merge(base[key], value)
else:
base[key] = value
def _load_profile(profile_name: str, project_root: str | None = None) -> dict:
"""Load a named profile from the profiles search path.
Searches in order:
1. Local project .seedgo/profiles/<name>.json
2. Built-in seedgo/profiles/<name>.json (not yet shipped)
Args:
profile_name: Name of the profile (e.g., "python-strict").
project_root: Optional project root for local profile lookup.
Returns:
Profile config dict (subset of full config).
Raises:
ConfigError: If the profile cannot be found or parsed.
"""
search_paths: list[Path] = []
if project_root:
search_paths.append(Path(project_root) / ".seedgo" / "profiles" / f"{profile_name}.json")
# Built-in profiles directory (alongside this file)
builtin_profiles = Path(__file__).parent / "profiles"
search_paths.append(builtin_profiles / f"{profile_name}.json")
for profile_path in search_paths:
if profile_path.exists():
try:
with open(profile_path, "r", encoding="utf-8") as f:
return json.load(f)
except json.JSONDecodeError as e:
raise ConfigError(f"Invalid JSON in profile {profile_path}: {e}") from e
raise ConfigError(f"Profile '{profile_name}' not found. Checked: {[str(p) for p in search_paths]}")
+179
View File
@@ -0,0 +1,179 @@
"""
Seed Go Plugin Discovery
Discovers plugins from three sources in priority order:
1. Built-in plugins shipped with seedgo (seedgo/plugins/)
2. Installed package plugins via pip entry points (group="seedgo.plugins")
3. Local project plugins in .seedgo/plugins/
Later sources override earlier ones on name collision — local plugins always win.
This allows projects to override built-in or installed plugins with their own versions.
Duck typing contract: a .py file is a plugin if it has:
- PLUGIN_NAME (str): unique kebab-case identifier
- check (callable): the check function
"""
import importlib
import importlib.metadata
import importlib.util
from pathlib import Path
def discover_plugins(project_root: str | None = None) -> list[dict]:
"""Discover all available plugins from all sources.
Searches built-ins, installed packages, and local project plugins.
Deduplicates by plugin name with last-wins semantics (local overrides
installed overrides builtin).
Args:
project_root: Optional path to project root. When provided, also
searches .seedgo/plugins/ for local project plugins.
Returns:
List of plugin descriptor dicts, each with keys:
- "name" (str): plugin's PLUGIN_NAME value
- "module" (module): the loaded module object
- "source" (str): "builtin", "package:<dist-name>", or "local"
- "path" (str): absolute path to the plugin file
"""
plugins: list[dict] = []
# Source 1: Built-in plugins (ships empty in production)
plugins.extend(_discover_builtin())
# Source 2: Installed package plugins via entry points
plugins.extend(_discover_entry_points())
# Source 3: Local project plugins (.seedgo/plugins/)
if project_root:
plugins.extend(_discover_local(project_root))
# Deduplicate by name — last wins (local > installed > builtin)
seen: dict[str, dict] = {}
for plugin in plugins:
seen[plugin["name"]] = plugin
return list(seen.values())
def _discover_builtin() -> list[dict]:
"""Discover plugins shipped inside the seedgo package.
Scans the seedgo/plugins/ directory. In production this directory ships
empty — built-in examples live in examples/plugins/ (not installed).
Returns:
List of plugin descriptor dicts with source="builtin".
"""
builtin_dir = Path(__file__).parent / "plugins"
return _scan_directory(builtin_dir, source="builtin")
def _discover_entry_points() -> list[dict]:
"""Discover plugins installed as pip packages.
Third-party plugin packages register under the "seedgo.plugins" entry
point group in their pyproject.toml:
[project.entry-points."seedgo.plugins"]
no_bare_except = "seedgo_python_basics.no_bare_except"
Returns:
List of plugin descriptor dicts with source="package:<dist-name>".
"""
plugins: list[dict] = []
try:
eps = importlib.metadata.entry_points(group="seedgo.plugins")
except Exception:
return plugins
for ep in eps:
try:
module = ep.load()
if hasattr(module, "PLUGIN_NAME") and callable(getattr(module, "check", None)):
dist_name = ep.dist.name if ep.dist else "unknown"
file_path = str(Path(module.__file__)) if getattr(module, "__file__", None) else ""
plugins.append({
"name": module.PLUGIN_NAME,
"module": module,
"source": f"package:{dist_name}",
"path": file_path,
})
except Exception:
pass # Skip broken entry points silently
return plugins
def _discover_local(project_root: str) -> list[dict]:
"""Discover plugins in the project's .seedgo/plugins/ directory.
Local plugins override built-in and installed plugins of the same name,
giving projects full control over their standards checking.
Args:
project_root: Absolute path to the project root.
Returns:
List of plugin descriptor dicts with source="local".
"""
local_dir = Path(project_root) / ".seedgo" / "plugins"
return _scan_directory(local_dir, source="local")
def _scan_directory(directory: Path, source: str) -> list[dict]:
"""Scan a directory for plugin files using duck typing.
A file is treated as a plugin if it:
- Has a .py extension
- Does not start with underscore (skips __init__.py, _helpers.py, etc.)
- Defines PLUGIN_NAME (str attribute)
- Defines check (callable)
Plugins that fail to import are silently skipped to avoid one broken
plugin preventing all other plugins from loading.
Args:
directory: Directory to scan. Returns empty list if it does not exist.
source: Source label to embed in descriptors ("builtin", "local", etc.).
Returns:
List of plugin descriptor dicts sorted by plugin name for determinism.
"""
plugins: list[dict] = []
if not directory.exists():
return plugins
if not directory.is_dir():
return plugins
for file_path in sorted(directory.glob("*.py")):
if file_path.name.startswith("_"):
continue
try:
spec = importlib.util.spec_from_file_location(file_path.stem, file_path)
if spec is None or spec.loader is None:
continue
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module) # type: ignore[attr-defined]
# Duck typing: needs PLUGIN_NAME (str) + check (callable)
plugin_name = getattr(module, "PLUGIN_NAME", None)
check_fn = getattr(module, "check", None)
if isinstance(plugin_name, str) and callable(check_fn):
plugins.append({
"name": plugin_name,
"module": module,
"source": source,
"path": str(file_path),
})
except Exception:
pass # Skip broken plugins silently
return plugins
+39
View File
@@ -0,0 +1,39 @@
"""
Seed Go Exception Hierarchy
Defines all custom exceptions for the seedgo package.
All exceptions inherit from SeedGoError for easy catch-all handling.
"""
class SeedGoError(Exception):
"""Base exception for all seedgo errors.
Catch this to handle any seedgo-specific failure without caring
about the specific cause.
"""
class ConfigError(SeedGoError):
"""Raised when config loading or parsing fails.
Covers missing required fields, invalid JSON, schema violations,
and unresolvable profile references.
"""
class PluginError(SeedGoError):
"""Raised when a plugin fails to load or execute.
Covers import failures, missing required attributes (PLUGIN_NAME,
check function), and runtime errors during check execution.
"""
class DiscoveryError(SeedGoError):
"""Raised when plugin discovery encounters an unrecoverable error.
Note: Individual broken plugins are silently skipped during discovery.
This exception is only raised when the discovery process itself fails
(e.g., the plugins directory is unreadable).
"""
+76
View File
@@ -0,0 +1,76 @@
"""
Seed Go Core Data Models
Defines the plugin contract types: Severity, CheckItem, and CheckResult.
All plugin check() functions must return a CheckResult instance.
These are pure dataclasses with no external dependencies — safe to import
anywhere without pulling in AIPass or other infrastructure.
"""
from dataclasses import dataclass, field
from enum import Enum
from typing import Optional
class Severity(Enum):
"""Severity levels for individual check items.
Determines how a failed check impacts the overall score and pass/fail:
- ERROR: Full score deduction. Any unresolved error blocks pass regardless of score.
- WARNING: Half score deduction (configurable). Degrades score but does not block pass.
- INFO: No score deduction. Informational only — shown in output, zero score impact.
"""
ERROR = "error"
WARNING = "warning"
INFO = "info"
@dataclass
class CheckItem:
"""One individual check within a plugin result.
Represents a single rule evaluation. A plugin may return many CheckItems,
one per rule or per violation found in the file.
Attributes:
name: Short identifier for this check (e.g., "bare-except", "missing-type-hint").
passed: Whether this specific check passed.
message: Human-readable explanation of the result or violation.
severity: How serious the violation is. Defaults to ERROR.
line: Source line number where the violation was found, if applicable.
fix_hint: Actionable suggestion for how to resolve the violation.
"""
name: str
passed: bool
message: str
severity: Severity = Severity.ERROR
line: Optional[int] = None
fix_hint: Optional[str] = None
@dataclass
class CheckResult:
"""Return type for all plugin check() functions.
Every plugin must return exactly one CheckResult per file checked.
The result aggregates all individual CheckItem results and provides
an overall pass/fail verdict and score.
Attributes:
plugin: The plugin's PLUGIN_NAME string (e.g., "no-bare-except").
passed: Overall pass/fail for this plugin against this file.
checks: List of individual check results. Empty list means no checks ran.
score: Weighted score from 0-100. Calculated by the scoring engine.
file_path: Absolute path to the file that was checked.
metadata: Plugin-defined extra data (e.g., AST stats, timing). Serializable to JSON.
"""
plugin: str
passed: bool
checks: list[CheckItem] = field(default_factory=list)
score: int = 0
file_path: str = ""
metadata: dict = field(default_factory=dict)
+9
View File
@@ -0,0 +1,9 @@
"""
Seed Go Built-in Plugins
This directory ships empty in production. Built-in plugin examples live in
examples/plugins/ (not installed with the package).
Third-party plugins register via the "seedgo.plugins" entry_points group.
Local project plugins live in .seedgo/plugins/.
"""
+170
View File
@@ -0,0 +1,170 @@
"""
Seed Go Plugin: docstring-coverage
Checks that public functions and classes have docstrings. Docstrings are the
primary mechanism for in-code documentation and are used by help(), IDEs, and
documentation generators like Sphinx.
Skips:
- Private functions/classes (starting with _)
- Very short functions (< 3 lines of body statements)
- __init__ and other dunders (covered by class docstring)
Uses the `ast` module for reliable parsing. Reports at INFO severity — this
is a suggestion, not a blocking violation.
"""
import ast
from pathlib import Path
from seedgo.models import CheckItem, CheckResult, Severity
PLUGIN_NAME = "docstring-coverage"
PLUGIN_DESCRIPTION = "Public functions and classes should have docstrings."
FILE_TYPES = ["*.py"]
PLUGIN_VERSION = "1.0.0"
# Minimum number of body statements to require a docstring
_MIN_BODY_LINES = 3
def check(file_path: str, config: dict | None = None) -> CheckResult:
"""Check a Python file for public functions and classes missing docstrings.
Parses the file with ast and checks the first statement of each public
function and class body. If the first statement is not a string literal,
the docstring is considered missing.
Args:
file_path: Absolute path to the Python file to check.
config: Optional plugin config dict (unused by this plugin).
Returns:
CheckResult with INFO-severity items for missing docstrings.
"""
_ = config # Part of plugin interface contract
try:
source = Path(file_path).read_text(encoding="utf-8", errors="replace")
except OSError:
return CheckResult(
plugin=PLUGIN_NAME,
passed=True,
checks=[],
file_path=file_path,
metadata={"skipped": True, "reason": "file_read_error"},
)
if not source.strip():
return CheckResult(
plugin=PLUGIN_NAME,
passed=True,
checks=[CheckItem(
name="docstring-coverage",
passed=True,
message="Empty file — nothing to check.",
severity=Severity.INFO,
)],
file_path=file_path,
)
try:
tree = ast.parse(source, filename=file_path)
except SyntaxError:
return CheckResult(
plugin=PLUGIN_NAME,
passed=True,
checks=[],
file_path=file_path,
metadata={"skipped": True, "reason": "syntax_error"},
)
violations: list[CheckItem] = []
for node in ast.walk(tree):
if isinstance(node, ast.ClassDef):
if _should_check_class(node):
if not _has_docstring(node):
violations.append(CheckItem(
name="docstring-coverage",
passed=False,
message=(
f"Class `{node.name}` at line {node.lineno} "
f"is missing a docstring."
),
severity=Severity.INFO,
line=node.lineno,
fix_hint=f'Add a docstring as the first statement: """Describe {node.name} here."""',
))
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
if _should_check_function(node):
if not _has_docstring(node):
violations.append(CheckItem(
name="docstring-coverage",
passed=False,
message=(
f"Function `{node.name}` at line {node.lineno} "
f"is missing a docstring."
),
severity=Severity.INFO,
line=node.lineno,
fix_hint=f'Add a docstring as the first statement: """Describe what {node.name} does."""',
))
if violations:
# INFO violations don't cause overall failure
passed = True
checks = violations
else:
passed = True
checks = [
CheckItem(
name="docstring-coverage",
passed=True,
message="All public functions and classes have docstrings.",
severity=Severity.INFO,
)
]
return CheckResult(
plugin=PLUGIN_NAME,
passed=passed,
checks=checks,
file_path=file_path,
metadata={"violations_found": len(violations)},
)
def _has_docstring(node: ast.AST) -> bool:
"""Return True if the node's body starts with a string literal (docstring)."""
body = getattr(node, "body", [])
if not body:
return False
first = body[0]
if isinstance(first, ast.Expr) and isinstance(first.value, ast.Constant):
return isinstance(first.value.value, str)
return False
def _should_check_function(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
"""Return True if this function should be checked for a docstring."""
# Skip private functions
if node.name.startswith("_"):
return False
# Skip very short functions (fewer than _MIN_BODY_LINES body statements)
# Count non-docstring statements
body = node.body
if len(body) < _MIN_BODY_LINES:
return False
return True
def _should_check_class(node: ast.ClassDef) -> bool:
"""Return True if this class should be checked for a docstring."""
# Skip private classes
if node.name.startswith("_"):
return False
return True
+294
View File
@@ -0,0 +1,294 @@
"""
Seed Go Plugin: file-structure
Enforces directory conventions for Python projects. This is Seed Go's killer
feature — no traditional linter (ruff, pylint, flake8) checks WHERE files are
placed. They only check file contents.
Checks performed:
a. Test files (test_*.py or *_test.py) should be in a tests/ directory,
not placed directly at the project root.
b. Python source files should not live directly at the project root
(except for well-known root-level files like setup.py, conftest.py).
c. Source packages (directories with .py files) should have __init__.py.
Config example:
{
"plugins": {
"config": {
"file-structure": {
"allowed_root_files": ["setup.py", "conftest.py", "manage.py"]
}
}
}
}
Note: This plugin receives one file at a time from the runner. It checks the
given file's placement relative to the project root, which it infers from the
path. For checks that require directory context (like __init__.py presence),
it inspects the file's parent directory.
"""
from pathlib import Path
from seedgo.models import CheckItem, CheckResult, Severity
PLUGIN_NAME = "file-structure"
PLUGIN_DESCRIPTION = "Enforce directory conventions: test placement, root files, package __init__.py."
FILE_TYPES = ["*.py"]
PLUGIN_VERSION = "1.0.0"
# Files commonly allowed at the project root
DEFAULT_ALLOWED_ROOT_FILES = [
"setup.py",
"conftest.py",
"manage.py",
"wsgi.py",
"asgi.py",
"fabfile.py",
"noxfile.py",
"tasks.py",
]
# Directories that are considered "test directories" — test files are OK here
TEST_DIR_NAMES = {"tests", "test", "testing", "spec"}
# Directories that should never trigger root-file violations even if named differently
SKIP_DIR_NAMES = {
".seedgo",
".git",
"__pycache__",
"node_modules",
".tox",
".venv",
"venv",
"env",
".eggs",
"dist",
"build",
}
def check(file_path: str, config: dict | None = None) -> CheckResult:
"""Check a Python file's placement against project directory conventions.
Inspects the file path relative to the inferred project root and flags
violations of standard Python project structure conventions.
Args:
file_path: Absolute path to the Python file to check.
config: Optional dict. Supported keys:
- "allowed_root_files": list[str] of filenames OK at root.
Returns:
CheckResult with ERROR-severity items for structural violations.
"""
cfg = config or {}
allowed_root_files: list[str] = cfg.get("allowed_root_files", DEFAULT_ALLOWED_ROOT_FILES)
path = Path(file_path).resolve()
# Skip files that don't exist (e.g., deleted during scan)
if not path.exists():
return CheckResult(
plugin=PLUGIN_NAME,
passed=True,
checks=[],
file_path=file_path,
metadata={"skipped": True, "reason": "file_not_found"},
)
# Skip files in ignored directories
for part in path.parts:
if part in SKIP_DIR_NAMES:
return CheckResult(
plugin=PLUGIN_NAME,
passed=True,
checks=[],
file_path=file_path,
metadata={"skipped": True, "reason": "excluded_directory"},
)
violations: list[CheckItem] = []
file_name = path.name
parent_dir = path.parent
parent_name = parent_dir.name
# -------------------------------------------------------------------
# Infer project root: walk up to find a known project root marker.
# Markers: .seedgo/, setup.py, pyproject.toml, setup.cfg, .git/
# -------------------------------------------------------------------
project_root = _find_project_root(path)
if project_root is None:
# Cannot determine project root — skip structural checks
return CheckResult(
plugin=PLUGIN_NAME,
passed=True,
checks=[],
file_path=file_path,
metadata={"skipped": True, "reason": "no_project_root_found"},
)
try:
rel_path = path.relative_to(project_root)
except ValueError:
return CheckResult(
plugin=PLUGIN_NAME,
passed=True,
checks=[],
file_path=file_path,
metadata={"skipped": True, "reason": "path_outside_project_root"},
)
rel_parts = rel_path.parts # e.g., ("src", "mypackage", "module.py")
is_at_root = len(rel_parts) == 1 # file is directly in project root
# -------------------------------------------------------------------
# Check A: Test files should be in a tests/ directory
# -------------------------------------------------------------------
is_test_file = file_name.startswith("test_") or file_name.endswith("_test.py")
if is_test_file:
# Check if ANY parent directory (relative to project root) is a test dir
parent_dirs = {part.lower() for part in rel_parts[:-1]}
in_test_dir = bool(parent_dirs & TEST_DIR_NAMES)
if not in_test_dir:
violations.append(CheckItem(
name="test-file-placement",
passed=False,
message=(
f"Test file `{rel_path}` is not inside a tests/ directory. "
f"Test files should be grouped under tests/ for discoverability."
),
severity=Severity.ERROR,
line=None,
fix_hint=f"Move `{file_name}` into a `tests/` directory.",
))
# -------------------------------------------------------------------
# Check B: Non-special Python files should not be at project root
# -------------------------------------------------------------------
if is_at_root and not is_test_file:
if file_name not in allowed_root_files and not file_name.startswith("_"):
violations.append(CheckItem(
name="root-python-file",
passed=False,
message=(
f"Python file `{file_name}` is at the project root. "
f"Source files should be inside a package directory (e.g., src/)."
),
severity=Severity.ERROR,
line=None,
fix_hint=(
f"Move `{file_name}` into a source package directory, "
f"or add it to `allowed_root_files` in plugin config."
),
))
# -------------------------------------------------------------------
# Check C: Source packages should have __init__.py
# -------------------------------------------------------------------
# Only check non-root, non-test, non-hidden directories
if not is_at_root and not is_test_file:
# Check if the file's parent directory is a Python package
# (i.e., contains .py files but no __init__.py)
if _is_missing_init(parent_dir, project_root):
violations.append(CheckItem(
name="missing-init-py",
passed=False,
message=(
f"Directory `{parent_name}/` contains Python files but has no `__init__.py`. "
f"Add `__init__.py` to make it a proper Python package."
),
severity=Severity.ERROR,
line=None,
fix_hint=f"Create an empty `{parent_dir / '__init__.py'}` file.",
))
if violations:
passed = False
checks = violations
else:
passed = True
checks = [
CheckItem(
name="file-structure",
passed=True,
message=f"File `{rel_path}` follows project structure conventions.",
severity=Severity.ERROR,
)
]
return CheckResult(
plugin=PLUGIN_NAME,
passed=passed,
checks=checks,
file_path=file_path,
metadata={"violations_found": len(violations)},
)
def _find_project_root(file_path: Path) -> Path | None:
"""Walk up from file_path to find the project root.
Looks for markers: .seedgo/, .git/, pyproject.toml, setup.py, setup.cfg.
Args:
file_path: Absolute path to a file within the project.
Returns:
Path to the project root directory, or None if not found.
"""
markers = {".seedgo", ".git", "pyproject.toml", "setup.py", "setup.cfg"}
current = file_path.parent
for directory in [current, *current.parents]:
for marker in markers:
if (directory / marker).exists():
return directory
return None
def _is_missing_init(directory: Path, project_root: Path) -> bool:
"""Return True if a directory has Python files but no __init__.py.
Only checks non-hidden, non-special directories that are inside the
project root (to avoid checking installed packages, virtualenvs, etc.).
Args:
directory: Directory to check.
project_root: Project root — only check directories within this.
Returns:
True if __init__.py is absent and the directory has .py source files.
"""
# Skip if directory is not within project root
try:
directory.relative_to(project_root)
except ValueError:
return False
# Skip special/hidden/generated directories
dir_name = directory.name
if dir_name.startswith(".") or dir_name.startswith("_") or dir_name in SKIP_DIR_NAMES:
return False
# Skip test directories — they typically don't need __init__.py
if dir_name.lower() in TEST_DIR_NAMES:
return False
# Check for __init__.py
if (directory / "__init__.py").exists():
return False
# Check if there are any .py source files (that aren't __init__.py)
py_files = [
f for f in directory.iterdir()
if f.is_file() and f.suffix == ".py" and f.name != "__init__.py"
]
return len(py_files) > 0
+143
View File
@@ -0,0 +1,143 @@
"""
Seed Go Plugin: function-length
Flags functions that exceed a configurable maximum line count. Long functions
are harder to read, test, and maintain. Breaking them into smaller, focused
functions improves code quality significantly.
This is a Seed Go differentiator: ruff has no function length rule. No
traditional linter checks this. Only architectural tools can enforce it.
Default max_lines: 50 (configurable via plugin config)
Config example:
{
"plugins": {
"config": {
"function-length": {"max_lines": 40}
}
}
}
Uses the `ast` module's end_lineno attribute (Python 3.8+) to get accurate
function boundaries including all nested code.
"""
import ast
from pathlib import Path
from seedgo.models import CheckItem, CheckResult, Severity
PLUGIN_NAME = "function-length"
PLUGIN_DESCRIPTION = "Flag functions exceeding a configurable maximum line count."
FILE_TYPES = ["*.py"]
PLUGIN_VERSION = "1.0.0"
DEFAULT_MAX_LINES = 50
def check(file_path: str, config: dict | None = None) -> CheckResult:
"""Check a Python file for functions exceeding the maximum line count.
Uses ast.parse() and end_lineno to measure function length precisely,
including all nested statements, docstrings, and blank lines within the body.
Args:
file_path: Absolute path to the Python file to check.
config: Optional dict with key "max_lines" (int). Defaults to 50.
Returns:
CheckResult with one WARNING CheckItem per oversized function found.
"""
cfg = config or {}
max_lines: int = int(cfg.get("max_lines", DEFAULT_MAX_LINES))
try:
source = Path(file_path).read_text(encoding="utf-8", errors="replace")
except OSError:
return CheckResult(
plugin=PLUGIN_NAME,
passed=True,
checks=[],
file_path=file_path,
metadata={"skipped": True, "reason": "file_read_error"},
)
if not source.strip():
return CheckResult(
plugin=PLUGIN_NAME,
passed=True,
checks=[CheckItem(
name="function-length",
passed=True,
message="Empty file — no functions to check.",
severity=Severity.WARNING,
)],
file_path=file_path,
)
try:
tree = ast.parse(source, filename=file_path)
except SyntaxError:
return CheckResult(
plugin=PLUGIN_NAME,
passed=True,
checks=[],
file_path=file_path,
metadata={"skipped": True, "reason": "syntax_error"},
)
violations: list[CheckItem] = []
for node in ast.walk(tree):
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
continue
start_line: int = node.lineno
# end_lineno is available from Python 3.8+
end_line: int = getattr(node, "end_lineno", node.lineno)
func_lines: int = end_line - start_line + 1
if func_lines > max_lines:
func_name = node.name
violations.append(
CheckItem(
name="function-length",
passed=False,
message=(
f"Function `{func_name}` at line {start_line} "
f"is {func_lines} lines long (max: {max_lines})."
),
severity=Severity.WARNING,
line=start_line,
fix_hint=(
f"Break `{func_name}` into smaller functions. "
f"Extract logical sections into helper functions."
),
)
)
if violations:
passed = False
checks = violations
else:
passed = True
checks = [
CheckItem(
name="function-length",
passed=True,
message=f"All functions are within the {max_lines}-line limit.",
severity=Severity.WARNING,
)
]
return CheckResult(
plugin=PLUGIN_NAME,
passed=passed,
checks=checks,
file_path=file_path,
metadata={
"violations_found": len(violations),
"max_lines": max_lines,
},
)
+125
View File
@@ -0,0 +1,125 @@
"""
Seed Go Plugin: no-bare-except
Flags bare `except:` clauses in Python files. Bare excepts swallow all
exceptions including KeyboardInterrupt and SystemExit, which can cause
programs to become unresponsive or hide serious errors.
Correct usage:
except Exception: # catches all normal exceptions
except (ValueError, TypeError): # specific exceptions preferred
Linters like ruff have this rule (E722), so this plugin demonstrates how
Seed Go can layer additional context and fixable hints on top of or alongside
traditional linter rules.
"""
import re
from pathlib import Path
from seedgo.models import CheckItem, CheckResult, Severity
PLUGIN_NAME = "no-bare-except"
PLUGIN_DESCRIPTION = "Flag bare except: clauses that swallow all exceptions."
FILE_TYPES = ["*.py"]
PLUGIN_VERSION = "1.0.0"
# Matches `except:` with nothing after the colon (bare except)
# Allows for trailing whitespace and inline comments
_BARE_EXCEPT_RE = re.compile(r"^\s*except\s*:\s*(#.*)?$")
def check(file_path: str, config: dict | None = None) -> CheckResult:
"""Check a Python file for bare except: clauses.
Parses the file line-by-line, tracking docstring/multiline string state
to avoid false positives from `except:` appearing inside string literals.
Args:
file_path: Absolute path to the Python file to check.
config: Optional plugin config dict (unused by this plugin).
Returns:
CheckResult with one CheckItem per bare except found, plus a summary
item if none are found.
"""
_ = config # Part of plugin interface contract
try:
source = Path(file_path).read_text(encoding="utf-8", errors="replace")
except OSError:
# Cannot read file — return passing result (don't block on I/O errors)
return CheckResult(
plugin=PLUGIN_NAME,
passed=True,
checks=[],
file_path=file_path,
metadata={"skipped": True, "reason": "file_read_error"},
)
violations: list[CheckItem] = []
lines = source.splitlines()
# Track whether we're inside a triple-quoted string to avoid false positives
in_triple_single = False
in_triple_double = False
for lineno, raw_line in enumerate(lines, start=1):
# Simplified triple-quote tracking (handles common cases)
# Count unescaped triple quotes — toggle state
stripped = raw_line
# Toggle triple-string state before checking for except
# Count occurrences of ''' and """ (not inside each other)
if not in_triple_double:
triple_single_count = stripped.count("'''")
if triple_single_count % 2 == 1:
in_triple_single = not in_triple_single
if not in_triple_single:
triple_double_count = stripped.count('"""')
if triple_double_count % 2 == 1:
in_triple_double = not in_triple_double
# Only check lines that are not inside a string literal
if in_triple_single or in_triple_double:
continue
# Strip the line to check if it's a comment
stripped_line = raw_line.strip()
if stripped_line.startswith("#"):
continue
if _BARE_EXCEPT_RE.match(raw_line):
violations.append(
CheckItem(
name="bare-except",
passed=False,
message=f"Bare `except:` at line {lineno} — catches ALL exceptions including KeyboardInterrupt.",
severity=Severity.WARNING,
line=lineno,
fix_hint="Replace with `except Exception:` or a more specific exception type.",
)
)
if violations:
passed = False
checks = violations
else:
passed = True
checks = [
CheckItem(
name="bare-except",
passed=True,
message="No bare except: clauses found.",
severity=Severity.WARNING,
)
]
return CheckResult(
plugin=PLUGIN_NAME,
passed=passed,
checks=checks,
file_path=file_path,
metadata={"violations_found": len(violations)},
)
+177
View File
@@ -0,0 +1,177 @@
"""
Seed Go Plugin: type-hints-required
Checks that public functions have return type annotations. Type hints improve
IDE support, catch bugs early, and make code self-documenting. This plugin
targets the most impactful annotation: the return type.
Skips:
- Private functions (starting with _ or __)
- __init__, __str__, __repr__, and other dunder methods
- Functions that are very short (< 2 lines) — likely trivial wrappers
Uses the `ast` module for reliable parsing rather than fragile regex, so it
correctly handles multi-line signatures, decorators, and nested functions.
"""
import ast
from pathlib import Path
from seedgo.models import CheckItem, CheckResult, Severity
PLUGIN_NAME = "type-hints-required"
PLUGIN_DESCRIPTION = "Public functions must have return type annotations."
FILE_TYPES = ["*.py"]
PLUGIN_VERSION = "1.0.0"
# Dunder methods commonly exempted from return type requirements
_EXEMPT_DUNDERS = {
"__init__",
"__str__",
"__repr__",
"__len__",
"__bool__",
"__hash__",
"__del__",
"__enter__",
"__exit__",
"__iter__",
"__next__",
"__contains__",
"__getitem__",
"__setitem__",
"__delitem__",
"__call__",
}
def check(file_path: str, config: dict | None = None) -> CheckResult:
"""Check a Python file for public functions missing return type annotations.
Parses the file with the ast module and inspects all function definitions.
Only public, non-exempted functions are checked.
Args:
file_path: Absolute path to the Python file to check.
config: Optional plugin config dict (unused by this plugin).
Returns:
CheckResult with one CheckItem per violation found, or a passing item
if all public functions have return type annotations.
"""
_ = config # Part of plugin interface contract
try:
source = Path(file_path).read_text(encoding="utf-8", errors="replace")
except OSError:
return CheckResult(
plugin=PLUGIN_NAME,
passed=True,
checks=[],
file_path=file_path,
metadata={"skipped": True, "reason": "file_read_error"},
)
if not source.strip():
return CheckResult(
plugin=PLUGIN_NAME,
passed=True,
checks=[CheckItem(
name="type-hints-required",
passed=True,
message="Empty file — no functions to check.",
severity=Severity.WARNING,
)],
file_path=file_path,
)
try:
tree = ast.parse(source, filename=file_path)
except SyntaxError:
return CheckResult(
plugin=PLUGIN_NAME,
passed=True,
checks=[],
file_path=file_path,
metadata={"skipped": True, "reason": "syntax_error"},
)
violations: list[CheckItem] = []
for node in ast.walk(tree):
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
continue
func_name = node.name
# Skip private functions (starting with _)
if func_name.startswith("_"):
continue
# Skip exempt dunder methods
if func_name in _EXEMPT_DUNDERS:
continue
# Skip very short functions (< 2 statements — likely trivial)
if len(node.body) < 2:
# Check if it's just a pass or docstring
if _is_trivial_body(node.body):
continue
# Check for missing return annotation
if node.returns is None:
# Estimate function length from source lines
violations.append(
CheckItem(
name="type-hints-required",
passed=False,
message=(
f"Public function `{func_name}` at line {node.lineno} "
f"missing return type annotation."
),
severity=Severity.WARNING,
line=node.lineno,
fix_hint=f"Add `-> ReturnType` before the colon: `def {func_name}(...) -> ReturnType:`",
)
)
if violations:
passed = False
checks = violations
else:
passed = True
checks = [
CheckItem(
name="type-hints-required",
passed=True,
message="All public functions have return type annotations.",
severity=Severity.WARNING,
)
]
return CheckResult(
plugin=PLUGIN_NAME,
passed=passed,
checks=checks,
file_path=file_path,
metadata={"violations_found": len(violations)},
)
def _is_trivial_body(body: list) -> bool:
"""Return True if a function body is trivially simple (pass, ellipsis, or docstring only)."""
if len(body) == 0:
return True
if len(body) == 1:
stmt = body[0]
# pass statement
if isinstance(stmt, ast.Pass):
return True
# ... (ellipsis)
if isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Constant):
if stmt.value.value is ...:
return True
# String = docstring
if isinstance(stmt.value.value, str):
return True
return False
+274
View File
@@ -0,0 +1,274 @@
"""
Seed Go Result Reporter
Formats check results for display in three output modes:
"human" — Colored terminal output using raw ANSI escape codes.
Shows plugin names, pass/fail verdicts, scores, and per-check
items with severity markers. Summary line at the bottom.
No external dependencies (no Rich, no colorama).
"json" — Machine-readable JSON dict. Includes all result data and the
overall summary. Safe for piping to other tools.
"github" — GitHub Actions annotation format. Emits ::error and ::warning
annotation lines that GitHub renders inline on PR diffs.
All formats are produced by a single entry point: report_results().
"""
import dataclasses
import json
from .models import CheckResult, Severity
# ---------------------------------------------------------------------------
# ANSI color codes (raw escape sequences — zero dependencies)
# ---------------------------------------------------------------------------
_RESET = "\033[0m"
_BOLD = "\033[1m"
_RED = "\033[31m"
_GREEN = "\033[32m"
_YELLOW = "\033[33m"
_CYAN = "\033[36m"
_DIM = "\033[2m"
def report_results(
results: list[CheckResult],
overall: dict,
format: str = "human",
) -> str:
"""Format check results for display.
Dispatches to the appropriate formatter based on the format argument.
Args:
results: List of CheckResult objects returned by run_checks().
overall: Overall summary dict returned by run_checks() (from
calculate_overall()). Expected keys: overall_score, passed,
threshold, plugins_passed, plugins_failed, error_count,
warning_count, info_count.
format: Output format. One of:
"human" — Colored terminal output (default).
"json" — JSON-encoded string, machine-readable.
"github" — GitHub Actions annotation lines.
Returns:
Formatted string ready to print or write to stdout.
Raises:
ValueError: If format is not one of the three supported values.
"""
if format == "human":
return _format_human(results, overall)
elif format == "json":
return _format_json(results, overall)
elif format == "github":
return _format_github(results, overall)
else:
raise ValueError(f"Unknown format {format!r}. Choose: human, json, github")
# ---------------------------------------------------------------------------
# Human formatter
# ---------------------------------------------------------------------------
def _format_human(results: list[CheckResult], overall: dict) -> str:
"""Produce colored terminal output for human consumption.
Layout:
plugin-name .............. PASS (100/100)
another-plugin ........... FAIL (60/100)
✗ check-name: message [line N]
hint: fix_hint text
✓ passing-check: message
Overall: 80/100 — PASS (threshold: 75)
2 checks ran, 1 passed, 1 failed
"""
lines: list[str] = []
if not results:
lines.append(f"{_DIM}No checks ran.{_RESET}")
lines.append(_summary_line(overall))
return "\n".join(lines)
# Group results by plugin name for a cleaner display
for result in results:
lines.append(_plugin_header_line(result))
for item in result.checks:
lines.extend(_check_item_lines(item))
lines.append("")
lines.append(_summary_line(overall))
lines.append(_counts_line(overall))
return "\n".join(lines)
def _plugin_header_line(result: CheckResult) -> str:
"""Format the plugin name + verdict + score header line."""
name = result.plugin
score = result.score
file_label = f" [{result.file_path}]" if result.file_path else ""
dots = "." * max(1, 50 - len(name) - len(file_label))
if result.passed:
verdict = f"{_GREEN}PASS{_RESET}"
else:
verdict = f"{_RED}FAIL{_RESET}"
score_str = f"({score}/100)"
return f" {_BOLD}{name}{_RESET}{file_label} {_DIM}{dots}{_RESET} {verdict} {_DIM}{score_str}{_RESET}"
def _check_item_lines(item) -> list[str]:
"""Format a single CheckItem into one or two display lines."""
lines: list[str] = []
if item.passed:
marker = f"{_GREEN}✓{_RESET}"
color = _DIM
elif item.severity == Severity.ERROR:
marker = f"{_RED}✗{_RESET}"
color = _RED
elif item.severity == Severity.WARNING:
marker = f"{_YELLOW}⚠{_RESET}"
color = _YELLOW
else:
marker = f"{_CYAN}ℹ{_RESET}"
color = _CYAN
line_ref = f" [line {item.line}]" if item.line is not None else ""
main = f" {marker} {color}{item.name}{_RESET}: {item.message}{line_ref}"
lines.append(main)
if item.fix_hint and not item.passed:
lines.append(f" {_DIM}hint: {item.fix_hint}{_RESET}")
return lines
def _summary_line(overall: dict) -> str:
"""Format the overall score summary line."""
score = overall.get("overall_score", 100)
passed = overall.get("passed", True)
threshold = overall.get("threshold", 75)
if passed:
verdict = f"{_GREEN}{_BOLD}PASS{_RESET}"
else:
verdict = f"{_RED}{_BOLD}FAIL{_RESET}"
return f" {_BOLD}Overall:{_RESET} {score}/100 — {verdict} {_DIM}(threshold: {threshold}){_RESET}"
def _counts_line(overall: dict) -> str:
"""Format the plugin count summary line."""
p_passed = overall.get("plugins_passed", 0)
p_failed = overall.get("plugins_failed", 0)
total = p_passed + p_failed
errors = overall.get("error_count", 0)
warnings = overall.get("warning_count", 0)
parts = [f"{total} check(s) ran", f"{p_passed} passed", f"{p_failed} failed"]
if errors:
parts.append(f"{_RED}{errors} error(s){_RESET}")
if warnings:
parts.append(f"{_YELLOW}{warnings} warning(s){_RESET}")
return " " + ", ".join(parts)
# ---------------------------------------------------------------------------
# JSON formatter
# ---------------------------------------------------------------------------
def _format_json(results: list[CheckResult], overall: dict) -> str:
"""Produce a JSON string containing all results and the overall summary.
The JSON structure is:
{
"overall_score": int,
"passed": bool,
"threshold": int,
"plugins_passed": int,
"plugins_failed": int,
"error_count": int,
"warning_count": int,
"info_count": int,
"results": [
{
"plugin": str,
"passed": bool,
"score": int,
"file_path": str,
"checks": [...],
"metadata": {...}
},
...
]
}
"""
serializable_results = []
for result in results:
d = dataclasses.asdict(result)
# Convert Severity enum values to plain strings for JSON
for check in d.get("checks", []):
if isinstance(check.get("severity"), Severity):
check["severity"] = check["severity"].value
elif hasattr(check.get("severity"), "value"):
check["severity"] = check["severity"].value
serializable_results.append(d)
payload = dict(overall)
payload["results"] = serializable_results
return json.dumps(payload, indent=2)
# ---------------------------------------------------------------------------
# GitHub Actions formatter
# ---------------------------------------------------------------------------
def _format_github(results: list[CheckResult], overall: dict) -> str: # noqa: ARG001
"""Produce GitHub Actions workflow annotation lines.
Each failed check item becomes an annotation:
::error file=path/to/file.py,line=42::message [plugin-name]
::warning file=path/to/file.py,line=42::message [plugin-name]
::notice file=path/to/file.py::message [plugin-name]
INFO items are emitted as ::notice. Passed items are omitted.
See: https://docs.github.com/en/actions/writing-workflows/
choosing-what-your-workflow-does/workflow-commands-for-github-actions
"""
lines: list[str] = []
for result in results:
for item in result.checks:
if item.passed:
continue # Skip passing checks — no annotation needed
file_part = f"file={result.file_path}" if result.file_path else ""
line_part = f",line={item.line}" if item.line is not None else ""
location = f"{file_part}{line_part}"
location_prefix = f"{location}::" if location else ""
message = f"{item.message} [{result.plugin}]"
if item.severity == Severity.ERROR:
lines.append(f"::error {location_prefix}{message}")
elif item.severity == Severity.WARNING:
lines.append(f"::warning {location_prefix}{message}")
else:
lines.append(f"::notice {location_prefix}{message}")
return "\n".join(lines)
+325
View File
@@ -0,0 +1,325 @@
"""
Seed Go Check Runner
Orchestrates the full check pipeline: plugin discovery, file discovery,
check execution, and score calculation.
This module ties all Phase 1 components together:
- discover_plugins() — finds all plugins from all sources
- load_config() — loads and resolves .seedgo/config.json
- File walking — finds target files matching plugin FILE_TYPES,
respecting config paths.include/exclude
- Plugin execution — calls plugin.check(file_path, plugin_config) per match
- Scoring — severity-weighted score calculation (see calculate_score)
Severity-weighted scoring model:
- error_weight (default 1.0) — full score deduction
- warning_weight (default 0.5) — half score deduction
- info_weight (default 0.0) — no score deduction (informational)
- score = (passed_weight / total_weight) * 100
- Any unresolved ERROR blocks pass regardless of score
"""
import fnmatch
import os
from pathlib import Path
from typing import Optional
from .config import load_config
from .discovery import discover_plugins
from .models import CheckResult, Severity
def run_checks(
project_root: str,
files: Optional[list[str]] = None,
plugins: Optional[list[str]] = None,
) -> tuple[list["CheckResult"], dict]:
"""Run all applicable checks against project files.
Main orchestration function. Discovers plugins, finds target files,
executes checks, and returns results with an overall summary.
Args:
project_root: Absolute path to the project root (must contain .seedgo/).
files: Optional list of specific file paths to check. If None or empty,
discovers all files under project_root matching plugin FILE_TYPES
and config paths.include/exclude rules.
plugins: Optional list of plugin names to run. If None or empty, runs
all discovered plugins (respecting config enabled/disabled).
Returns:
A tuple of:
- list[CheckResult]: One result per (file, plugin) pair that ran.
- dict: Overall summary from calculate_overall(). Keys:
overall_score, passed, threshold, plugins_passed, plugins_failed,
error_count, warning_count, info_count.
"""
discovered = discover_plugins(project_root)
config = load_config(project_root)
# Apply enabled/disabled plugin filters from config
enabled_set = set(config.get("plugins", {}).get("enabled", []))
disabled_set = set(config.get("plugins", {}).get("disabled", []))
plugin_configs = config.get("plugins", {}).get("config", {})
# Filter discovered plugins
active_plugins = []
for plugin_info in discovered:
name = plugin_info["name"]
# Respect CLI plugin filter
if plugins and name not in plugins:
continue
# Respect config enabled list (if non-empty, only those are active)
if enabled_set and name not in enabled_set:
continue
# Respect config disabled list
if name in disabled_set:
continue
active_plugins.append(plugin_info)
# Determine target files
if files:
target_files = [str(Path(f).resolve()) for f in files]
else:
target_files = _find_project_files(project_root, config)
scoring_config = config.get("scoring", {})
all_results: list[CheckResult] = []
for file_path in target_files:
for plugin_info in active_plugins:
module = plugin_info["module"]
file_types = getattr(module, "FILE_TYPES", ["*"])
# Only run plugin on files matching its FILE_TYPES patterns
if not _file_matches_types(file_path, file_types):
continue
plugin_cfg = plugin_configs.get(plugin_info["name"], {})
try:
result = module.check(file_path, plugin_cfg or None)
# Calculate severity-weighted score
result.score = calculate_score(result, scoring_config)
all_results.append(result)
except Exception as exc:
# Plugin crashed — record as failed result, don't propagate
all_results.append(CheckResult(
plugin=plugin_info["name"],
passed=False,
checks=[],
score=0,
file_path=file_path,
metadata={"error": str(exc)},
))
overall = calculate_overall(all_results, scoring_config)
return all_results, overall
def calculate_score(result: "CheckResult", config: dict) -> int:
"""Calculate a severity-weighted score for a single plugin result.
Score formula:
score = (passed_weight / total_weight) * 100
Where each CheckItem contributes its severity's weight to the total.
If the item passed, its weight also goes to passed_weight.
Weights (from config or defaults):
error_weight: 1.0 — full deduction
warning_weight: 0.5 — half deduction
info_weight: 0.0 — no deduction (informational only)
Special cases:
- No checks → score = 100 (no checks means no violations)
- total_weight == 0 → score = 100 (all checks are INFO-level only)
Args:
result: A CheckResult from a plugin's check() call.
config: Scoring config dict. Typically config["scoring"] from load_config().
Keys: error_weight, warning_weight, info_weight.
Returns:
Integer score from 0 to 100 (inclusive).
"""
if not result.checks:
return 100
error_weight: float = config.get("error_weight", 1.0)
warning_weight: float = config.get("warning_weight", 0.5)
info_weight: float = config.get("info_weight", 0.0)
weight_map = {
Severity.ERROR: error_weight,
Severity.WARNING: warning_weight,
Severity.INFO: info_weight,
}
total_weight: float = 0.0
passed_weight: float = 0.0
for check_item in result.checks:
w = weight_map.get(check_item.severity, error_weight)
total_weight += w
if check_item.passed:
passed_weight += w
if total_weight == 0.0:
return 100
return int((passed_weight / total_weight) * 100)
def calculate_overall(results: list["CheckResult"], config: dict) -> dict:
"""Aggregate per-plugin scores into a single overall summary.
Pass condition: overall_score >= threshold AND error_count == 0.
A single unresolved ERROR prevents passing regardless of score.
Args:
results: List of CheckResult objects (may span multiple files/plugins).
config: Scoring config dict. Typically config["scoring"] from load_config().
Keys used: threshold, error_weight, warning_weight, info_weight.
Returns:
Dict with keys:
overall_score (int): Mean of per-result scores (0-100).
passed (bool): True if score >= threshold and no ERRORs.
threshold (int): Pass threshold from config (default 75).
plugins_passed (int): Count of results with score >= threshold.
plugins_failed (int): Count of results with score < threshold.
error_count (int): Unresolved ERROR check items across all results.
warning_count (int): Unresolved WARNING check items across all results.
info_count (int): Unresolved INFO check items across all results.
"""
threshold: int = int(config.get("threshold", 75))
if not results:
return {
"overall_score": 100,
"passed": True,
"threshold": threshold,
"plugins_passed": 0,
"plugins_failed": 0,
"error_count": 0,
"warning_count": 0,
"info_count": 0,
}
# Recalculate scores for all results using the provided config
scores = [calculate_score(r, config) for r in results]
overall_score = int(sum(scores) / len(scores))
# Count unresolved (failed) check items by severity
error_count = sum(
1 for r in results for c in r.checks
if not c.passed and c.severity == Severity.ERROR
)
warning_count = sum(
1 for r in results for c in r.checks
if not c.passed and c.severity == Severity.WARNING
)
info_count = sum(
1 for r in results for c in r.checks
if not c.passed and c.severity == Severity.INFO
)
# Pass requires score above threshold AND zero unresolved errors
passed = overall_score >= threshold and error_count == 0
plugins_passed = sum(1 for s in scores if s >= threshold)
plugins_failed = sum(1 for s in scores if s < threshold)
return {
"overall_score": overall_score,
"passed": passed,
"threshold": threshold,
"plugins_passed": plugins_passed,
"plugins_failed": plugins_failed,
"error_count": error_count,
"warning_count": warning_count,
"info_count": info_count,
}
def _find_project_files(project_root: str, config: dict) -> list[str]:
"""Walk the project directory and return files matching include/exclude config.
Respects config paths.include (list of paths/globs relative to project_root)
and config paths.exclude (list of paths/globs to skip).
Args:
project_root: Absolute path to the project root.
config: Fully resolved config dict from load_config().
Returns:
Sorted list of absolute file path strings.
"""
root = Path(project_root).resolve()
paths_config = config.get("paths", {})
include_patterns: list[str] = paths_config.get("include", ["."])
exclude_patterns: list[str] = paths_config.get("exclude", [])
collected: set[str] = set()
for include in include_patterns:
include_path = root / include
# Normalise: if the include pattern resolves to a directory, walk it.
# Otherwise treat it as a glob from the root.
if include_path.is_dir():
for dirpath, _, filenames in os.walk(include_path):
for fname in filenames:
fp = Path(dirpath) / fname
collected.add(str(fp.resolve()))
else:
# Try as a glob pattern relative to root
for fp in root.glob(include):
if fp.is_file():
collected.add(str(fp.resolve()))
# Apply exclude patterns — match against relative paths
result: list[str] = []
for file_path in sorted(collected):
try:
rel = str(Path(file_path).relative_to(root))
except ValueError:
rel = file_path
excluded = False
for pattern in exclude_patterns:
# Match against relative path or just the filename
if fnmatch.fnmatch(rel, pattern) or fnmatch.fnmatch(os.path.basename(rel), pattern):
excluded = True
break
# Also handle directory prefixes: "tests/" should exclude "tests/foo.py"
if rel.startswith(pattern.rstrip("/") + "/") or rel.startswith(pattern):
excluded = True
break
if not excluded:
result.append(file_path)
return result
def _file_matches_types(file_path: str, file_types: list[str]) -> bool:
"""Check whether a file path matches any of the plugin's FILE_TYPES patterns.
Uses fnmatch glob semantics against the file's basename.
A pattern of "*" matches any file.
Args:
file_path: Absolute or relative path to the file.
file_types: List of glob patterns from the plugin's FILE_TYPES constant
(e.g., ["*.py"], ["*.js", "*.ts"]).
Returns:
True if the file matches at least one pattern.
"""
name = os.path.basename(file_path)
for pattern in file_types:
if fnmatch.fnmatch(name, pattern):
return True
return False
File diff suppressed because it is too large Load Diff
+735
View File
@@ -0,0 +1,735 @@
"""
Seed Go Core Framework Tests
Comprehensive tests for Phase 1 deliverables:
- models.py: CheckResult, CheckItem, Severity
- config.py: load_config, find_project_root, create_default_config, DEFAULT_CONFIG
- discovery.py: discover_plugins and helper functions
- bypass.py: is_bypassed, load_bypass_rules
- exceptions.py: exception hierarchy
Target: 85%+ coverage of src/seedgo/ core modules.
"""
import json
import sys
from pathlib import Path
import pytest
# Ensure src/ is on the path when running from the repo root
_repo_root = Path(__file__).parent.parent
_src_path = str(_repo_root / "src")
if _src_path not in sys.path:
sys.path.insert(0, _src_path)
from seedgo.models import CheckItem, CheckResult, Severity
from seedgo.config import (
DEFAULT_CONFIG,
_deep_merge,
create_default_config,
find_project_root,
load_config,
resolve_file_config,
)
from seedgo.bypass import is_bypassed, load_bypass_rules
from seedgo.discovery import _scan_directory, discover_plugins
from seedgo.exceptions import ConfigError, DiscoveryError, PluginError, SeedGoError
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def tmp_project(tmp_path):
"""A temporary project root with a .seedgo/ directory."""
seedgo_dir = tmp_path / ".seedgo"
seedgo_dir.mkdir()
(seedgo_dir / "plugins").mkdir()
return tmp_path
@pytest.fixture
def tmp_project_with_config(tmp_project):
"""A temporary project with a minimal .seedgo/config.json."""
config = {
"version": "1.0.0",
"plugins": {
"enabled": ["test-plugin"],
"disabled": [],
"config": {},
},
"scoring": {"threshold": 80},
}
config_path = tmp_project / ".seedgo" / "config.json"
config_path.write_text(json.dumps(config))
return tmp_project
@pytest.fixture
def simple_plugin_file(tmp_project):
"""Write a minimal valid plugin into .seedgo/plugins/."""
plugin_code = """
PLUGIN_NAME = "test-plugin"
PLUGIN_DESCRIPTION = "A simple test plugin"
FILE_TYPES = ["*.py"]
from seedgo.models import CheckResult, CheckItem, Severity
def check(file_path, config=None):
return CheckResult(
plugin=PLUGIN_NAME,
passed=True,
checks=[CheckItem(name="always-passes", passed=True, message="ok")],
score=100,
file_path=file_path,
)
"""
plugin_path = tmp_project / ".seedgo" / "plugins" / "test_plugin.py"
plugin_path.write_text(plugin_code)
return plugin_path
@pytest.fixture
def bypass_rules_file(tmp_project):
"""Write a .seedgo/bypass.json file."""
rules = {
"version": "1.0.0",
"bypass": [
{
"file": "src/legacy.py",
"plugin": "no-bare-except",
"reason": "Legacy code",
},
{
"file": "src/utils.py",
"plugin": "type-hints",
"lines": [10, 20],
"reason": "Line-specific bypass",
},
],
}
bypass_path = tmp_project / ".seedgo" / "bypass.json"
bypass_path.write_text(json.dumps(rules))
return tmp_project
# ---------------------------------------------------------------------------
# Severity tests
# ---------------------------------------------------------------------------
class TestSeverity:
def test_severity_values(self):
assert Severity.ERROR.value == "error"
assert Severity.WARNING.value == "warning"
assert Severity.INFO.value == "info"
def test_severity_is_enum(self):
from enum import Enum
assert issubclass(Severity, Enum)
def test_severity_members(self):
members = {s.name for s in Severity}
assert members == {"ERROR", "WARNING", "INFO"}
def test_severity_comparison(self):
assert Severity.ERROR == Severity.ERROR
assert Severity.ERROR != Severity.WARNING
def test_severity_from_value(self):
assert Severity("error") == Severity.ERROR
assert Severity("warning") == Severity.WARNING
assert Severity("info") == Severity.INFO
# ---------------------------------------------------------------------------
# CheckItem tests
# ---------------------------------------------------------------------------
class TestCheckItem:
def test_required_fields(self):
item = CheckItem(name="test", passed=True, message="all good")
assert item.name == "test"
assert item.passed is True
assert item.message == "all good"
def test_default_severity_is_error(self):
item = CheckItem(name="x", passed=False, message="fail")
assert item.severity == Severity.ERROR
def test_custom_severity(self):
item = CheckItem(name="x", passed=False, message="warn", severity=Severity.WARNING)
assert item.severity == Severity.WARNING
def test_optional_line_defaults_to_none(self):
item = CheckItem(name="x", passed=True, message="ok")
assert item.line is None
def test_optional_fix_hint_defaults_to_none(self):
item = CheckItem(name="x", passed=True, message="ok")
assert item.fix_hint is None
def test_all_fields(self):
item = CheckItem(
name="bare-except",
passed=False,
message="Found bare except at line 5",
severity=Severity.WARNING,
line=5,
fix_hint="except Exception:",
)
assert item.name == "bare-except"
assert item.passed is False
assert item.severity == Severity.WARNING
assert item.line == 5
assert item.fix_hint == "except Exception:"
def test_missing_required_field_raises(self):
with pytest.raises(TypeError):
CheckItem(name="x", passed=True) # type: ignore[call-arg] # missing message
def test_is_dataclass(self):
import dataclasses
assert dataclasses.is_dataclass(CheckItem)
# ---------------------------------------------------------------------------
# CheckResult tests
# ---------------------------------------------------------------------------
class TestCheckResult:
def test_required_fields(self):
result = CheckResult(plugin="my-plugin", passed=True)
assert result.plugin == "my-plugin"
assert result.passed is True
def test_checks_defaults_to_empty_list(self):
result = CheckResult(plugin="p", passed=True)
assert result.checks == []
def test_score_defaults_to_zero(self):
result = CheckResult(plugin="p", passed=True)
assert result.score == 0
def test_file_path_defaults_to_empty_string(self):
result = CheckResult(plugin="p", passed=True)
assert result.file_path == ""
def test_metadata_defaults_to_empty_dict(self):
result = CheckResult(plugin="p", passed=True)
assert result.metadata == {}
def test_metadata_is_independent_per_instance(self):
r1 = CheckResult(plugin="p", passed=True)
r2 = CheckResult(plugin="p", passed=True)
r1.metadata["key"] = "val"
assert "key" not in r2.metadata
def test_checks_is_independent_per_instance(self):
r1 = CheckResult(plugin="p", passed=True)
r2 = CheckResult(plugin="p", passed=True)
r1.checks.append(CheckItem(name="x", passed=True, message="ok"))
assert len(r2.checks) == 0
def test_full_construction(self):
items = [
CheckItem(name="c1", passed=True, message="ok"),
CheckItem(name="c2", passed=False, message="fail", severity=Severity.WARNING),
]
result = CheckResult(
plugin="no-bare-except",
passed=False,
checks=items,
score=65,
file_path="/path/to/file.py",
metadata={"ast_nodes": 42},
)
assert result.plugin == "no-bare-except"
assert result.passed is False
assert len(result.checks) == 2
assert result.score == 65
assert result.file_path == "/path/to/file.py"
assert result.metadata["ast_nodes"] == 42
def test_is_dataclass(self):
import dataclasses
assert dataclasses.is_dataclass(CheckResult)
def test_asdict_serializable(self):
import dataclasses
result = CheckResult(
plugin="p",
passed=True,
checks=[CheckItem(name="c", passed=True, message="ok")],
score=100,
)
d = dataclasses.asdict(result)
assert d["plugin"] == "p"
assert d["passed"] is True
assert d["score"] == 100
assert d["checks"][0]["name"] == "c"
# ---------------------------------------------------------------------------
# Config tests
# ---------------------------------------------------------------------------
class TestDefaultConfig:
def test_default_config_has_required_keys(self):
assert "version" in DEFAULT_CONFIG
assert "profile" in DEFAULT_CONFIG
assert "plugins" in DEFAULT_CONFIG
assert "scoring" in DEFAULT_CONFIG
assert "paths" in DEFAULT_CONFIG
assert "overrides" in DEFAULT_CONFIG
def test_default_profile_is_none(self):
assert DEFAULT_CONFIG["profile"] is None
def test_default_threshold(self):
assert DEFAULT_CONFIG["scoring"]["threshold"] == 75
def test_default_weights(self):
scoring = DEFAULT_CONFIG["scoring"]
assert scoring["error_weight"] == 1.0
assert scoring["warning_weight"] == 0.5
assert scoring["info_weight"] == 0.0
def test_default_paths_include(self):
assert DEFAULT_CONFIG["paths"]["include"] == ["."]
def test_default_plugins_empty(self):
plugins = DEFAULT_CONFIG["plugins"]
assert plugins["enabled"] == []
assert plugins["disabled"] == []
assert plugins["config"] == {}
def test_default_overrides_empty(self):
assert DEFAULT_CONFIG["overrides"] == []
class TestLoadConfig:
def test_returns_defaults_when_no_config_file(self, tmp_project):
config = load_config(str(tmp_project))
assert config["scoring"]["threshold"] == 75
assert config["profile"] is None
def test_loads_user_config(self, tmp_project_with_config):
config = load_config(str(tmp_project_with_config))
assert config["scoring"]["threshold"] == 80
def test_merges_user_config_over_defaults(self, tmp_project):
user_cfg = {"scoring": {"threshold": 90}}
(tmp_project / ".seedgo" / "config.json").write_text(json.dumps(user_cfg))
config = load_config(str(tmp_project))
assert config["scoring"]["threshold"] == 90
# Other defaults preserved
assert config["scoring"]["error_weight"] == 1.0
def test_raises_config_error_on_invalid_json(self, tmp_project):
(tmp_project / ".seedgo" / "config.json").write_text("{ invalid json }")
with pytest.raises(ConfigError):
load_config(str(tmp_project))
def test_plugins_enabled_list_loaded(self, tmp_project):
cfg = {"plugins": {"enabled": ["plugin-a", "plugin-b"]}}
(tmp_project / ".seedgo" / "config.json").write_text(json.dumps(cfg))
config = load_config(str(tmp_project))
assert "plugin-a" in config["plugins"]["enabled"]
def test_default_config_not_mutated(self, tmp_project):
"""load_config must return a copy — DEFAULT_CONFIG must stay pristine."""
import copy
original = copy.deepcopy(DEFAULT_CONFIG)
cfg = {"scoring": {"threshold": 99}}
(tmp_project / ".seedgo" / "config.json").write_text(json.dumps(cfg))
load_config(str(tmp_project))
assert DEFAULT_CONFIG == original
class TestFindProjectRoot:
def test_finds_root_from_subdir(self, tmp_project):
subdir = tmp_project / "src" / "mypackage"
subdir.mkdir(parents=True)
root = find_project_root(str(subdir))
assert root == str(tmp_project)
def test_finds_root_from_file(self, tmp_project):
src = tmp_project / "src"
src.mkdir()
some_file = src / "main.py"
some_file.write_text("pass")
root = find_project_root(str(some_file))
assert root == str(tmp_project)
def test_returns_none_when_no_seedgo_dir(self, tmp_path):
subdir = tmp_path / "no_seedgo_here" / "nested"
subdir.mkdir(parents=True)
root = find_project_root(str(subdir))
assert root is None
def test_finds_root_from_project_root_itself(self, tmp_project):
root = find_project_root(str(tmp_project))
assert root == str(tmp_project)
class TestCreateDefaultConfig:
def test_creates_config_file(self, tmp_path):
project = tmp_path / "newproject"
project.mkdir()
config_path = create_default_config(str(project))
assert Path(config_path).exists()
def test_created_config_is_valid_json(self, tmp_path):
project = tmp_path / "newproject"
project.mkdir()
config_path = create_default_config(str(project))
with open(config_path) as f:
data = json.load(f)
assert "version" in data
assert "scoring" in data
def test_creates_seedgo_dir_if_missing(self, tmp_path):
project = tmp_path / "newproject"
project.mkdir()
create_default_config(str(project))
assert (project / ".seedgo").is_dir()
def test_creates_plugins_subdir(self, tmp_path):
project = tmp_path / "newproject"
project.mkdir()
create_default_config(str(project))
assert (project / ".seedgo" / "plugins").is_dir()
def test_raises_if_config_already_exists(self, tmp_project):
# Write a config so it already exists
(tmp_project / ".seedgo" / "config.json").write_text("{}")
with pytest.raises(ConfigError, match="already exists"):
create_default_config(str(tmp_project))
def test_profile_embedded_in_config(self, tmp_path):
project = tmp_path / "newproject"
project.mkdir()
config_path = create_default_config(str(project), profile="python-basic")
with open(config_path) as f:
data = json.load(f)
assert data["profile"] == "python-basic"
class TestDeepMerge:
def test_simple_override(self):
base = {"a": 1, "b": 2}
_deep_merge(base, {"b": 99})
assert base == {"a": 1, "b": 99}
def test_nested_merge(self):
base = {"scoring": {"threshold": 75, "error_weight": 1.0}}
_deep_merge(base, {"scoring": {"threshold": 90}})
assert base["scoring"]["threshold"] == 90
assert base["scoring"]["error_weight"] == 1.0 # preserved
def test_new_key_added(self):
base = {"a": 1}
_deep_merge(base, {"b": 2})
assert base["b"] == 2
def test_list_replaces_not_merges(self):
base = {"plugins": {"enabled": ["a", "b"]}}
_deep_merge(base, {"plugins": {"enabled": ["c"]}})
assert base["plugins"]["enabled"] == ["c"]
class TestResolveFileConfig:
def test_no_overrides_returns_same_config(self, tmp_project):
config = load_config(str(tmp_project))
resolved = resolve_file_config(config, str(tmp_project / "src" / "main.py"), str(tmp_project))
assert resolved["scoring"]["threshold"] == config["scoring"]["threshold"]
def test_override_applied_for_matching_path(self, tmp_project):
cfg = {
"scoring": {"threshold": 75},
"overrides": [
{"paths": ["tests/"], "scoring": {"threshold": 50}}
],
}
(tmp_project / ".seedgo" / "config.json").write_text(json.dumps(cfg))
config = load_config(str(tmp_project))
test_file = str(tmp_project / "tests" / "test_foo.py")
resolved = resolve_file_config(config, test_file, str(tmp_project))
assert resolved["scoring"]["threshold"] == 50
def test_override_not_applied_for_non_matching_path(self, tmp_project):
cfg = {
"scoring": {"threshold": 75},
"overrides": [
{"paths": ["tests/"], "scoring": {"threshold": 50}}
],
}
(tmp_project / ".seedgo" / "config.json").write_text(json.dumps(cfg))
config = load_config(str(tmp_project))
src_file = str(tmp_project / "src" / "main.py")
resolved = resolve_file_config(config, src_file, str(tmp_project))
assert resolved["scoring"]["threshold"] == 75
# ---------------------------------------------------------------------------
# Bypass tests
# ---------------------------------------------------------------------------
class TestLoadBypassRules:
def test_returns_empty_list_when_no_file(self, tmp_project):
rules = load_bypass_rules(str(tmp_project))
assert rules == []
def test_loads_rules(self, bypass_rules_file):
rules = load_bypass_rules(str(bypass_rules_file))
assert len(rules) == 2
def test_returns_empty_on_invalid_json(self, tmp_project):
(tmp_project / ".seedgo" / "bypass.json").write_text("{ bad json }")
rules = load_bypass_rules(str(tmp_project))
assert rules == []
def test_returns_empty_when_bypass_key_missing(self, tmp_project):
(tmp_project / ".seedgo" / "bypass.json").write_text('{"version": "1.0.0"}')
rules = load_bypass_rules(str(tmp_project))
assert rules == []
class TestIsBypassed:
def test_no_rules_returns_false(self):
assert is_bypassed("src/foo.py", "my-plugin", bypass_rules=None) is False
def test_empty_rules_returns_false(self):
assert is_bypassed("src/foo.py", "my-plugin", bypass_rules=[]) is False
def test_whole_file_plugin_bypass(self):
rules = [{"file": "src/legacy.py", "plugin": "no-bare-except"}]
assert is_bypassed("src/legacy.py", "no-bare-except", bypass_rules=rules) is True
def test_different_file_not_bypassed(self):
rules = [{"file": "src/legacy.py", "plugin": "no-bare-except"}]
assert is_bypassed("src/other.py", "no-bare-except", bypass_rules=rules) is False
def test_different_plugin_not_bypassed(self):
rules = [{"file": "src/legacy.py", "plugin": "no-bare-except"}]
assert is_bypassed("src/legacy.py", "type-hints", bypass_rules=rules) is False
def test_line_specific_bypass_matching_line(self):
rules = [{"file": "src/utils.py", "plugin": "type-hints", "lines": [10, 20]}]
assert is_bypassed("src/utils.py", "type-hints", line=10, bypass_rules=rules) is True
def test_line_specific_bypass_non_matching_line(self):
rules = [{"file": "src/utils.py", "plugin": "type-hints", "lines": [10, 20]}]
assert is_bypassed("src/utils.py", "type-hints", line=99, bypass_rules=rules) is False
def test_line_specific_bypass_no_line_provided(self):
"""Line rule requires a matching line — without line=, should not bypass."""
rules = [{"file": "src/utils.py", "plugin": "type-hints", "lines": [10, 20]}]
assert is_bypassed("src/utils.py", "type-hints", line=None, bypass_rules=rules) is False
def test_project_root_relative_path(self, tmp_project):
rules = [{"file": "src/legacy.py", "plugin": "no-bare-except"}]
abs_path = str(tmp_project / "src" / "legacy.py")
assert is_bypassed(abs_path, "no-bare-except", bypass_rules=rules, project_root=str(tmp_project)) is True
def test_multiple_rules_first_match_wins(self):
rules = [
{"file": "src/foo.py", "plugin": "plugin-a"},
{"file": "src/foo.py", "plugin": "plugin-b"},
]
assert is_bypassed("src/foo.py", "plugin-a", bypass_rules=rules) is True
assert is_bypassed("src/foo.py", "plugin-b", bypass_rules=rules) is True
assert is_bypassed("src/foo.py", "plugin-c", bypass_rules=rules) is False
# ---------------------------------------------------------------------------
# Discovery tests
# ---------------------------------------------------------------------------
class TestScanDirectory:
def test_returns_empty_for_nonexistent_dir(self, tmp_path):
nonexistent = tmp_path / "does_not_exist"
result = _scan_directory(nonexistent, source="builtin")
assert result == []
def test_skips_underscore_files(self, tmp_path):
(tmp_path / "_private.py").write_text("PLUGIN_NAME = 'x'\ndef check(f, c=None): pass")
(tmp_path / "__init__.py").write_text("")
result = _scan_directory(tmp_path, source="builtin")
assert result == []
def test_discovers_valid_plugin(self, tmp_path):
plugin_code = "PLUGIN_NAME = 'my-plugin'\ndef check(f, c=None): pass"
(tmp_path / "my_plugin.py").write_text(plugin_code)
result = _scan_directory(tmp_path, source="local")
assert len(result) == 1
assert result[0]["name"] == "my-plugin"
assert result[0]["source"] == "local"
def test_skips_file_without_plugin_name(self, tmp_path):
(tmp_path / "not_a_plugin.py").write_text("def check(f, c=None): pass")
result = _scan_directory(tmp_path, source="local")
assert result == []
def test_skips_file_without_check_function(self, tmp_path):
(tmp_path / "no_check.py").write_text("PLUGIN_NAME = 'x'")
result = _scan_directory(tmp_path, source="local")
assert result == []
def test_skips_broken_plugin_silently(self, tmp_path):
(tmp_path / "broken.py").write_text("raise RuntimeError('boom')")
# Should not raise — broken plugins are silently skipped
result = _scan_directory(tmp_path, source="local")
assert result == []
def test_multiple_plugins_discovered(self, tmp_path):
for i in range(3):
code = f"PLUGIN_NAME = 'plugin-{i}'\ndef check(f, c=None): pass"
(tmp_path / f"plugin_{i}.py").write_text(code)
result = _scan_directory(tmp_path, source="builtin")
assert len(result) == 3
def test_descriptor_has_required_keys(self, tmp_path):
(tmp_path / "p.py").write_text("PLUGIN_NAME = 'p'\ndef check(f, c=None): pass")
result = _scan_directory(tmp_path, source="local")
assert len(result) == 1
descriptor = result[0]
assert "name" in descriptor
assert "module" in descriptor
assert "source" in descriptor
assert "path" in descriptor
class TestDiscoverPlugins:
def test_returns_list(self, tmp_project):
result = discover_plugins(str(tmp_project))
assert isinstance(result, list)
def test_discovers_local_plugin(self, tmp_project, simple_plugin_file):
result = discover_plugins(str(tmp_project))
names = [p["name"] for p in result]
assert "test-plugin" in names
def test_local_plugin_source_is_local(self, tmp_project, simple_plugin_file):
result = discover_plugins(str(tmp_project))
local_plugins = [p for p in result if p["source"] == "local"]
assert len(local_plugins) >= 1
def test_deduplication_last_wins(self, tmp_project):
"""Two plugins with the same name — the last one (local) wins."""
# Create a builtin-style plugin by patching the builtin directory
local_code = "PLUGIN_NAME = 'dupe-plugin'\ndef check(f, c=None): return 'local'"
(tmp_project / ".seedgo" / "plugins" / "dupe.py").write_text(local_code)
result = discover_plugins(str(tmp_project))
dupe = next((p for p in result if p["name"] == "dupe-plugin"), None)
assert dupe is not None
assert dupe["source"] == "local"
def test_no_project_root_still_works(self):
result = discover_plugins(project_root=None)
assert isinstance(result, list)
def test_plugin_descriptor_has_module(self, tmp_project, simple_plugin_file):
result = discover_plugins(str(tmp_project))
plugin = next((p for p in result if p["name"] == "test-plugin"), None)
assert plugin is not None
assert hasattr(plugin["module"], "check")
assert hasattr(plugin["module"], "PLUGIN_NAME")
# ---------------------------------------------------------------------------
# Exception hierarchy tests
# ---------------------------------------------------------------------------
class TestExceptions:
def test_seedgo_error_is_exception(self):
assert issubclass(SeedGoError, Exception)
def test_config_error_inherits_seedgo_error(self):
assert issubclass(ConfigError, SeedGoError)
def test_plugin_error_inherits_seedgo_error(self):
assert issubclass(PluginError, SeedGoError)
def test_discovery_error_inherits_seedgo_error(self):
assert issubclass(DiscoveryError, SeedGoError)
def test_config_error_can_be_raised_and_caught(self):
with pytest.raises(ConfigError):
raise ConfigError("bad config")
def test_plugin_error_can_be_raised_and_caught(self):
with pytest.raises(PluginError):
raise PluginError("bad plugin")
def test_discovery_error_can_be_raised_and_caught(self):
with pytest.raises(DiscoveryError):
raise DiscoveryError("discovery failed")
def test_catch_all_via_seedgo_error(self):
"""All custom exceptions should be catchable via SeedGoError."""
for exc_class in (ConfigError, PluginError, DiscoveryError):
with pytest.raises(SeedGoError):
raise exc_class("test")
def test_error_message_preserved(self):
try:
raise ConfigError("specific message")
except ConfigError as e:
assert "specific message" in str(e)
# ---------------------------------------------------------------------------
# Public API / __init__ tests
# ---------------------------------------------------------------------------
class TestPublicAPI:
def test_version_is_string(self):
import seedgo
assert isinstance(seedgo.__version__, str)
def test_version_format(self):
import seedgo
parts = seedgo.__version__.split(".")
assert len(parts) == 3
assert all(p.isdigit() for p in parts)
def test_version_is_1_0_0(self):
import seedgo
assert seedgo.__version__ == "1.0.0"
def test_check_result_importable_from_seedgo(self):
from seedgo import CheckResult
assert CheckResult is not None
def test_check_item_importable_from_seedgo(self):
from seedgo import CheckItem
assert CheckItem is not None
def test_severity_importable_from_seedgo(self):
from seedgo import Severity
assert Severity is not None
def test_discover_plugins_importable_from_seedgo(self):
from seedgo import discover_plugins
assert callable(discover_plugins)
def test_load_config_importable_from_seedgo(self):
from seedgo import load_config
assert callable(load_config)
def test_all_exports_listed_in_dunder_all(self):
import seedgo
for name in ["CheckResult", "CheckItem", "Severity", "discover_plugins", "load_config"]:
assert name in seedgo.__all__
+772
View File
@@ -0,0 +1,772 @@
"""
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 sys
from pathlib import Path
import pytest
# Ensure src/ is on the path when running from the repo root
_repo_root = Path(__file__).parent.parent
_src_path = str(_repo_root / "src")
if _src_path not in sys.path:
sys.path.insert(0, _src_path)
# Import plugin modules directly for unit testing
import importlib.util
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