diff --git a/.seedgo/config.json b/.seedgo/config.json new file mode 100644 index 00000000..f66c994f --- /dev/null +++ b/.seedgo/config.json @@ -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": [] +} diff --git a/README.md b/README.md index 26ebe759..4174ccc1 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/pyproject.toml b/pyproject.toml index ffe6afec..2c454e54 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/pyrightconfig.json b/pyrightconfig.json new file mode 100644 index 00000000..1d09e831 --- /dev/null +++ b/pyrightconfig.json @@ -0,0 +1,6 @@ +{ + "extraPaths": ["src"], + "pythonVersion": "3.10", + "reportMissingImports": "error", + "reportAttributeAccessIssue": "error" +} diff --git a/src/seedgo/__init__.py b/src/seedgo/__init__.py new file mode 100644 index 00000000..fad0e849 --- /dev/null +++ b/src/seedgo/__init__.py @@ -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__", +] diff --git a/src/seedgo/bypass.py b/src/seedgo/bypass.py new file mode 100644 index 00000000..e8994fbf --- /dev/null +++ b/src/seedgo/bypass.py @@ -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 diff --git a/src/seedgo/cli.py b/src/seedgo/cli.py new file mode 100644 index 00000000..f2e914ce --- /dev/null +++ b/src/seedgo/cli.py @@ -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) diff --git a/src/seedgo/config.py b/src/seedgo/config.py new file mode 100644 index 00000000..cf4f5ada --- /dev/null +++ b/src/seedgo/config.py @@ -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/.json + 2. Built-in seedgo/profiles/.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]}") diff --git a/src/seedgo/discovery.py b/src/seedgo/discovery.py new file mode 100644 index 00000000..75ac551c --- /dev/null +++ b/src/seedgo/discovery.py @@ -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:", 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:". + """ + 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 diff --git a/src/seedgo/exceptions.py b/src/seedgo/exceptions.py new file mode 100644 index 00000000..bcecb2a2 --- /dev/null +++ b/src/seedgo/exceptions.py @@ -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). + """ diff --git a/src/seedgo/models.py b/src/seedgo/models.py new file mode 100644 index 00000000..30679755 --- /dev/null +++ b/src/seedgo/models.py @@ -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) diff --git a/src/seedgo/plugins/__init__.py b/src/seedgo/plugins/__init__.py new file mode 100644 index 00000000..2a52dae6 --- /dev/null +++ b/src/seedgo/plugins/__init__.py @@ -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/. +""" diff --git a/src/seedgo/plugins/docstring_coverage.py b/src/seedgo/plugins/docstring_coverage.py new file mode 100644 index 00000000..26b2ddb5 --- /dev/null +++ b/src/seedgo/plugins/docstring_coverage.py @@ -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 diff --git a/src/seedgo/plugins/file_structure.py b/src/seedgo/plugins/file_structure.py new file mode 100644 index 00000000..d4929bba --- /dev/null +++ b/src/seedgo/plugins/file_structure.py @@ -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 diff --git a/src/seedgo/plugins/function_length.py b/src/seedgo/plugins/function_length.py new file mode 100644 index 00000000..3d8903a6 --- /dev/null +++ b/src/seedgo/plugins/function_length.py @@ -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, + }, + ) diff --git a/src/seedgo/plugins/no_bare_except.py b/src/seedgo/plugins/no_bare_except.py new file mode 100644 index 00000000..0363368a --- /dev/null +++ b/src/seedgo/plugins/no_bare_except.py @@ -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)}, + ) diff --git a/src/seedgo/plugins/type_hints_required.py b/src/seedgo/plugins/type_hints_required.py new file mode 100644 index 00000000..8398074c --- /dev/null +++ b/src/seedgo/plugins/type_hints_required.py @@ -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 diff --git a/src/seedgo/reporter.py b/src/seedgo/reporter.py new file mode 100644 index 00000000..caec2fb4 --- /dev/null +++ b/src/seedgo/reporter.py @@ -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) diff --git a/src/seedgo/runner.py b/src/seedgo/runner.py new file mode 100644 index 00000000..bf864bf4 --- /dev/null +++ b/src/seedgo/runner.py @@ -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 diff --git a/tests/test_seedgo_cli.py b/tests/test_seedgo_cli.py new file mode 100644 index 00000000..8403c626 --- /dev/null +++ b/tests/test_seedgo_cli.py @@ -0,0 +1,1209 @@ +""" +Seed Go Phase 2 Tests — CLI, Runner, and Reporter + +Covers: + - runner.py: calculate_score, calculate_overall, run_checks, file matching + - reporter.py: human / json / github output formats + - cli.py: init / check / list commands via argparse + subprocess + +All tests use tmp_path for isolation. Mock plugins are written as .py files +into .seedgo/plugins/ — no monkeypatching of discovery needed. + +sys.path fix ensures src/ is importable when running from the repo root: + python -m pytest tests/test_seedgo_cli.py -v +""" + +import json +import sys +from pathlib import Path + +import pytest + +# --------------------------------------------------------------------------- +# sys.path fix — must appear before any seedgo imports +# --------------------------------------------------------------------------- + +_src_path = str(Path(__file__).parent.parent / "src") +if _src_path not in sys.path: + sys.path.insert(0, _src_path) + +from seedgo.models import CheckItem, CheckResult, Severity +from seedgo.runner import ( + _file_matches_types, + _find_project_files, + calculate_overall, + calculate_score, + run_checks, +) +from seedgo.reporter import report_results + + +# --------------------------------------------------------------------------- +# Shared fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def tmp_project(tmp_path): + """Minimal project root with .seedgo/ directory tree.""" + seedgo_dir = tmp_path / ".seedgo" + seedgo_dir.mkdir() + (seedgo_dir / "plugins").mkdir() + return tmp_path + + +@pytest.fixture +def passing_plugin(tmp_project): + """A plugin that always passes for *.py files.""" + code = '''\ +PLUGIN_NAME = "always-pass" +PLUGIN_DESCRIPTION = "Always returns a passing result" +FILE_TYPES = ["*.py"] + +import sys +_src = str(__import__("pathlib").Path(__file__).parent.parent.parent.parent.parent / "src") +if _src not in sys.path: + sys.path.insert(0, _src) + +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="All good")], + score=100, + file_path=file_path, + ) +''' + (tmp_project / ".seedgo" / "plugins" / "always_pass.py").write_text(code) + return tmp_project + + +@pytest.fixture +def failing_plugin(tmp_project): + """A plugin that always fails with one ERROR for *.py files.""" + code = '''\ +PLUGIN_NAME = "always-fail" +PLUGIN_DESCRIPTION = "Always returns a failing result" +FILE_TYPES = ["*.py"] + +import sys +_src = str(__import__("pathlib").Path(__file__).parent.parent.parent.parent.parent / "src") +if _src not in sys.path: + sys.path.insert(0, _src) + +from seedgo.models import CheckResult, CheckItem, Severity + +def check(file_path, config=None): + return CheckResult( + plugin=PLUGIN_NAME, + passed=False, + checks=[ + CheckItem( + name="always-fails", + passed=False, + message="This check always fails", + severity=Severity.ERROR, + line=1, + fix_hint="Cannot fix", + ) + ], + score=0, + file_path=file_path, + ) +''' + (tmp_project / ".seedgo" / "plugins" / "always_fail.py").write_text(code) + return tmp_project + + +@pytest.fixture +def warning_plugin(tmp_project): + """A plugin that returns a WARNING-level failure for *.py files.""" + code = '''\ +PLUGIN_NAME = "warn-plugin" +PLUGIN_DESCRIPTION = "Returns a warning" +FILE_TYPES = ["*.py"] + +import sys +_src = str(__import__("pathlib").Path(__file__).parent.parent.parent.parent.parent / "src") +if _src not in sys.path: + sys.path.insert(0, _src) + +from seedgo.models import CheckResult, CheckItem, Severity + +def check(file_path, config=None): + return CheckResult( + plugin=PLUGIN_NAME, + passed=False, + checks=[ + CheckItem( + name="warns", + passed=False, + message="A warning issue", + severity=Severity.WARNING, + line=5, + ) + ], + score=50, + file_path=file_path, + ) +''' + (tmp_project / ".seedgo" / "plugins" / "warn_plugin.py").write_text(code) + return tmp_project + + +@pytest.fixture +def sample_py_file(tmp_project): + """A Python source file to check.""" + src = tmp_project / "src" + src.mkdir() + f = src / "main.py" + f.write_text("def hello(): pass\n") + return f + + +# --------------------------------------------------------------------------- +# calculate_score tests +# --------------------------------------------------------------------------- + + +class TestCalculateScore: + def test_no_checks_returns_100(self): + result = CheckResult(plugin="p", passed=True, checks=[]) + assert calculate_score(result, {}) == 100 + + def test_all_passing_checks_returns_100(self): + result = CheckResult( + plugin="p", + passed=True, + checks=[ + CheckItem(name="c1", passed=True, message="ok", severity=Severity.ERROR), + CheckItem(name="c2", passed=True, message="ok", severity=Severity.WARNING), + ], + ) + assert calculate_score(result, {}) == 100 + + def test_all_failing_errors_returns_0(self): + result = CheckResult( + plugin="p", + passed=False, + checks=[ + CheckItem(name="c1", passed=False, message="fail", severity=Severity.ERROR), + CheckItem(name="c2", passed=False, message="fail", severity=Severity.ERROR), + ], + ) + assert calculate_score(result, {}) == 0 + + def test_half_errors_failing_returns_50(self): + result = CheckResult( + plugin="p", + passed=False, + checks=[ + CheckItem(name="c1", passed=True, message="ok", severity=Severity.ERROR), + CheckItem(name="c2", passed=False, message="fail", severity=Severity.ERROR), + ], + ) + assert calculate_score(result, {}) == 50 + + def test_warning_has_half_weight_by_default(self): + """One passing ERROR (weight 1.0) + one failing WARNING (weight 0.5) = 1/1.5 * 100 = 66.""" + result = CheckResult( + plugin="p", + passed=False, + checks=[ + CheckItem(name="c1", passed=True, message="ok", severity=Severity.ERROR), + CheckItem(name="c2", passed=False, message="warn", severity=Severity.WARNING), + ], + ) + score = calculate_score(result, {}) + assert score == int((1.0 / 1.5) * 100) # 66 + + def test_info_has_zero_weight_by_default(self): + """INFO items don't affect score — one failing INFO should still give 100.""" + result = CheckResult( + plugin="p", + passed=True, + checks=[ + CheckItem(name="c1", passed=False, message="info", severity=Severity.INFO), + ], + ) + assert calculate_score(result, {}) == 100 + + def test_custom_weights_from_config(self): + """Custom weights override defaults.""" + config = {"error_weight": 2.0, "warning_weight": 1.0, "info_weight": 0.0} + result = CheckResult( + plugin="p", + passed=False, + checks=[ + CheckItem(name="c1", passed=True, message="ok", severity=Severity.ERROR), + CheckItem(name="c2", passed=False, message="fail", severity=Severity.WARNING), + ], + ) + # passed_weight = 2.0 (ERROR passed), total_weight = 2.0 + 1.0 = 3.0 + # score = int(2/3 * 100) = 66 + score = calculate_score(result, config) + assert score == int((2.0 / 3.0) * 100) + + def test_all_info_checks_returns_100(self): + """All INFO (zero weight) → total_weight == 0 → score = 100.""" + result = CheckResult( + plugin="p", + passed=True, + checks=[ + CheckItem(name="i1", passed=False, message="info", severity=Severity.INFO), + CheckItem(name="i2", passed=False, message="info", severity=Severity.INFO), + ], + ) + assert calculate_score(result, {}) == 100 + + +# --------------------------------------------------------------------------- +# calculate_overall tests +# --------------------------------------------------------------------------- + + +class TestCalculateOverall: + def test_empty_results_returns_100_pass(self): + overall = calculate_overall([], {}) + assert overall["overall_score"] == 100 + assert overall["passed"] is True + assert overall["plugins_passed"] == 0 + assert overall["plugins_failed"] == 0 + assert overall["error_count"] == 0 + + def test_single_passing_result(self): + results = [ + CheckResult( + plugin="p", + passed=True, + checks=[CheckItem(name="c", passed=True, message="ok")], + ) + ] + overall = calculate_overall(results, {}) + assert overall["overall_score"] == 100 + assert overall["passed"] is True + assert overall["plugins_passed"] == 1 + assert overall["plugins_failed"] == 0 + + def test_single_failing_result_with_error(self): + results = [ + CheckResult( + plugin="p", + passed=False, + checks=[ + CheckItem(name="c", passed=False, message="fail", severity=Severity.ERROR) + ], + ) + ] + overall = calculate_overall(results, {}) + assert overall["overall_score"] == 0 + assert overall["passed"] is False + assert overall["error_count"] == 1 + + def test_error_blocks_pass_even_at_high_score(self): + """An ERROR-severity failure must block pass even if score is above threshold.""" + results = [ + CheckResult( + plugin="p", + passed=False, + checks=[ + CheckItem(name="ok1", passed=True, message="ok", severity=Severity.ERROR), + CheckItem(name="ok2", passed=True, message="ok", severity=Severity.ERROR), + CheckItem(name="ok3", passed=True, message="ok", severity=Severity.ERROR), + CheckItem(name="fail", passed=False, message="fail", severity=Severity.ERROR), + ], + ) + ] + overall = calculate_overall(results, {"threshold": 70}) + # Score = 75 (3/4 * 100), above threshold=70 — but error blocks pass + assert overall["overall_score"] == 75 + assert overall["error_count"] == 1 + assert overall["passed"] is False + + def test_warning_does_not_block_pass(self): + """A WARNING-only failure should allow passing if score >= threshold.""" + results = [ + CheckResult( + plugin="p", + passed=False, + checks=[ + CheckItem(name="ok1", passed=True, message="ok", severity=Severity.ERROR), + CheckItem(name="warn", passed=False, message="warn", severity=Severity.WARNING), + ], + ) + ] + # passed_weight = 1.0, total_weight = 1.5, score = 66 + overall = calculate_overall(results, {"threshold": 60}) + assert overall["error_count"] == 0 + assert overall["warning_count"] == 1 + assert overall["passed"] is True # score 66 >= threshold 60, no errors + + def test_overall_score_is_mean_of_results(self): + """overall_score should be the mean of individual result scores.""" + r1 = CheckResult( + plugin="p1", + passed=True, + checks=[CheckItem(name="c", passed=True, message="ok", severity=Severity.ERROR)], + ) + r2 = CheckResult( + plugin="p2", + passed=False, + checks=[CheckItem(name="c", passed=False, message="fail", severity=Severity.ERROR)], + ) + overall = calculate_overall([r1, r2], {}) + # r1 score = 100, r2 score = 0, mean = 50 + assert overall["overall_score"] == 50 + + def test_threshold_respected(self): + results = [ + CheckResult( + plugin="p", + passed=True, + checks=[CheckItem(name="c", passed=True, message="ok")], + ) + ] + overall = calculate_overall(results, {"threshold": 90}) + assert overall["threshold"] == 90 + + def test_info_count_tracked(self): + results = [ + CheckResult( + plugin="p", + passed=False, + checks=[ + CheckItem(name="info", passed=False, message="info", severity=Severity.INFO), + ], + ) + ] + overall = calculate_overall(results, {}) + assert overall["info_count"] == 1 + assert overall["error_count"] == 0 + assert overall["warning_count"] == 0 + + def test_plugins_passed_and_failed_counts(self): + results = [ + CheckResult( + plugin="p1", + passed=True, + checks=[CheckItem(name="c", passed=True, message="ok", severity=Severity.ERROR)], + ), + CheckResult( + plugin="p2", + passed=False, + checks=[CheckItem(name="c", passed=False, message="fail", severity=Severity.ERROR)], + ), + CheckResult( + plugin="p3", + passed=False, + checks=[CheckItem(name="c", passed=False, message="fail", severity=Severity.ERROR)], + ), + ] + overall = calculate_overall(results, {"threshold": 75}) + assert overall["plugins_passed"] == 1 + assert overall["plugins_failed"] == 2 + + +# --------------------------------------------------------------------------- +# File matching tests +# --------------------------------------------------------------------------- + + +class TestFileMatchesTypes: + def test_py_pattern_matches_py_file(self): + assert _file_matches_types("/path/to/file.py", ["*.py"]) is True + + def test_py_pattern_does_not_match_js_file(self): + assert _file_matches_types("/path/to/file.js", ["*.py"]) is False + + def test_wildcard_matches_any_file(self): + assert _file_matches_types("/path/to/file.anything", ["*"]) is True + + def test_multiple_patterns_any_match(self): + assert _file_matches_types("/path/to/file.ts", ["*.js", "*.ts"]) is True + + def test_no_patterns_returns_false(self): + assert _file_matches_types("/path/to/file.py", []) is False + + def test_specific_filename_match(self): + assert _file_matches_types("/path/to/Makefile", ["Makefile"]) is True + + +class TestFindProjectFiles: + def test_finds_py_files_in_project(self, tmp_project): + src = tmp_project / "src" + src.mkdir() + (src / "main.py").write_text("pass") + (src / "utils.py").write_text("pass") + config = {"paths": {"include": ["."], "exclude": []}} + files = _find_project_files(str(tmp_project), config) + file_names = [Path(f).name for f in files] + assert "main.py" in file_names + assert "utils.py" in file_names + + def test_excludes_patterns(self, tmp_project): + src = tmp_project / "src" + tests = tmp_project / "tests" + src.mkdir() + tests.mkdir() + (src / "main.py").write_text("pass") + (tests / "test_main.py").write_text("pass") + config = {"paths": {"include": ["."], "exclude": ["tests/"]}} + files = _find_project_files(str(tmp_project), config) + file_names = [Path(f).name for f in files] + assert "main.py" in file_names + assert "test_main.py" not in file_names + + def test_specific_include_path(self, tmp_project): + src = tmp_project / "src" + other = tmp_project / "other" + src.mkdir() + other.mkdir() + (src / "main.py").write_text("pass") + (other / "other.py").write_text("pass") + config = {"paths": {"include": ["src"], "exclude": []}} + files = _find_project_files(str(tmp_project), config) + file_names = [Path(f).name for f in files] + assert "main.py" in file_names + assert "other.py" not in file_names + + +# --------------------------------------------------------------------------- +# run_checks integration tests +# --------------------------------------------------------------------------- + + +class TestRunChecks: + def test_no_plugins_returns_empty_results_and_pass(self, tmp_project, sample_py_file): + # Filter to a nonexistent plugin name so builtins are also excluded + results, overall = run_checks(str(tmp_project), files=[str(sample_py_file)], plugins=["nonexistent-plugin-xyz"]) + assert results == [] + assert overall["passed"] is True + assert overall["overall_score"] == 100 + + def test_passing_plugin_gives_pass(self, passing_plugin, sample_py_file): + # Filter to only the local always-pass plugin to isolate from builtins + results, overall = run_checks(str(passing_plugin), files=[str(sample_py_file)], plugins=["always-pass"]) + assert len(results) == 1 + assert results[0].passed is True + assert overall["passed"] is True + + def test_failing_plugin_gives_fail(self, failing_plugin, sample_py_file): + # Filter to only the local always-fail plugin to isolate from builtins + results, overall = run_checks(str(failing_plugin), files=[str(sample_py_file)], plugins=["always-fail"]) + assert len(results) == 1 + assert results[0].passed is False + assert overall["passed"] is False + assert overall["error_count"] == 1 + + def test_plugin_filter_restricts_plugins(self, passing_plugin, failing_plugin, sample_py_file): + """When plugins filter is given, only those plugins should run.""" + # Both plugins are in the same project dir (failing_plugin fixture modifies passing_plugin's dir) + # We need a project with both; use the failing_plugin fixture which wraps passing_plugin + # Actually since fixtures use the same tmp_project, we can't easily combine them. + # Instead test with a fresh project that has one plugin, filtered out. + results, overall = run_checks( + str(passing_plugin), + files=[str(sample_py_file)], + plugins=["nonexistent-plugin"], + ) + assert results == [] + assert overall["passed"] is True + + def test_file_type_filtering(self, passing_plugin): + """Plugin with FILE_TYPES=['*.py'] should not run on .txt files.""" + txt_file = passing_plugin / "src" / "readme.txt" + (passing_plugin / "src").mkdir(exist_ok=True) + txt_file.write_text("hello") + results, overall = run_checks(str(passing_plugin), files=[str(txt_file)]) + assert results == [] + + def test_explicit_files_override_discovery(self, passing_plugin, sample_py_file): + # Filter to only the local always-pass plugin to isolate from builtins + results, overall = run_checks(str(passing_plugin), files=[str(sample_py_file)], plugins=["always-pass"]) + # Only the one file we specified should be checked + assert len(results) == 1 + assert results[0].file_path == str(sample_py_file) + + def test_score_set_on_results(self, passing_plugin, sample_py_file): + results, _ = run_checks(str(passing_plugin), files=[str(sample_py_file)]) + assert results[0].score == 100 + + def test_crashed_plugin_returns_failed_result(self, tmp_project, sample_py_file): + """A plugin that raises an exception should return a failed CheckResult, not crash.""" + code = '''\ +PLUGIN_NAME = "crash-plugin" +PLUGIN_DESCRIPTION = "Always crashes" +FILE_TYPES = ["*.py"] + +def check(file_path, config=None): + raise RuntimeError("kaboom") +''' + (tmp_project / ".seedgo" / "plugins" / "crash_plugin.py").write_text(code) + # Filter to only the crash plugin to isolate from builtins + results, overall = run_checks(str(tmp_project), files=[str(sample_py_file)], plugins=["crash-plugin"]) + assert len(results) == 1 + assert results[0].passed is False + assert results[0].score == 0 + assert "kaboom" in results[0].metadata.get("error", "") + + +# --------------------------------------------------------------------------- +# Reporter tests +# --------------------------------------------------------------------------- + + +class TestReportHuman: + def _make_result(self, passed=True, score=100, checks=None): + return CheckResult( + plugin="test-plugin", + passed=passed, + checks=checks or [], + score=score, + file_path="/path/to/file.py", + ) + + def _overall(self, score=100, passed=True, threshold=75): + return { + "overall_score": score, + "passed": passed, + "threshold": threshold, + "plugins_passed": 1 if passed else 0, + "plugins_failed": 0 if passed else 1, + "error_count": 0, + "warning_count": 0, + "info_count": 0, + } + + def test_output_contains_plugin_name(self): + result = self._make_result() + output = report_results([result], self._overall()) + assert "test-plugin" in output + + def test_passing_result_contains_pass(self): + result = self._make_result(passed=True, score=100) + output = report_results([result], self._overall(score=100, passed=True)) + assert "PASS" in output + + def test_failing_result_contains_fail(self): + checks = [CheckItem(name="c", passed=False, message="fail", severity=Severity.ERROR)] + result = self._make_result(passed=False, score=0, checks=checks) + output = report_results([result], self._overall(score=0, passed=False)) + assert "FAIL" in output + + def test_score_shown_in_output(self): + result = self._make_result(score=75) + output = report_results([result], self._overall(score=75)) + assert "75" in output + + def test_check_item_message_shown(self): + checks = [CheckItem(name="bare-except", passed=False, message="Found bare except", severity=Severity.ERROR)] + result = self._make_result(passed=False, checks=checks) + output = report_results([result], self._overall(passed=False)) + assert "Found bare except" in output + + def test_fix_hint_shown_for_failing_check(self): + checks = [ + CheckItem( + name="c", + passed=False, + message="fail", + severity=Severity.ERROR, + fix_hint="Use except Exception:", + ) + ] + result = self._make_result(passed=False, checks=checks) + output = report_results([result], self._overall(passed=False)) + assert "Use except Exception:" in output + + def test_no_results_shows_no_checks_ran(self): + output = report_results([], self._overall()) + assert "No checks ran" in output + + def test_threshold_shown_in_summary(self): + result = self._make_result() + output = report_results([result], self._overall(threshold=80)) + assert "80" in output + + def test_line_number_shown_when_present(self): + checks = [CheckItem(name="c", passed=False, message="fail", severity=Severity.ERROR, line=42)] + result = self._make_result(passed=False, checks=checks) + output = report_results([result], self._overall(passed=False)) + assert "42" in output + + def test_invalid_format_raises_value_error(self): + with pytest.raises(ValueError, match="Unknown format"): + report_results([], {}, format="xml") + + +class TestReportJSON: + def test_output_is_valid_json(self): + results = [CheckResult(plugin="p", passed=True, checks=[], score=100, file_path="/f.py")] + overall = { + "overall_score": 100, "passed": True, "threshold": 75, + "plugins_passed": 1, "plugins_failed": 0, + "error_count": 0, "warning_count": 0, "info_count": 0, + } + output = report_results(results, overall, format="json") + data = json.loads(output) + assert isinstance(data, dict) + + def test_json_contains_overall_score(self): + results = [CheckResult(plugin="p", passed=True, checks=[], score=100, file_path="/f.py")] + overall = { + "overall_score": 88, "passed": True, "threshold": 75, + "plugins_passed": 1, "plugins_failed": 0, + "error_count": 0, "warning_count": 0, "info_count": 0, + } + data = json.loads(report_results(results, overall, format="json")) + assert data["overall_score"] == 88 + + def test_json_contains_results_list(self): + results = [CheckResult(plugin="p", passed=True, checks=[], score=100, file_path="/f.py")] + overall = { + "overall_score": 100, "passed": True, "threshold": 75, + "plugins_passed": 1, "plugins_failed": 0, + "error_count": 0, "warning_count": 0, "info_count": 0, + } + data = json.loads(report_results(results, overall, format="json")) + assert "results" in data + assert len(data["results"]) == 1 + assert data["results"][0]["plugin"] == "p" + + def test_json_severity_is_string(self): + """Severity enum must be serialized as string in JSON output.""" + checks = [CheckItem(name="c", passed=False, message="fail", severity=Severity.ERROR)] + results = [CheckResult(plugin="p", passed=False, checks=checks, score=0, file_path="/f.py")] + overall = { + "overall_score": 0, "passed": False, "threshold": 75, + "plugins_passed": 0, "plugins_failed": 1, + "error_count": 1, "warning_count": 0, "info_count": 0, + } + data = json.loads(report_results(results, overall, format="json")) + sev = data["results"][0]["checks"][0]["severity"] + assert isinstance(sev, str) + assert sev == "error" + + def test_json_passed_field_present(self): + results = [] + overall = { + "overall_score": 100, "passed": True, "threshold": 75, + "plugins_passed": 0, "plugins_failed": 0, + "error_count": 0, "warning_count": 0, "info_count": 0, + } + data = json.loads(report_results(results, overall, format="json")) + assert "passed" in data + + +class TestReportGitHub: + def test_error_produces_error_annotation(self): + checks = [ + CheckItem( + name="c", + passed=False, + message="Missing type hint", + severity=Severity.ERROR, + line=42, + ) + ] + results = [CheckResult(plugin="type-hints", passed=False, checks=checks, score=0, file_path="src/main.py")] + overall = { + "overall_score": 0, "passed": False, "threshold": 75, + "plugins_passed": 0, "plugins_failed": 1, + "error_count": 1, "warning_count": 0, "info_count": 0, + } + output = report_results(results, overall, format="github") + assert "::error" in output + assert "Missing type hint" in output + + def test_warning_produces_warning_annotation(self): + checks = [ + CheckItem( + name="c", + passed=False, + message="Class too large", + severity=Severity.WARNING, + line=15, + ) + ] + results = [CheckResult(plugin="no-god-objects", passed=False, checks=checks, score=50, file_path="src/models.py")] + overall = { + "overall_score": 50, "passed": False, "threshold": 75, + "plugins_passed": 0, "plugins_failed": 1, + "error_count": 0, "warning_count": 1, "info_count": 0, + } + output = report_results(results, overall, format="github") + assert "::warning" in output + assert "Class too large" in output + + def test_file_and_line_in_annotation(self): + checks = [ + CheckItem(name="c", passed=False, message="msg", severity=Severity.ERROR, line=7) + ] + results = [CheckResult(plugin="p", passed=False, checks=checks, score=0, file_path="/some/file.py")] + overall = { + "overall_score": 0, "passed": False, "threshold": 75, + "plugins_passed": 0, "plugins_failed": 1, + "error_count": 1, "warning_count": 0, "info_count": 0, + } + output = report_results(results, overall, format="github") + assert "file=/some/file.py" in output + assert "line=7" in output + + def test_passing_checks_not_in_github_output(self): + checks = [ + CheckItem(name="ok", passed=True, message="all good", severity=Severity.ERROR), + ] + results = [CheckResult(plugin="p", passed=True, checks=checks, score=100, file_path="/f.py")] + overall = { + "overall_score": 100, "passed": True, "threshold": 75, + "plugins_passed": 1, "plugins_failed": 0, + "error_count": 0, "warning_count": 0, "info_count": 0, + } + output = report_results(results, overall, format="github") + assert "::error" not in output + assert "::warning" not in output + + def test_no_results_produces_empty_output(self): + overall = { + "overall_score": 100, "passed": True, "threshold": 75, + "plugins_passed": 0, "plugins_failed": 0, + "error_count": 0, "warning_count": 0, "info_count": 0, + } + output = report_results([], overall, format="github") + assert output == "" + + def test_plugin_name_included_in_annotation(self): + checks = [CheckItem(name="c", passed=False, message="fail", severity=Severity.ERROR)] + results = [CheckResult(plugin="my-plugin", passed=False, checks=checks, score=0, file_path="/f.py")] + overall = { + "overall_score": 0, "passed": False, "threshold": 75, + "plugins_passed": 0, "plugins_failed": 1, + "error_count": 1, "warning_count": 0, "info_count": 0, + } + output = report_results(results, overall, format="github") + assert "my-plugin" in output + + +# --------------------------------------------------------------------------- +# CLI tests — via subprocess to test the real entry point +# --------------------------------------------------------------------------- + + +import subprocess + + +class TestCLIInit: + def test_init_creates_config_file(self, tmp_path): + result = subprocess.run( + [sys.executable, "-m", "seedgo.cli"], + input="", + capture_output=True, + text=True, + cwd=str(tmp_path), + env={**__import__("os").environ, "PYTHONPATH": _src_path}, + ) + # The above runs cli as module — we need the main() entry + # Actually test by calling main() directly via runner script + pass # Covered by direct function tests below + + def test_init_via_main_creates_config(self, tmp_path): + """Call _cmd_init directly to test config creation.""" + from seedgo.cli import _cmd_init + import argparse + + args = argparse.Namespace(profile=None) + old_cwd = __import__("os").getcwd() + try: + __import__("os").chdir(str(tmp_path)) + _cmd_init(args) + finally: + __import__("os").chdir(old_cwd) + + assert (tmp_path / ".seedgo" / "config.json").exists() + + def test_init_with_profile_embeds_profile(self, tmp_path): + from seedgo.cli import _cmd_init + import argparse + + args = argparse.Namespace(profile="python-basic") + old_cwd = __import__("os").getcwd() + try: + __import__("os").chdir(str(tmp_path)) + _cmd_init(args) + finally: + __import__("os").chdir(old_cwd) + + config_path = tmp_path / ".seedgo" / "config.json" + data = json.loads(config_path.read_text()) + assert data["profile"] == "python-basic" + + def test_init_fails_if_config_exists(self, tmp_project): + """If config already exists, init should exit with code 1.""" + from seedgo.cli import _cmd_init + import argparse + + (tmp_project / ".seedgo" / "config.json").write_text("{}") + args = argparse.Namespace(profile=None) + old_cwd = __import__("os").getcwd() + try: + __import__("os").chdir(str(tmp_project)) + with pytest.raises(SystemExit) as exc_info: + _cmd_init(args) + finally: + __import__("os").chdir(old_cwd) + + assert exc_info.value.code == 1 + + def test_init_creates_plugins_dir(self, tmp_path): + from seedgo.cli import _cmd_init + import argparse + + args = argparse.Namespace(profile=None) + old_cwd = __import__("os").getcwd() + try: + __import__("os").chdir(str(tmp_path)) + _cmd_init(args) + finally: + __import__("os").chdir(old_cwd) + + assert (tmp_path / ".seedgo" / "plugins").is_dir() + + +class TestCLICheck: + def test_check_with_no_plugins_exits_0(self, tmp_project, sample_py_file): + """No matching plugins = no failures = exit code 0.""" + from seedgo.cli import _cmd_check + import argparse + + # Filter to a nonexistent plugin so builtins are also excluded + args = argparse.Namespace( + files=[str(sample_py_file)], + format="human", + threshold=None, + plugins=["nonexistent-plugin-xyz"], + ) + old_cwd = __import__("os").getcwd() + try: + __import__("os").chdir(str(tmp_project)) + with pytest.raises(SystemExit) as exc_info: + _cmd_check(args) + finally: + __import__("os").chdir(old_cwd) + + assert exc_info.value.code == 0 + + def test_check_with_passing_plugin_exits_0(self, passing_plugin, sample_py_file): + from seedgo.cli import _cmd_check + import argparse + + # Filter to only the local always-pass plugin to isolate from builtins + args = argparse.Namespace( + files=[str(sample_py_file)], + format="human", + threshold=None, + plugins=["always-pass"], + ) + old_cwd = __import__("os").getcwd() + try: + __import__("os").chdir(str(passing_plugin)) + with pytest.raises(SystemExit) as exc_info: + _cmd_check(args) + finally: + __import__("os").chdir(old_cwd) + + assert exc_info.value.code == 0 + + def test_check_with_failing_plugin_exits_1(self, failing_plugin, sample_py_file): + from seedgo.cli import _cmd_check + import argparse + + args = argparse.Namespace( + files=[str(sample_py_file)], + format="human", + threshold=None, + plugins=None, + ) + old_cwd = __import__("os").getcwd() + try: + __import__("os").chdir(str(failing_plugin)) + with pytest.raises(SystemExit) as exc_info: + _cmd_check(args) + finally: + __import__("os").chdir(old_cwd) + + assert exc_info.value.code == 1 + + def test_check_json_format_produces_valid_json(self, passing_plugin, sample_py_file, capsys): + from seedgo.cli import _cmd_check + import argparse + + args = argparse.Namespace( + files=[str(sample_py_file)], + format="json", + threshold=None, + plugins=None, + ) + old_cwd = __import__("os").getcwd() + try: + __import__("os").chdir(str(passing_plugin)) + with pytest.raises(SystemExit): + _cmd_check(args) + finally: + __import__("os").chdir(old_cwd) + + captured = capsys.readouterr() + data = json.loads(captured.out) + assert "overall_score" in data + + def test_check_threshold_override(self, passing_plugin, sample_py_file): + """--threshold 101 should force a fail even with a passing plugin.""" + from seedgo.cli import _cmd_check + import argparse + + args = argparse.Namespace( + files=[str(sample_py_file)], + format="human", + threshold=101, # impossible to reach + plugins=None, + ) + old_cwd = __import__("os").getcwd() + try: + __import__("os").chdir(str(passing_plugin)) + with pytest.raises(SystemExit) as exc_info: + _cmd_check(args) + finally: + __import__("os").chdir(old_cwd) + + assert exc_info.value.code == 1 + + def test_check_without_seedgo_dir_exits_1(self, tmp_path, capsys): + """Running check outside a project (no .seedgo/) should exit 1 with error.""" + from seedgo.cli import _cmd_check + import argparse + + args = argparse.Namespace( + files=[], + format="human", + threshold=None, + plugins=None, + ) + old_cwd = __import__("os").getcwd() + try: + __import__("os").chdir(str(tmp_path)) + with pytest.raises(SystemExit) as exc_info: + _cmd_check(args) + finally: + __import__("os").chdir(old_cwd) + + assert exc_info.value.code == 1 + + +class TestCLIList: + def test_list_with_no_plugins_prints_message(self, tmp_project, capsys): + # With builtin plugins now shipping, list always shows at least the builtins. + # The test verifies list exits 0 and produces output — specific message + # check updated to reflect that builtins are always discovered. + from seedgo.cli import _cmd_list + import argparse + + args = argparse.Namespace() + old_cwd = __import__("os").getcwd() + try: + __import__("os").chdir(str(tmp_project)) + with pytest.raises(SystemExit) as exc_info: + _cmd_list(args) + finally: + __import__("os").chdir(old_cwd) + + captured = capsys.readouterr() + assert exc_info.value.code == 0 + # Builtins are always present now — output should list them + assert "plugin" in captured.out.lower() + + def test_list_shows_plugin_names(self, passing_plugin, capsys): + from seedgo.cli import _cmd_list + import argparse + + args = argparse.Namespace() + old_cwd = __import__("os").getcwd() + try: + __import__("os").chdir(str(passing_plugin)) + with pytest.raises(SystemExit): + _cmd_list(args) + finally: + __import__("os").chdir(old_cwd) + + captured = capsys.readouterr() + assert "always-pass" in captured.out + + def test_list_shows_source(self, passing_plugin, capsys): + from seedgo.cli import _cmd_list + import argparse + + args = argparse.Namespace() + old_cwd = __import__("os").getcwd() + try: + __import__("os").chdir(str(passing_plugin)) + with pytest.raises(SystemExit): + _cmd_list(args) + finally: + __import__("os").chdir(old_cwd) + + captured = capsys.readouterr() + assert "local" in captured.out + + def test_list_shows_file_types(self, passing_plugin, capsys): + from seedgo.cli import _cmd_list + import argparse + + args = argparse.Namespace() + old_cwd = __import__("os").getcwd() + try: + __import__("os").chdir(str(passing_plugin)) + with pytest.raises(SystemExit): + _cmd_list(args) + finally: + __import__("os").chdir(old_cwd) + + captured = capsys.readouterr() + assert "*.py" in captured.out + + def test_list_exits_0(self, tmp_project): + from seedgo.cli import _cmd_list + import argparse + + args = argparse.Namespace() + old_cwd = __import__("os").getcwd() + try: + __import__("os").chdir(str(tmp_project)) + with pytest.raises(SystemExit) as exc_info: + _cmd_list(args) + finally: + __import__("os").chdir(old_cwd) + + assert exc_info.value.code == 0 + + +# --------------------------------------------------------------------------- +# CLI main() argparse dispatch tests +# --------------------------------------------------------------------------- + + +class TestCLIMain: + """Test the main() entry point's argparse dispatch via sys.argv patching.""" + + def test_main_no_args_exits_0(self): + """main() with no command prints help and exits 0.""" + from seedgo.cli import main + + with pytest.MonkeyPatch().context() as mp: + mp.setattr(sys, "argv", ["seedgo"]) + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 0 + + def test_main_init_command_dispatches(self, tmp_path): + """main() with 'init' dispatches to _cmd_init.""" + from seedgo.cli import main + + with pytest.MonkeyPatch().context() as mp: + mp.setattr(sys, "argv", ["seedgo", "init"]) + mp.chdir(tmp_path) + main() + + assert (tmp_path / ".seedgo" / "config.json").exists() + + def test_main_init_with_profile(self, tmp_path): + """main() with 'init --profile python-basic' passes profile through.""" + from seedgo.cli import main + + with pytest.MonkeyPatch().context() as mp: + mp.setattr(sys, "argv", ["seedgo", "init", "--profile", "python-basic"]) + mp.chdir(tmp_path) + main() + + data = json.loads((tmp_path / ".seedgo" / "config.json").read_text()) + assert data["profile"] == "python-basic" + + def test_main_list_command_dispatches(self, tmp_project, capsys): + """main() with 'list' dispatches to _cmd_list.""" + from seedgo.cli import main + + with pytest.MonkeyPatch().context() as mp: + mp.setattr(sys, "argv", ["seedgo", "list"]) + mp.chdir(tmp_project) + with pytest.raises(SystemExit) as exc_info: + main() + + assert exc_info.value.code == 0 + + def test_main_check_no_seedgo_exits_1(self, tmp_path, capsys): + """main() check outside a project exits 1.""" + from seedgo.cli import main + + with pytest.MonkeyPatch().context() as mp: + mp.setattr(sys, "argv", ["seedgo", "check"]) + mp.chdir(tmp_path) + with pytest.raises(SystemExit) as exc_info: + main() + + assert exc_info.value.code == 1 + + def test_main_check_with_passing_plugin(self, passing_plugin, sample_py_file): + """main() check with passing plugin exits 0.""" + from seedgo.cli import main + + with pytest.MonkeyPatch().context() as mp: + # Filter to only the local always-pass plugin to isolate from builtins + mp.setattr(sys, "argv", ["seedgo", "check", str(sample_py_file), "--plugin", "always-pass"]) + mp.chdir(passing_plugin) + with pytest.raises(SystemExit) as exc_info: + main() + + assert exc_info.value.code == 0 + + def test_main_check_github_format(self, tmp_project, sample_py_file, capsys): + """main() check --format github produces annotation-style output (empty if passing).""" + from seedgo.cli import main + + with pytest.MonkeyPatch().context() as mp: + # Filter to a nonexistent plugin so builtins are also excluded — pure format test + mp.setattr(sys, "argv", [ + "seedgo", "check", str(sample_py_file), + "--format", "github", + "--plugin", "nonexistent-plugin-xyz", + ]) + mp.chdir(tmp_project) + with pytest.raises(SystemExit) as exc_info: + main() + + assert exc_info.value.code == 0 + + def test_main_check_nonexistent_file_exits_1(self, tmp_project, capsys): + """main() check with a file that doesn't exist exits 1.""" + from seedgo.cli import main + + with pytest.MonkeyPatch().context() as mp: + mp.setattr(sys, "argv", ["seedgo", "check", "/nonexistent/file.py"]) + mp.chdir(tmp_project) + with pytest.raises(SystemExit) as exc_info: + main() + + assert exc_info.value.code == 1 + + +# --------------------------------------------------------------------------- +# Public API export test +# --------------------------------------------------------------------------- + + +class TestPublicAPIPhase2: + def test_run_checks_importable_from_seedgo(self): + from seedgo import run_checks as rc + assert callable(rc) + + def test_run_checks_in_dunder_all(self): + import seedgo + assert "run_checks" in seedgo.__all__ diff --git a/tests/test_seedgo_core.py b/tests/test_seedgo_core.py new file mode 100644 index 00000000..81ecb41a --- /dev/null +++ b/tests/test_seedgo_core.py @@ -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__ diff --git a/tests/test_seedgo_plugins.py b/tests/test_seedgo_plugins.py new file mode 100644 index 00000000..6c235979 --- /dev/null +++ b/tests/test_seedgo_plugins.py @@ -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