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:
co-authored by
Claude Opus 4.6
parent
eebd71780b
commit
ba51e747cf
@@ -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": []
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extraPaths": ["src"],
|
||||
"pythonVersion": "3.10",
|
||||
"reportMissingImports": "error",
|
||||
"reportAttributeAccessIssue": "error"
|
||||
}
|
||||
@@ -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__",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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]}")
|
||||
@@ -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
|
||||
@@ -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).
|
||||
"""
|
||||
@@ -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)
|
||||
@@ -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/.
|
||||
"""
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
@@ -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)},
|
||||
)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
@@ -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__
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user