diff --git a/CHANGELOG.md b/CHANGELOG.md index 57a0ee6f..7726979b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,14 @@ PyPI version — not the changelog header. ## [2026-07-21] +**fix(seedgo)** — checker accuracy arc (S330): AST-based import analysis +lands in the checkers (dead_code, encapsulation, handlers, readme, +test_quality, unused_function) — 13 false positives eliminated fleet-wide, +2 real hooks imports that legitimately bypass the pattern documented instead +of suppressed. branch_audit, checklist and ignore_handler aligned; provider +hooks snapshot fixture refreshed; stale bypass entries for deleted tools +purged across branches (devpulse, memory, seedgo, hooks). Fleet audit 100%. + **feat(hooks)** — hook sound layer + temporal grounding. Sounds now mirror the log across the hook fleet (prompt, lifecycle, notification, security handlers) — audible liveness for the whole layer, verified live (2465 green, diff --git a/src/aipass/devpulse/.seedgo/bypass.json b/src/aipass/devpulse/.seedgo/bypass.json index 9ed1f7f9..8659c1d5 100644 --- a/src/aipass/devpulse/.seedgo/bypass.json +++ b/src/aipass/devpulse/.seedgo/bypass.json @@ -70,71 +70,6 @@ "file": "tests/test_owner_guard.py", "reason": "Unit test imports the owner guard handler (its SUT) and patches spawn.registry's get_owner/is_owner — the guard is a shared handler primitive with no apps/modules/ command entry point. Same direct-SUT pattern as test_compass_store.py. #681." }, - { - "standard": "encapsulation", - "file": "tools/spot_check.py", - "reason": "Standalone diagnostic tool script, not a handler." - }, - { - "standard": "debug_print", - "file": "tools/spot_check.py", - "reason": "Standalone CLI tool — print() is the intended output method." - }, - { - "standard": "silent_catch", - "file": "tools/hook_engine_poc/engine.py", - "reason": "POC hook engine — stdlib only, no prax logger. Uses sys.stderr for error reporting." - }, - { - "standard": "error_handling", - "file": "tools/hook_engine_poc/engine.py", - "reason": "POC hook engine — exception handlers write to stderr, not prax logger." - }, - { - "standard": "silent_catch", - "file": "tools/hook_engine_poc/test_engine.py", - "reason": "POC test harness — catches test exceptions to report pass/fail, writes to stderr." - }, - { - "standard": "imports", - "file": "tools/hook_engine_poc/test_engine.py", - "reason": "POC test harness — sys.path needed to import engine.py from same directory." - }, - { - "standard": "trigger", - "file": "tools/hook_engine_poc/test_engine.py", - "reason": "POC test harness — .unlink() clears ephemeral JSONL log between tests, not a tracked file." - }, - { - "standard": "help_text", - "file": "tools/hook_engine_poc/test_engine.py", - "reason": "POC test harness — usage example in docstring." - }, - { - "standard": "debug_print", - "file": "tools/rm_shim/redteam_suite.py", - "reason": "Standalone red-team diagnostic runner (FPLAN-0250 Phase 6) — print() IS the report output, same as broker_acceptance_test.py." - }, - { - "standard": "encapsulation", - "file": "tools/rm_shim/redteam_suite.py", - "reason": "Red-team tool imports the real broker daemon/client + sandbox module directly to exercise them under live conditions — that is the point of an integration probe, not a handler." - }, - { - "standard": "imports", - "file": "tools/rm_shim/redteam_suite.py", - "reason": "Standalone script run via 'python tools/...' — sys.path insert lets it import the production modules it red-teams without being pip-installed." - }, - { - "standard": "help_text", - "file": "tools/rm_shim/redteam_suite.py", - "reason": "Diagnostic script — docstring shows the 'python tools/...' invocation; it is not a drone-routed module." - }, - { - "standard": "documentation", - "file": "tools/rm_shim/redteam_suite.py", - "reason": "Result.ok/bad are 2-line internal report helpers in a diagnostic script — self-evident, docstrings redundant." - }, { "standard": "encapsulation", "file": "tests/test_compass_store.py", diff --git a/src/aipass/memory/.seedgo/bypass.json b/src/aipass/memory/.seedgo/bypass.json index b01b259b..f4576278 100644 --- a/src/aipass/memory/.seedgo/bypass.json +++ b/src/aipass/memory/.seedgo/bypass.json @@ -759,31 +759,6 @@ "standard": "meta", "reason": "Test file — META block present at lines 1-7; hook false-positive on test file format." }, - { - "file": "tools/migrate_entries.py", - "standard": "architecture", - "reason": "Standalone utility script in tools/ — intentionally outside 3-layer apps/ structure." - }, - { - "file": "tools/strip_trinity_limits.py", - "standard": "architecture", - "reason": "One-time migration script in tools/ — intentionally outside 3-layer apps/ structure." - }, - { - "file": "tools/strip_trinity_limits.py", - "standard": "log_visibility", - "reason": "Standalone script — cannot import prax logger. Uses stdlib logging." - }, - { - "file": "tools/migrate_entries.py", - "standard": "silent_catch", - "reason": "Standalone script — cannot import prax logger. Errors reported via stderr print + result dict." - }, - { - "file": "tools/migrate_entries.py", - "standard": "debug_print", - "reason": "Standalone script — print(stderr) is the error reporting mechanism. No prax available." - }, { "file": "tests/test_detector.py", "standard": "architecture", diff --git a/src/aipass/seedgo/.seedgo/bypass.json b/src/aipass/seedgo/.seedgo/bypass.json index 1b0e515b..754a5315 100644 --- a/src/aipass/seedgo/.seedgo/bypass.json +++ b/src/aipass/seedgo/.seedgo/bypass.json @@ -195,11 +195,6 @@ "standard": "deep_nesting", "reason": "scan() depth 6 — state machine tracking file types across standards with nested conditionals, inherent to triplet completeness detection" }, - { - "file": "tools/triplet_scanner.py", - "standard": "deep_nesting", - "reason": "scan() depth 6 — state machine tracking file types across standards with nested conditionals, inherent to triplet completeness detection" - }, { "file": "apps/handlers/aipass_standards/introspection_check.py", "standard": "deep_nesting", diff --git a/src/aipass/seedgo/README.md b/src/aipass/seedgo/README.md index d6cc947a..762d2cac 100644 --- a/src/aipass/seedgo/README.md +++ b/src/aipass/seedgo/README.md @@ -120,9 +120,9 @@ seedgo/ │ │ ├── branch_audit.py # Per-branch scoring engine │ │ ├── discovery.py # Branch discovery (CWD-first registry) │ │ └── audit_display.py # Rich result formatting -│ ├── bypass/ # Bypass system +│ ├── bypass/ # Bypass + ignore systems │ │ ├── bypass_handler.py # .seedgo/bypass.json loader -│ │ └── ignore_handler.py # .seedgo/ignore patterns +│ │ └── ignore_handler.py # Audit ignore patterns + .seedgoignore engine │ ├── config/ # Configuration handlers │ ├── diagnostics/ # Pyright integration + branch discovery │ ├── json/ # JSON tracking (json_handler) @@ -146,6 +146,8 @@ seedgo/ **Bypass system:** `.seedgo/bypass.json` per branch. Each entry has file, standard, optional lines, and required reason. Checkers call `is_bypassed()` per violation. Bypass is intentional documented deviation, not ignoring. +**`.seedgoignore` (throwaway paths, no reason required):** Drop a `.seedgoignore` file into any directory to exclude matching files/dirs from scans, audits, and the per-file checklist — same gitignore-style patterns and per-directory nesting semantics as a real `.gitignore` (via `pathspec`). Scope is exactly that directory's subtree; a nested `.seedgoignore` adds further excludes on top of any ancestor's, it doesn't replace them. A global default (`tools/`) applies fleet-wide with zero setup, since every branch's `apps/tools/` is deliberate throwaway prototyping space — quick scripts for fast answers, not standards-compliant by design. Unlike bypass (documented exception to a specific standard on a specific file), `.seedgoignore` removes the file from consideration entirely and needs no reason. It does **not** touch diagnostics (ruff/pyright) — those keep running on ignored files so auto-fix still catches real errors while you write; only standards checks skip them. See `ignore_handler.load_ignore_entries()` / `is_seedgo_ignored()`. + --- ## The 40 Standards diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/dead_code_check.py b/src/aipass/seedgo/apps/handlers/aipass_standards/dead_code_check.py index 2416e841..c3d759f6 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/dead_code_check.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/dead_code_check.py @@ -24,6 +24,7 @@ from pathlib import Path from aipass.prax import logger from aipass.seedgo.apps.handlers.json import json_handler from aipass.seedgo.apps.handlers.bypass.utils import is_bypassed +from aipass.seedgo.apps.handlers.bypass.ignore_handler import is_seedgo_ignored, load_ignore_entries from aipass.seedgo.apps.handlers.aipass_standards.skip_dirs import SOURCE_SKIP_DIRS, is_disabled_file AUDIT_SCOPE = "branch_level" @@ -42,12 +43,17 @@ _SKIP_DIRS = SOURCE_SKIP_DIRS | {"tests", "json_templates"} # ============================================= -def _should_skip(path: Path) -> bool: - """Check whether any parent directory component is in the skip set or file is disabled.""" - return any(part in _SKIP_DIRS for part in path.parts) or is_disabled_file(path.name) +def _should_skip(path: Path, branch_root: Path, ignore_entries: list) -> bool: + """Check whether any parent directory component is in the skip set, file is + disabled, or the path is excluded via .seedgoignore / the global default.""" + return ( + any(part in _SKIP_DIRS for part in path.parts) + or is_disabled_file(path.name) + or is_seedgo_ignored(str(path), branch_root, ignore_entries) + ) -def _collect_scannable_files(apps_dir: Path) -> list[Path]: +def _collect_scannable_files(apps_dir: Path, branch_root: Path, ignore_entries: list) -> list[Path]: """ Collect .py files from apps/modules/ and apps/handlers/ that should be checked for usage. Skips __init__.py, __pycache__, .archive, etc. @@ -60,20 +66,20 @@ def _collect_scannable_files(apps_dir: Path) -> list[Path]: for py_file in subdir.rglob("*.py"): if py_file.name == "__init__.py": continue - if _should_skip(py_file): + if _should_skip(py_file, branch_root, ignore_entries): continue targets.append(py_file) return sorted(targets) -def _collect_source_text(apps_dir: Path) -> str: +def _collect_source_text(apps_dir: Path, branch_root: Path, ignore_entries: list) -> str: """ Read ALL .py files under apps/ into a single string for searching. This is the corpus we search for references. """ parts: list[str] = [] for py_file in apps_dir.rglob("*.py"): - if _should_skip(py_file): + if _should_skip(py_file, branch_root, ignore_entries): continue try: parts.append(py_file.read_text(encoding="utf-8", errors="ignore")) @@ -266,7 +272,8 @@ def check_branch(branch_path: str, bypass_rules: list | None = None) -> dict: entry_point_name = branch_name # Collect scannable files - targets = _collect_scannable_files(apps_dir) + ignore_entries = load_ignore_entries(bp) + targets = _collect_scannable_files(apps_dir, bp, ignore_entries) if not targets: result = { "passed": True, @@ -287,7 +294,7 @@ def check_branch(branch_path: str, bypass_rules: list | None = None) -> dict: return result # Build the source corpus (all .py content from apps/) - source_text = _collect_source_text(apps_dir) + source_text = _collect_source_text(apps_dir, bp, ignore_entries) # Check each target for references total_files = len(targets) diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/encapsulation_check.py b/src/aipass/seedgo/apps/handlers/aipass_standards/encapsulation_check.py index 2104ad0c..bcda9dfd 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/encapsulation_check.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/encapsulation_check.py @@ -28,6 +28,11 @@ from aipass.seedgo.apps.handlers.bypass.utils import is_bypassed # Audit scope: all Python files AUDIT_SCOPE = "all_files" +# Cross-branch/cross-package handler imports are import-statement checks, so a +# violation hiding in a package marker file must not be invisible to the audit +# the way it would be for content checkers (dead code, naming, etc.). +INCLUDE_INIT_FILES = True + def _find_registry() -> Path: """Find AIPASS_REGISTRY.json by walking up from this file's location.""" diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/handlers_check.py b/src/aipass/seedgo/apps/handlers/aipass_standards/handlers_check.py index 9aa0c317..e11b9cd4 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/handlers_check.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/handlers_check.py @@ -13,9 +13,10 @@ Validates handler compliance with AIPass handler standards. Checks handler independence, auto-detection pattern, no orchestration. """ +import ast import re from pathlib import Path -from typing import Dict, List, Optional +from typing import Dict, Iterator, List, Optional, Tuple from aipass.prax import logger from aipass.seedgo.apps.handlers.json import json_handler @@ -24,6 +25,11 @@ from aipass.seedgo.apps.handlers.bypass.utils import is_bypassed # Audit scope: all Python files AUDIT_SCOPE = "all_files" +# Cross-handler/orchestration imports are purely import-statement checks, so a +# violation hiding in a package marker file must not be invisible to the audit +# the way it would be for content checkers (dead code, naming, etc.). +INCLUDE_INIT_FILES = True + def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: """ @@ -121,6 +127,39 @@ def check_module(module_path: str, bypass_rules: list | None = None) -> Dict: return {"passed": overall_passed, "checks": checks, "score": score, "standard": "HANDLERS"} +def _iter_import_modules(content: str) -> Iterator[Tuple[int, str, List[str]]]: + """ + Parse content and yield (line_number, dotted_module, imported_names) for every + real import statement — string literals, comments and docstrings can never + produce a hit because the AST only contains actual import nodes. + + Relative imports (from . import x) are omitted entirely: they can't reference + an absolute dotted path like "apps.handlers"/"apps.modules" and are always + same-package, so callers don't need to special-case them. + """ + try: + tree = ast.parse(content) + except SyntaxError as e: + logger.info("Skipping import scan: SyntaxError during parse: %s", e) + return + + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom): + if node.level: + continue + yield node.lineno, node.module or "", [alias.name for alias in node.names] + elif isinstance(node, ast.Import): + for alias in node.names: + yield node.lineno, alias.name, [] + + +def _line_text(lines: List[str], lineno: int, fallback: str) -> str: + """Return the source line at 1-indexed lineno, or a fallback if out of range.""" + if 0 < lineno <= len(lines): + return lines[lineno - 1].strip() + return fallback + + def check_handler_independence(content: str, lines: List[str], module_path: str) -> Dict: """ Check handler independence - no cross-handler imports except defaults @@ -141,58 +180,26 @@ def check_handler_independence(content: str, lines: List[str], module_path: str) own_package = path_parts[i + 1] break - in_docstring = False - for i, line in enumerate(lines, 1): - stripped = line.strip() - - # Track docstrings (handle both single-line and multi-line) - if stripped.startswith('"""') or stripped.startswith("'''"): - quote_marker = '"""' if stripped.startswith('"""') else "'''" - # Count occurrences of the quote marker - quote_count = stripped.count(quote_marker) - if quote_count >= 2: - # Single-line docstring (opening and closing on same line) - continue # Skip this line but don't toggle state - else: - # Multi-line docstring boundary - in_docstring = not in_docstring - - # Skip docstrings, comments and empty lines - if in_docstring or not stripped or stripped.startswith("#"): + for lineno, module, names in _iter_import_modules(content): + if "apps.handlers" not in module: continue - # Check for handler imports - if "apps.handlers" in stripped and ("from " in stripped or "import " in stripped): - # Skip if in a string (rough check) - if '"from ' in stripped or "'from " in stripped: - continue + # Allowed: Default handlers (json_handler) + if module.endswith("handlers.json") and "json_handler" in names: + continue - # Extract code part (before comment) - code_part = stripped.split("#")[0] if "#" in stripped else stripped + # Allowed: Same package absolute imports + if own_package and f"handlers.{own_package}" in module: + continue - if "apps.handlers" not in code_part: - continue - - # Allowed: Default handlers (json_handler) - if "handlers.json import json_handler" in code_part: - continue - - # Allowed: Same package imports (relative imports like "from .decorators") - if code_part.strip().startswith("from ."): - continue - - # Allowed: Same package absolute imports - if own_package and f"handlers.{own_package}" in code_part: - continue - - # Forbidden: Cross-handler imports - forbidden_imports.append(f"line {i}: {stripped}") + # Forbidden: Cross-handler imports + forbidden_imports.append(f"line {lineno}: {_line_text(lines, lineno, module)}") if forbidden_imports: return { "name": "Handler independence", "passed": False, - "message": f"Cross-handler imports detected (except defaults): {forbidden_imports[0]}", + "message": "Cross-handler imports detected (except defaults): " + "; ".join(forbidden_imports), } return {"name": "Handler independence", "passed": True, "message": "No forbidden cross-handler imports detected"} @@ -239,50 +246,22 @@ def check_no_orchestration(content: str, lines: List[str]) -> Optional[Dict]: """ module_imports = [] - in_docstring = False - for i, line in enumerate(lines, 1): - stripped = line.strip() - - # Track docstrings (handle both single-line and multi-line) - if stripped.startswith('"""') or stripped.startswith("'''"): - quote_marker = '"""' if stripped.startswith('"""') else "'''" - # Count occurrences of the quote marker - quote_count = stripped.count(quote_marker) - if quote_count >= 2: - # Single-line docstring (opening and closing on same line) - continue # Skip this line but don't toggle state - else: - # Multi-line docstring boundary - in_docstring = not in_docstring - - # Skip docstrings, comments and empty lines - if in_docstring or not stripped or stripped.startswith("#"): + for lineno, module, _names in _iter_import_modules(content): + if "apps.modules" not in module: continue - # Check for module imports - if "apps.modules" in stripped and ("from " in stripped or "import " in stripped): - # Skip if in a string - if '"from ' in stripped or "'from " in stripped: - continue + # Allowed: Service imports (prax.apps.modules.logger, cli.apps.modules) + if "prax.apps.modules.logger" in module or "cli.apps.modules" in module: + continue - # Extract code part - code_part = stripped.split("#")[0] if "#" in stripped else stripped - - if "apps.modules" not in code_part: - continue - - # Allowed: Service imports (prax.apps.modules.logger, cli.apps.modules) - if "prax.apps.modules.logger" in code_part or "cli.apps.modules" in code_part: - continue - - # Forbidden: Module imports (orchestration) - module_imports.append(f"line {i}: {stripped}") + # Forbidden: Module imports (orchestration) + module_imports.append(f"line {lineno}: {_line_text(lines, lineno, module)}") if module_imports: return { "name": "No orchestration", "passed": False, - "message": f"Handler imports modules (orchestration): {module_imports[0]}", + "message": "Handler imports modules (orchestration): " + "; ".join(module_imports), } return {"name": "No orchestration", "passed": True, "message": "No module imports detected (pure implementation)"} diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/readme_check.py b/src/aipass/seedgo/apps/handlers/aipass_standards/readme_check.py index f691dc28..8acab493 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/readme_check.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/readme_check.py @@ -31,6 +31,7 @@ from aipass.prax import logger from aipass.seedgo.apps.handlers.json import json_handler from aipass.seedgo.apps.handlers.bypass.utils import is_bypassed from aipass.seedgo.apps.handlers.aipass_standards.skip_dirs import SOURCE_SKIP_DIRS, is_disabled_file +from aipass.seedgo.apps.handlers.bypass.ignore_handler import is_seedgo_ignored, load_ignore_entries # Audit scope: entry points only (apps/{name}.py) AUDIT_SCOPE = "entry_point" @@ -503,12 +504,16 @@ def _extract_test_counts(content: str) -> List[int]: def _count_test_functions(tests_dir: Path) -> int: """Count `def test_` functions in all test_*.py files under tests/.""" count = 0 + branch_root = tests_dir.parent + ignore_entries = load_ignore_entries(branch_root) test_func_pattern = re.compile(r"^\s*def\s+test_", re.MULTILINE) for test_file in tests_dir.rglob("test_*.py"): if any(part in SOURCE_SKIP_DIRS for part in test_file.relative_to(tests_dir).parts): continue if is_disabled_file(test_file.name): continue + if is_seedgo_ignored(str(test_file), branch_root, ignore_entries): + continue try: source = test_file.read_text(encoding="utf-8") count += len(test_func_pattern.findall(source)) diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/test_quality_check.py b/src/aipass/seedgo/apps/handlers/aipass_standards/test_quality_check.py index c627cde0..f72b0095 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/test_quality_check.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/test_quality_check.py @@ -30,6 +30,7 @@ from aipass.prax import logger from aipass.seedgo.apps.handlers.json import json_handler from aipass.seedgo.apps.handlers.bypass.utils import is_bypassed from aipass.seedgo.apps.handlers.aipass_standards.skip_dirs import SOURCE_SKIP_DIRS, is_disabled_file +from aipass.seedgo.apps.handlers.bypass.ignore_handler import is_seedgo_ignored, load_ignore_entries AUDIT_SCOPE = "branch_level" @@ -180,7 +181,7 @@ def _should_skip_file(name: str) -> bool: return is_disabled_file(name) -def _find_test_files_broad(branch_path: Path) -> list[Path]: +def _find_test_files_broad(branch_path: Path, ignore_entries: list) -> list[Path]: """Find all test files for module coverage analysis. Broader than _find_all_test_files — also finds scattered test files @@ -197,6 +198,8 @@ def _find_test_files_broad(branch_path: Path) -> list[Path]: continue if any(_should_skip_dir(part) for part in py_file.relative_to(tests_dir).parts): continue + if is_seedgo_ignored(str(py_file), branch_path, ignore_entries): + continue resolved = py_file.resolve() if resolved not in seen: seen.add(resolved) @@ -208,6 +211,8 @@ def _find_test_files_broad(branch_path: Path) -> list[Path]: continue if py_file.name in ("__init__.py", "conftest.py") or _should_skip_file(py_file.name): continue + if is_seedgo_ignored(str(py_file), branch_path, ignore_entries): + continue if py_file.name.startswith("test_") or py_file.name.endswith("_test.py"): resolved = py_file.resolve() if resolved not in seen: @@ -234,7 +239,7 @@ def _analyze_test_file_imports(source: str) -> set[str]: return tested_modules -def _collect_testable_modules(branch_path: Path) -> set[str]: +def _collect_testable_modules(branch_path: Path, ignore_entries: list) -> set[str]: """Collect module names from apps/modules/ and apps/handlers/. Returns set of module names: @@ -255,17 +260,21 @@ def _collect_testable_modules(branch_path: Path) -> set[str]: and item.suffix == ".py" and item.name != "__init__.py" and not _should_skip_file(item.name) + and not is_seedgo_ignored(str(item), branch_path, ignore_entries) ): modules.add(item.stem) handlers_dir = apps_dir / "handlers" if handlers_dir.is_dir(): for item in sorted(handlers_dir.iterdir()): - if _should_skip_dir(item.name): + if _should_skip_dir(item.name) or is_seedgo_ignored(str(item), branch_path, ignore_entries): continue if item.is_dir() and item.name != "__pycache__": has_py = any( - f.suffix == ".py" and f.name != "__init__.py" and not _should_skip_file(f.name) + f.suffix == ".py" + and f.name != "__init__.py" + and not _should_skip_file(f.name) + and not is_seedgo_ignored(str(f), branch_path, ignore_entries) for f in item.iterdir() if f.is_file() ) @@ -282,7 +291,7 @@ def _collect_testable_modules(branch_path: Path) -> set[str]: return modules -def _find_all_test_files(branch_path: Path) -> list[Path]: +def _find_all_test_files(branch_path: Path, ignore_entries: list) -> list[Path]: """Find all test files and conftest.py in the branch's tests/ directory. Scans for any test_*.py file plus conftest.py -- no naming requirements. @@ -295,6 +304,8 @@ def _find_all_test_files(branch_path: Path) -> list[Path]: for p in sorted(tests_dir.iterdir()): if not p.is_file() or p.suffix != ".py": continue + if is_seedgo_ignored(str(p), branch_path, ignore_entries): + continue if p.name.startswith("test_") or p.name == "conftest.py": results.append(p) @@ -395,7 +406,8 @@ def check_branch(branch_path: str, bypass_rules: list | None = None) -> dict: } # Phase 1: Find all test files - test_files = _find_all_test_files(bp) + ignore_entries = load_ignore_entries(bp) + test_files = _find_all_test_files(bp, ignore_entries) if not test_files: checks.append( @@ -470,7 +482,7 @@ def check_branch(branch_path: str, bypass_rules: list | None = None) -> dict: # Phase 4: Module coverage (category 11 — from test_coverage_check.py) # Uses broader file discovery + import-based module mapping - broad_test_files = _find_test_files_broad(bp) + broad_test_files = _find_test_files_broad(bp, ignore_entries) total_tests = 0 tested_modules: set[str] = set() for tf in broad_test_files: @@ -483,7 +495,7 @@ def check_branch(branch_path: str, bypass_rules: list | None = None) -> dict: if total_tests == 0: tested_modules = set() - all_modules = _collect_testable_modules(bp) + all_modules = _collect_testable_modules(bp, ignore_entries) total_modules = len(all_modules) # 3 module coverage items diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/unused_function_check.py b/src/aipass/seedgo/apps/handlers/aipass_standards/unused_function_check.py index b866c6e6..ba7ed8d8 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/unused_function_check.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/unused_function_check.py @@ -32,6 +32,7 @@ from pathlib import Path from aipass.prax import logger from aipass.seedgo.apps.handlers.json import json_handler from aipass.seedgo.apps.handlers.bypass.utils import is_bypassed +from aipass.seedgo.apps.handlers.bypass.ignore_handler import is_seedgo_ignored, load_ignore_entries from aipass.seedgo.apps.handlers.aipass_standards.skip_dirs import SOURCE_SKIP_DIRS, is_disabled_file AUDIT_SCOPE = "branch_level" @@ -60,18 +61,23 @@ _MAIN_BLOCK_RE = re.compile( # -- File collection ---------------------------------------------------------- -def _should_skip(path: Path) -> bool: - """Return True if any path component is in the skip set or file is disabled.""" - return any(part in SKIP_DIRS for part in path.parts) or is_disabled_file(path.name) +def _should_skip(path: Path, branch_root: Path, ignore_entries: list) -> bool: + """Return True if any path component is in the skip set, file is disabled, + or the path is excluded via .seedgoignore / the global default.""" + return ( + any(part in SKIP_DIRS for part in path.parts) + or is_disabled_file(path.name) + or is_seedgo_ignored(str(path), branch_root, ignore_entries) + ) -def _collect_python_files(branch_path: Path) -> list[Path]: +def _collect_python_files(branch_path: Path, ignore_entries: list) -> list[Path]: """Collect all .py files in the branch, skipping irrelevant dirs.""" files: list[Path] = [] if not branch_path.is_dir(): return files for py_file in branch_path.rglob("*.py"): - if _should_skip(py_file): + if _should_skip(py_file, branch_path, ignore_entries): continue files.append(py_file) return sorted(files) @@ -216,7 +222,8 @@ def check_branch(branch_path: str, bypass_rules: list | None = None) -> dict: } # Phase 1: Collect all .py files - py_files = _collect_python_files(branch) + ignore_entries = load_ignore_entries(branch) + py_files = _collect_python_files(branch, ignore_entries) if not py_files: json_handler.log_operation( "check_completed", diff --git a/src/aipass/seedgo/apps/handlers/audit/branch_audit.py b/src/aipass/seedgo/apps/handlers/audit/branch_audit.py index b8668f3f..14f5593d 100644 --- a/src/aipass/seedgo/apps/handlers/audit/branch_audit.py +++ b/src/aipass/seedgo/apps/handlers/audit/branch_audit.py @@ -42,19 +42,27 @@ def discover_checkers(pack_path: Path | None = None) -> Dict[str, Any]: return checkers -def _collect_py_files(branch_path: Path) -> List[Dict[str, str]]: - """Collect auditable .py files from apps/, respecting ignore patterns.""" +def _collect_py_files(branch_path: Path, include_init: bool = False) -> List[Dict[str, str]]: + """Collect auditable .py files from apps/, respecting ignore patterns. + + __init__.py package markers are excluded by default — most checkers are + content-focused (dead code, naming, nesting) and __init__.py is typically + boilerplate. Pass include_init=True for import-statement checkers, where a + real cross-handler import hiding in a package marker must not go unseen. + """ apps_dir = branch_path / "apps" if not apps_dir.exists(): return [] ign = ignore_handler.get_audit_ignore_patterns() + ignore_entries = ignore_handler.load_ignore_entries(branch_path) return [ {"file": str(f), "name": f.name} for f in apps_dir.rglob("*.py") - if f.name != "__init__.py" + if (include_init or f.name != "__init__.py") and not is_disabled_file(f.name) and not is_throwaway_path(str(f)) and not any(p in str(f).lower() for p in ign) + and not ignore_handler.is_seedgo_ignored(str(f), branch_path, ignore_entries) ] @@ -132,6 +140,7 @@ def audit_branch(branch: Dict[str, str], bypass_rules: list, pack_path: Path | N """Audit a branch for standards compliance. Returns backward-compatible dict.""" entry_file, branch_path = branch["entry_file"], Path(branch["path"]) checkers, all_files = discover_checkers(pack_path), _collect_py_files(branch_path) + files_with_init: List[Dict[str, str]] | None = None # Discover diagnostics checker from handlers/diagnostics/ (outside pack dirs) diag_mod = _load_diagnostics_checker() @@ -161,7 +170,12 @@ def audit_branch(branch: Dict[str, str], bypass_rules: list, pack_path: Path | N results[name], scores[name] = {"passed": False, "score": 0, "error": str(e)}, 0 # All-files scope: scan every .py file, override score with average if scope == "all_files" and all_files: - v, s = _run_all_files(checker, name, all_files, bypass_rules) + scan_files = all_files + if getattr(checker, "INCLUDE_INIT_FILES", False): + if files_with_init is None: + files_with_init = _collect_py_files(branch_path, include_init=True) + scan_files = files_with_init + v, s = _run_all_files(checker, name, scan_files, bypass_rules) all_violations[name] = v if s: avg_score = int(sum(s) / len(s)) diff --git a/src/aipass/seedgo/apps/handlers/bypass/ignore_handler.py b/src/aipass/seedgo/apps/handlers/bypass/ignore_handler.py index 6a011e66..113b7d64 100644 --- a/src/aipass/seedgo/apps/handlers/bypass/ignore_handler.py +++ b/src/aipass/seedgo/apps/handlers/bypass/ignore_handler.py @@ -11,14 +11,23 @@ Ignore Pattern Configuration Handler Provides ignore patterns for audit file filtering, template baseline checking, and deprecated pattern tracking. Pure configuration with helper functions. + +Also provides the .seedgoignore engine — a gitignore-style dotfile droppable +into any directory (per-directory scope, same nesting semantics as .gitignore) +plus a global default so agents' tools/ dirs are ignored fleet-wide with zero +per-branch setup. See DEFAULT_IGNORE_PATTERNS / is_seedgo_ignored(). """ # ============================================= # IMPORTS # ============================================= -from typing import List +from pathlib import Path +from typing import List, Optional, Tuple +import pathspec + +from aipass.prax import logger from aipass.seedgo.apps.handlers.json import json_handler # ============================================= @@ -111,6 +120,95 @@ def get_deprecated_patterns() -> dict: return DEPRECATED_PATTERNS.copy() +# ============================================= +# SEEDGO_IGNORE — gitignore-style, per-directory +# ============================================= + +# Dotfile name — droppable into any directory in a branch, gitignore-style +# patterns (via pathspec), scoped to that directory's subtree exactly like a +# real .gitignore. +IGNORE_FILENAME = ".seedgoignore" + +# Global default — applied to every branch with zero per-branch setup. +# Agents' tools/ dirs are deliberate throwaway prototyping space (quick +# scripts for fast answers) — not standards-compliant by design, and that's +# fine and wanted (Patrick ruling). +DEFAULT_IGNORE_PATTERNS: List[str] = [ + "tools/", +] + + +def _iter_seedgo_ignore_files(branch_root: Path) -> List[Path]: + """Return every .seedgoignore file under branch_root, shallowest first.""" + return sorted(branch_root.rglob(IGNORE_FILENAME), key=lambda p: len(p.parts)) + + +def load_ignore_entries(branch_root: Path) -> List[Tuple[str, "pathspec.PathSpec"]]: + """Build the ordered (scope, PathSpec) list for a branch. + + scope "" is the global default and applies branch-wide. Each discovered + .seedgoignore file adds a scope equal to its own directory (relative to + branch_root) — its patterns are relative to that directory and only + match within its subtree, same nesting semantics as a real .gitignore. + + Args: + branch_root: Absolute path to the branch root. + + Returns: + Ordered list of (scope, PathSpec) pairs, global default first. + """ + root = Path(branch_root).resolve() + entries: List[Tuple[str, "pathspec.PathSpec"]] = [ + ("", pathspec.PathSpec.from_lines("gitignore", DEFAULT_IGNORE_PATTERNS)) + ] + + for ignore_file in _iter_seedgo_ignore_files(root): + scope = ignore_file.parent.relative_to(root).as_posix() + if scope == ".": + scope = "" + try: + lines = ignore_file.read_text(encoding="utf-8").splitlines() + except OSError as exc: + logger.info("[ignore_handler] Cannot read %s: %s", ignore_file, exc) + continue + entries.append((scope, pathspec.PathSpec.from_lines("gitignore", lines))) + + json_handler.log_operation("seedgo_ignore_loaded", {"branch_root": str(root), "files": len(entries) - 1}) + return entries + + +def is_seedgo_ignored( + file_path: str, branch_root: Path, entries: Optional[List[Tuple[str, "pathspec.PathSpec"]]] = None +) -> bool: + """Check whether file_path is ignored via .seedgoignore or the global default. + + Args: + file_path: Absolute path to the candidate file. + branch_root: Absolute path to the branch root. + entries: Pre-loaded result of load_ignore_entries(); loaded fresh if omitted. + + Returns: + True when scans/audits/checklists/checker-style enforcement should skip this path. + """ + root = Path(branch_root).resolve() + try: + rel = Path(file_path).resolve().relative_to(root).as_posix() + except ValueError as exc: + logger.info("[ignore_handler] %s not under branch root %s: %s", file_path, root, exc) + return False + + if entries is None: + entries = load_ignore_entries(root) + + for scope, spec in entries: + if scope and rel != scope and not rel.startswith(scope + "/"): + continue + sub_rel = rel[len(scope) + 1 :] if scope else rel + if spec.match_file(sub_rel): + return True + return False + + # ============================================= # MODULE INITIALIZATION # ============================================= diff --git a/src/aipass/seedgo/apps/modules/checklist.py b/src/aipass/seedgo/apps/modules/checklist.py index f4997af0..479b6f52 100644 --- a/src/aipass/seedgo/apps/modules/checklist.py +++ b/src/aipass/seedgo/apps/modules/checklist.py @@ -45,6 +45,9 @@ from aipass.seedgo.apps.handlers.bypass.bypass_handler import ( # Throwaway / prototype detection from aipass.seedgo.apps.handlers.aipass_standards.skip_dirs import is_prototype_file, is_throwaway_path +# .seedgoignore — gitignore-style per-directory + global default (tools/) +from aipass.seedgo.apps.handlers.bypass.ignore_handler import is_seedgo_ignored + # JSON handler for tracking from aipass.seedgo.apps.handlers.json import json_handler @@ -140,6 +143,10 @@ def run_checklist(file_path: str, pack_name: str = "aipass", prototype: bool = F if prototype or is_prototype_file(resolved): return [{"standard": "(skip)", "passed": True, "detail": "Prototype mode — standards skipped"}] + branch_path = _resolve_branch_path(resolved) + if branch_path is not None and is_seedgo_ignored(resolved, branch_path): + return [{"standard": "(skip)", "passed": True, "detail": "Ignored via .seedgoignore"}] + # Discover pack path pack_path = _resolve_pack_path(pack_name) if pack_path is None: @@ -191,20 +198,28 @@ def _resolve_pack_path(pack_name: str) -> Optional[Path]: return None -def _load_bypass_for_file(file_path: str) -> list: - """Load bypass rules for the branch containing file_path.""" +def _resolve_branch_path(file_path: str) -> Optional[Path]: + """Resolve the absolute branch root containing file_path, or None.""" branch = get_branch_from_path(file_path) if branch is None: - return [] + return None raw_path = branch.get("path", "") if not raw_path: - return [] + return None bp = Path(raw_path) if not bp.is_absolute(): from aipass.seedgo.apps.handlers.bypass.bypass_handler import _find_registry registry_path = _find_registry() bp = (registry_path.parent / bp).resolve() + return bp + + +def _load_bypass_for_file(file_path: str) -> list: + """Load bypass rules for the branch containing file_path.""" + bp = _resolve_branch_path(file_path) + if bp is None: + return [] return load_bypass_rules(str(bp)) diff --git a/src/aipass/seedgo/tests/fixtures/provider_hooks_snapshot.json b/src/aipass/seedgo/tests/fixtures/provider_hooks_snapshot.json index 8bdc07e3..2dda2d99 100644 --- a/src/aipass/seedgo/tests/fixtures/provider_hooks_snapshot.json +++ b/src/aipass/seedgo/tests/fixtures/provider_hooks_snapshot.json @@ -1,5 +1,21 @@ { "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:presence_gate" + } + ] + }, + { + "hooks": [ + { + "type": "command", + "command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:persistent_alert" + } + ] + }, { "hooks": [ { @@ -40,6 +56,22 @@ } ] }, + { + "hooks": [ + { + "type": "command", + "command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:compass_recall" + } + ] + }, + { + "hooks": [ + { + "type": "command", + "command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:feedback_pulse" + } + ] + }, { "hooks": [ { @@ -48,15 +80,53 @@ "timeout": 120 } ] - } - ], - "PreToolUse": [ + }, { - "matcher": "Bash|Edit|MultiEdit|Write|Read|Grep|Glob|WebSearch|WebFetch|Task", "hooks": [ { "type": "command", - "command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreToolUse" + "command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:context_gauge", + "timeout": 30 + } + ] + }, + { + "hooks": [ + { + "type": "command", + "command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py UserPromptSubmit:temporal", + "timeout": 30 + } + ] + } + ], + "Notification": [ + { + "hooks": [ + { + "type": "command", + "command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py Notification" + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py Stop" + } + ] + } + ], + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py SessionStart:cadence_reset", + "timeout": 30 } ] } @@ -72,6 +142,17 @@ ] } ], + "PreToolUse": [ + { + "matcher": "Bash|Edit|MultiEdit|Write|Read|Grep|Glob|WebSearch|WebFetch|Task", + "hooks": [ + { + "type": "command", + "command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreToolUse" + } + ] + } + ], "SubagentStop": [ { "hooks": [ @@ -82,26 +163,6 @@ ] } ], - "Stop": [ - { - "hooks": [ - { - "type": "command", - "command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py Stop" - } - ] - } - ], - "Notification": [ - { - "hooks": [ - { - "type": "command", - "command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py Notification" - } - ] - } - ], "PreCompact": [ { "matcher": "manual", @@ -162,14 +223,23 @@ "timeout": 120 } ] - } - ], - "SessionStart": [ + }, { + "matcher": "manual", "hooks": [ { "type": "command", - "command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py SessionStart:cadence_reset", + "command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreCompact:pre_compact_prep", + "timeout": 30 + } + ] + }, + { + "matcher": "auto", + "hooks": [ + { + "type": "command", + "command": "$AIPASS_HOME/.venv/bin/python3 $AIPASS_HOME/src/aipass/hooks/apps/handlers/bridges/claude.py PreCompact:pre_compact_prep", "timeout": 30 } ] diff --git a/src/aipass/seedgo/tests/test_bypass.py b/src/aipass/seedgo/tests/test_bypass.py index 0a5bf3fc..cce05bc7 100644 --- a/src/aipass/seedgo/tests/test_bypass.py +++ b/src/aipass/seedgo/tests/test_bypass.py @@ -221,6 +221,111 @@ def test_get_deprecated_patterns_returns_dict(): assert isinstance(value, str) +# --------------------------------------------------------------------------- +# Tests -- .seedgoignore engine (load_ignore_entries / is_seedgo_ignored) +# --------------------------------------------------------------------------- + + +def test_global_default_ignores_tools_dir_with_no_dotfile(tmp_path): + """Global default (tools/) applies branch-wide even with zero .seedgoignore files.""" + from aipass.seedgo.apps.handlers.bypass.ignore_handler import is_seedgo_ignored + + tools_file = tmp_path / "apps" / "tools" / "scratch.py" + tools_file.parent.mkdir(parents=True) + tools_file.write_text("pass", encoding="utf-8") + normal_file = tmp_path / "apps" / "modules" / "real.py" + normal_file.parent.mkdir(parents=True) + normal_file.write_text("pass", encoding="utf-8") + + assert is_seedgo_ignored(str(tools_file), tmp_path) is True + assert is_seedgo_ignored(str(normal_file), tmp_path) is False + + +def test_seedgo_ignore_dotfile_scoped_to_its_own_directory(tmp_path): + """A .seedgoignore dropped in a subdir only affects that subdir's subtree, not siblings.""" + from aipass.seedgo.apps.handlers.bypass.ignore_handler import is_seedgo_ignored + + scratch_dir = tmp_path / "apps" / "handlers" / "experiment" + scratch_dir.mkdir(parents=True) + (scratch_dir / ".seedgoignore").write_text("*.draft.py\n", encoding="utf-8") + (scratch_dir / "wip.draft.py").write_text("pass", encoding="utf-8") + + sibling_dir = tmp_path / "apps" / "handlers" / "other" + sibling_dir.mkdir(parents=True) + (sibling_dir / "wip.draft.py").write_text("pass", encoding="utf-8") + + assert is_seedgo_ignored(str(scratch_dir / "wip.draft.py"), tmp_path) is True + assert is_seedgo_ignored(str(sibling_dir / "wip.draft.py"), tmp_path) is False + + +def test_seedgo_ignore_supports_gitignore_style_patterns(tmp_path): + """Comments, blank lines, and negation follow standard gitignore semantics.""" + from aipass.seedgo.apps.handlers.bypass.ignore_handler import is_seedgo_ignored + + (tmp_path / ".seedgoignore").write_text( + "\n".join(["# comment", "", "scratch/", "!scratch/keep_me.py"]), + encoding="utf-8", + ) + scratch_dir = tmp_path / "scratch" + scratch_dir.mkdir() + (scratch_dir / "throwaway.py").write_text("pass", encoding="utf-8") + (scratch_dir / "keep_me.py").write_text("pass", encoding="utf-8") + + assert is_seedgo_ignored(str(scratch_dir / "throwaway.py"), tmp_path) is True + assert is_seedgo_ignored(str(scratch_dir / "keep_me.py"), tmp_path) is False + + +def test_seedgo_ignore_nested_scopes_both_apply(tmp_path): + """A nested .seedgoignore adds to (not replaces) any ancestor .seedgoignore scopes.""" + from aipass.seedgo.apps.handlers.bypass.ignore_handler import is_seedgo_ignored + + (tmp_path / ".seedgoignore").write_text("*.rootskip\n", encoding="utf-8") + child_dir = tmp_path / "apps" / "child" + child_dir.mkdir(parents=True) + (child_dir / ".seedgoignore").write_text("*.childskip\n", encoding="utf-8") + (child_dir / "a.rootskip").write_text("pass", encoding="utf-8") + (child_dir / "b.childskip").write_text("pass", encoding="utf-8") + (child_dir / "c.py").write_text("pass", encoding="utf-8") + + assert is_seedgo_ignored(str(child_dir / "a.rootskip"), tmp_path) is True + assert is_seedgo_ignored(str(child_dir / "b.childskip"), tmp_path) is True + assert is_seedgo_ignored(str(child_dir / "c.py"), tmp_path) is False + + +def test_is_seedgo_ignored_path_outside_branch_root_returns_false(tmp_path): + """A file outside branch_root cannot be resolved to a relative path — treated as not ignored.""" + from aipass.seedgo.apps.handlers.bypass.ignore_handler import is_seedgo_ignored + + branch_root = tmp_path / "branch" + branch_root.mkdir() + outside_file = tmp_path / "elsewhere" / "file.py" + outside_file.parent.mkdir() + outside_file.write_text("pass", encoding="utf-8") + + assert is_seedgo_ignored(str(outside_file), branch_root) is False + + +def test_load_ignore_entries_default_only_when_no_dotfiles(tmp_path): + """With no .seedgoignore files present, only the global default scope is returned.""" + from aipass.seedgo.apps.handlers.bypass.ignore_handler import load_ignore_entries + + entries = load_ignore_entries(tmp_path) + assert len(entries) == 1 + assert entries[0][0] == "" + + +def test_is_seedgo_ignored_accepts_precomputed_entries(tmp_path): + """Passing pre-loaded entries skips the internal reload — same result as omitting it.""" + from aipass.seedgo.apps.handlers.bypass.ignore_handler import is_seedgo_ignored, load_ignore_entries + + tools_file = tmp_path / "tools" / "scratch.py" + tools_file.parent.mkdir(parents=True) + tools_file.write_text("pass", encoding="utf-8") + + entries = load_ignore_entries(tmp_path) + assert is_seedgo_ignored(str(tools_file), tmp_path, entries) is True + + # --------------------------------------------------------------------------- # Tests -- utils.is_bypassed name-scoped bypass # --------------------------------------------------------------------------- diff --git a/src/aipass/seedgo/tests/test_checkers_batch2.py b/src/aipass/seedgo/tests/test_checkers_batch2.py index c9c98e12..f29a5c45 100644 --- a/src/aipass/seedgo/tests/test_checkers_batch2.py +++ b/src/aipass/seedgo/tests/test_checkers_batch2.py @@ -158,6 +158,42 @@ from aipass.seedgo.apps.modules.scanner import scan_all result = check_handlers(fp, bypass_rules=bypass) assert result["score"] == 100 + def test_handlers_reports_all_forbidden_imports(self, tmp_path: Path) -> None: + """A file with two cross-handler imports must report both, not just the first.""" + handler_dir = tmp_path / "apps" / "handlers" / "mypack" + handler_dir.mkdir(parents=True) + code = """\ +from aipass.seedgo.apps.handlers.error import error_handler +from aipass.seedgo.apps.handlers.file import file_handler + +def do_stuff(): + return True +""" + fp = str(handler_dir / "double_violation.py") + Path(fp).write_text(code, encoding="utf-8") + result = check_handlers(fp) + independence = next(c for c in result["checks"] if c["name"] == "Handler independence") + assert "line 1" in independence["message"] + assert "line 2" in independence["message"] + + def test_handlers_reports_all_orchestration_imports(self, tmp_path: Path) -> None: + """A file with two module (orchestration) imports must report both, not just the first.""" + handler_dir = tmp_path / "apps" / "handlers" / "mypack" + handler_dir.mkdir(parents=True) + code = """\ +from aipass.seedgo.apps.modules.scanner import scan_all +from aipass.seedgo.apps.modules.reporter import report_all + +def do_stuff(): + return scan_all() +""" + fp = str(handler_dir / "double_orchestration.py") + Path(fp).write_text(code, encoding="utf-8") + result = check_handlers(fp) + orchestration = next(c for c in result["checks"] if c["name"] == "No orchestration") + assert "line 1" in orchestration["message"] + assert "line 2" in orchestration["message"] + # =================================================================== # 3. hardcoded_key_check diff --git a/src/aipass/seedgo/tests/test_checkers_batch4.py b/src/aipass/seedgo/tests/test_checkers_batch4.py index 8017b8fc..48e9c702 100644 --- a/src/aipass/seedgo/tests/test_checkers_batch4.py +++ b/src/aipass/seedgo/tests/test_checkers_batch4.py @@ -238,6 +238,21 @@ class TestDeadCodeCheck: assert result["score"] == 100 assert result["passed"] is True + def test_dead_code_ignores_tools_dir_by_default(self, mock_json, tmp_path: Path) -> None: + """Orphan modules under apps/tools/ are skipped via the global .seedgoignore default.""" + branch = _make_branch(tmp_path) + _write_file( + branch / "apps" / "tools" / "scratch.py", + "def lonely_function():\n return None\n", + ) + _write_file( + branch / "apps" / (branch.name + ".py"), + "def handle_command(): pass\n", + ) + result = dead_code_check_branch(str(branch)) + assert result["score"] == 100 + assert result["passed"] is True + # ============================================= # 5. test_quality_check (check_branch) @@ -466,3 +481,16 @@ class TestUnusedFunctionCheck: result = unused_function_check_branch(str(branch), bypass_rules=bypass) assert result["score"] == 100 assert result["passed"] is True + + def test_unused_function_respects_seedgo_ignore_dotfile(self, mock_json, tmp_path: Path) -> None: + """A .seedgoignore dropped alongside an unused function's file suppresses the flag.""" + branch = _make_branch(tmp_path) + experiment_dir = branch / "apps" / "handlers" / "experiment" + _write_file(experiment_dir / ".seedgoignore", "*\n") + _write_file( + experiment_dir / "scratch.py", + "def never_called():\n pass\n", + ) + result = unused_function_check_branch(str(branch)) + assert result["score"] == 100 + assert result["passed"] is True diff --git a/src/aipass/seedgo/tests/test_checkers_batch8.py b/src/aipass/seedgo/tests/test_checkers_batch8.py index c2794b63..4d4f6401 100644 --- a/src/aipass/seedgo/tests/test_checkers_batch8.py +++ b/src/aipass/seedgo/tests/test_checkers_batch8.py @@ -132,6 +132,45 @@ def test_handler_independence_same_package(tmp_path): assert result["passed"] is True +def test_handler_independence_fstring_guard_text_not_flagged(tmp_path): + """An f-string that mentions apps.handlers as help text is not a real import.""" + content = ( + "def guard():\n" + " raise ImportError(\n" + ' f" from aipass.seedgo.apps.handlers.error import error_handler\\n"\n' + " )\n" + ) + handler_path = str(tmp_path / "apps" / "handlers" / "audit" / "guard.py") + + from aipass.seedgo.apps.handlers.aipass_standards.handlers_check import ( + check_handler_independence, + ) + + result = check_handler_independence(content, _lines(content), handler_path) + assert result["passed"] is True + + +def test_handler_independence_flags_real_import_alongside_guard_text(tmp_path): + """A real cross-handler import is still caught even when guard text sits nearby.""" + content = ( + "from aipass.seedgo.apps.handlers.error import error_handler\n" + "\n" + "def guard():\n" + " raise ImportError(\n" + ' f" from aipass.seedgo.apps.handlers.error import error_handler\\n"\n' + " )\n" + ) + handler_path = str(tmp_path / "apps" / "handlers" / "audit" / "mixed.py") + + from aipass.seedgo.apps.handlers.aipass_standards.handlers_check import ( + check_handler_independence, + ) + + result = check_handler_independence(content, _lines(content), handler_path) + assert result["passed"] is False + assert "line 1" in result["message"] + + # =========================================================================== # 2. handlers_check -- check_auto_detection # =========================================================================== @@ -238,6 +277,51 @@ def test_no_orchestration_in_docstring(): assert result["passed"] is True +def test_no_orchestration_fstring_guard_text_not_flagged(): + """An import-guard error message built from an f-string is not a real import. + + Regression test: apps/handlers/__init__.py raises ImportError with help text + like `f" from {MY_BRANCH}.apps.modules. import \\n"` — + the old text-matching check flagged this guard text as orchestration. + """ + content = ( + "MY_BRANCH = 'aipass.seedgo'\n" + "def guard():\n" + " raise ImportError(\n" + ' f" from {MY_BRANCH}.apps.modules. import \\n"\n' + " )\n" + ) + + from aipass.seedgo.apps.handlers.aipass_standards.handlers_check import ( + check_no_orchestration, + ) + + result = check_no_orchestration(content, _lines(content)) + assert result is not None + assert result["passed"] is True + + +def test_no_orchestration_flags_real_import_alongside_guard_text(): + """A real module import is still caught even when guard text sits nearby.""" + content = ( + "from aipass.seedgo.apps.modules.scanner import scan_all\n" + "\n" + "def guard():\n" + " raise ImportError(\n" + ' f" from aipass.seedgo.apps.modules. import \\n"\n' + " )\n" + ) + + from aipass.seedgo.apps.handlers.aipass_standards.handlers_check import ( + check_no_orchestration, + ) + + result = check_no_orchestration(content, _lines(content)) + assert result is not None + assert result["passed"] is False + assert "line 1" in result["message"] + + # =========================================================================== # 4. log_handler_check -- check_no_raw_file_handler # =========================================================================== diff --git a/src/aipass/seedgo/tests/test_checklist.py b/src/aipass/seedgo/tests/test_checklist.py index 7f655248..dce5d6e0 100644 --- a/src/aipass/seedgo/tests/test_checklist.py +++ b/src/aipass/seedgo/tests/test_checklist.py @@ -22,6 +22,11 @@ def _mock_infrastructure(monkeypatch): """Mock heavy infrastructure imports for checklist.""" import sys + from aipass.seedgo.apps.handlers.bypass.ignore_handler import ( + is_seedgo_ignored as real_is_seedgo_ignored, + load_ignore_entries as real_load_ignore_entries, + ) + mock_logger = MagicMock() mock_console = MagicMock() mock_error = MagicMock() @@ -67,6 +72,12 @@ def _mock_infrastructure(monkeypatch): bypass_mod.load_bypass_rules = MagicMock(return_value=[]) monkeypatch.setitem(sys.modules, "aipass.seedgo.apps.handlers.bypass.bypass_handler", bypass_mod) + ignore_mod = MagicMock() + ignore_mod.is_seedgo_ignored = real_is_seedgo_ignored + ignore_mod.load_ignore_entries = real_load_ignore_entries + bypass_pkg.ignore_handler = ignore_mod + monkeypatch.setitem(sys.modules, "aipass.seedgo.apps.handlers.bypass.ignore_handler", ignore_mod) + # Force re-import monkeypatch.delitem(sys.modules, "aipass.seedgo.apps.modules.checklist", raising=False) @@ -222,6 +233,31 @@ def test_run_checklist_prototype_marker_skips(tmp_path, monkeypatch): assert "prototype" in results[0]["detail"].lower() +def test_run_checklist_seedgo_ignore_skips(tmp_path, monkeypatch): + """A file under apps/tools/ is skipped via the global .seedgoignore default.""" + import sys + + from aipass.seedgo.apps.handlers.aipass_standards import skip_dirs + + monkeypatch.setattr(skip_dirs, "_get_temp_roots", lambda: []) + + branch_mod = MagicMock() + branch_mod.get_branch_from_path = MagicMock(return_value={"path": str(tmp_path)}) + branch_mod.load_bypass_rules = MagicMock(return_value=[]) + monkeypatch.setitem(sys.modules, "aipass.seedgo.apps.handlers.bypass.bypass_handler", branch_mod) + + from aipass.seedgo.apps.modules.checklist import run_checklist + + tools_dir = tmp_path / "apps" / "tools" + tools_dir.mkdir(parents=True) + f = tools_dir / "scratch.py" + f.write_text("x = 1\n", encoding="utf-8") + results = run_checklist(str(f)) + assert len(results) == 1 + assert results[0]["passed"] is True + assert "seedgoignore" in results[0]["detail"].lower() + + def test_run_checklist_normal_file_still_audited(tmp_path, monkeypatch): """A normal file without markers/temp path is still fully audited.""" import sys diff --git a/src/aipass/seedgo/tests/test_coverage_audit.py b/src/aipass/seedgo/tests/test_coverage_audit.py index 5f5eb65f..ff6b3357 100644 --- a/src/aipass/seedgo/tests/test_coverage_audit.py +++ b/src/aipass/seedgo/tests/test_coverage_audit.py @@ -32,8 +32,15 @@ def _mock_infrastructure(monkeypatch): mock_console = MagicMock() mock_json_handler = MagicMock() mock_json_handler.log_operation = MagicMock(return_value=True) + from aipass.seedgo.apps.handlers.bypass.ignore_handler import ( + is_seedgo_ignored as real_is_seedgo_ignored, + load_ignore_entries as real_load_ignore_entries, + ) + mock_ignore_handler = MagicMock() mock_ignore_handler.get_audit_ignore_patterns = MagicMock(return_value=[]) + mock_ignore_handler.is_seedgo_ignored = real_is_seedgo_ignored + mock_ignore_handler.load_ignore_entries = real_load_ignore_entries mock_scan_branch = MagicMock(return_value=None) # -- prax --------------------------------------------------------------- @@ -1087,6 +1094,27 @@ class TestCollectPyFiles: assert "handler.py" in names assert "__init__.py" not in names + def test_collects_init_files_when_requested(self, tmp_path, monkeypatch): + """include_init=True keeps __init__.py — for import checkers that must not miss them.""" + import sys + + skip_dirs = sys.modules.get("aipass.seedgo.apps.handlers.aipass_standards.skip_dirs") + if skip_dirs: + monkeypatch.setattr(skip_dirs, "_get_temp_roots", lambda: []) + + from aipass.seedgo.apps.handlers.audit.branch_audit import ( + _collect_py_files, + ) + + apps_dir = tmp_path / "apps" + apps_dir.mkdir() + (apps_dir / "__init__.py").write_text("", encoding="utf-8") + (apps_dir / "module.py").write_text("pass", encoding="utf-8") + result = _collect_py_files(tmp_path, include_init=True) + names = [f["name"] for f in result] + assert "module.py" in names + assert "__init__.py" in names + def test_respects_ignore_patterns(self, tmp_path, monkeypatch): """Files matching ignore patterns are excluded.""" import sys @@ -1134,6 +1162,29 @@ class TestCollectPyFiles: assert "module.py" in names assert "dashboard_sync(disabled).py" not in names + def test_respects_seedgo_ignore_tools_dir(self, tmp_path, monkeypatch): + """Files under apps/tools/ are excluded via the global .seedgoignore default.""" + import sys + + skip_dirs = sys.modules.get("aipass.seedgo.apps.handlers.aipass_standards.skip_dirs") + if skip_dirs: + monkeypatch.setattr(skip_dirs, "_get_temp_roots", lambda: []) + + from aipass.seedgo.apps.handlers.audit.branch_audit import ( + _collect_py_files, + ) + + apps_dir = tmp_path / "apps" + apps_dir.mkdir() + (apps_dir / "module.py").write_text("pass", encoding="utf-8") + tools_dir = apps_dir / "tools" + tools_dir.mkdir() + (tools_dir / "scratch.py").write_text("pass", encoding="utf-8") + result = _collect_py_files(tmp_path) + names = [f["name"] for f in result] + assert "module.py" in names + assert "scratch.py" not in names + class TestExtractBranchLevelViolations: """Tests for _extract_branch_level_violations.""" @@ -1462,6 +1513,7 @@ def _make_checker( checker = MagicMock() checker.AUDIT_SCOPE = scope checker.FILE_FILTER = None + checker.INCLUDE_INIT_FILES = False if has_check_module: default_mod = { @@ -1676,6 +1728,41 @@ class TestAuditBranch: result = branch_audit.audit_branch(branch, []) assert "naming_violations" in result + def test_all_files_scope_include_init_files_opt_in(self, tmp_path, monkeypatch): + """Checker with INCLUDE_INIT_FILES=True sees __init__.py; a normal checker does not.""" + from aipass.seedgo.apps.handlers.audit import branch_audit + + branch, branch_path = _setup_branch(tmp_path) + apps_dir = Path(branch_path) / "apps" + (apps_dir / "__init__.py").write_text("pass", encoding="utf-8") + + import_checker = _make_checker(scope="all_files") + import_checker.INCLUDE_INIT_FILES = True + normal_checker = _make_checker(scope="all_files") + + # tmp_path lives under the system temp dir, which is_throwaway_path + # filters out wholesale — neutralize that so _collect_py_files sees + # the fixture's files, same as it would for a real branch on disk. + monkeypatch.setattr(branch_audit, "is_throwaway_path", lambda path_str: False) + monkeypatch.setattr( + branch_audit, + "discover_checkers", + lambda pack_path=None: {"handlers": import_checker, "naming": normal_checker}, + ) + monkeypatch.setattr( + branch_audit, + "_load_diagnostics_checker", + lambda: None, + ) + monkeypatch.setattr(branch_audit, "scan_branch", lambda p: None) + + branch_audit.audit_branch(branch, []) + + import_checked_names = {Path(c.args[0]).name for c in import_checker.check_module.call_args_list} + normal_checked_names = {Path(c.args[0]).name for c in normal_checker.check_module.call_args_list} + assert "__init__.py" in import_checked_names + assert "__init__.py" not in normal_checked_names + def test_dynamic_post_check(self, tmp_path, monkeypatch): """check_branch_post discovered and called.""" from aipass.seedgo.apps.handlers.audit import branch_audit diff --git a/src/aipass/seedgo/tests/test_readme_content_checks.py b/src/aipass/seedgo/tests/test_readme_content_checks.py index c43b5ff4..dab084b7 100644 --- a/src/aipass/seedgo/tests/test_readme_content_checks.py +++ b/src/aipass/seedgo/tests/test_readme_content_checks.py @@ -48,6 +48,10 @@ def _mock_infrastructure(monkeypatch): monkeypatch.setitem(sys.modules, "aipass.seedgo.apps.handlers.json.json_handler", json_mod) from aipass.seedgo.apps.handlers.bypass.utils import is_bypassed as real_is_bypassed + from aipass.seedgo.apps.handlers.bypass.ignore_handler import ( + is_seedgo_ignored as real_is_seedgo_ignored, + load_ignore_entries as real_load_ignore_entries, + ) bypass_pkg = MagicMock() bypass_utils = MagicMock() @@ -55,6 +59,8 @@ def _mock_infrastructure(monkeypatch): bypass_pkg.utils = bypass_utils bypass_ignore = MagicMock() bypass_ignore.get_template_ignore_patterns = MagicMock(return_value=[]) + bypass_ignore.is_seedgo_ignored = real_is_seedgo_ignored + bypass_ignore.load_ignore_entries = real_load_ignore_entries bypass_pkg.ignore_handler = bypass_ignore monkeypatch.setitem(sys.modules, "aipass.seedgo.apps.handlers.bypass", bypass_pkg) monkeypatch.setitem(sys.modules, "aipass.seedgo.apps.handlers.bypass.utils", bypass_utils)