diff --git a/tests/arch/AGENTS.md b/tests/arch/AGENTS.md index ca14df874..277cd7d1a 100644 --- a/tests/arch/AGENTS.md +++ b/tests/arch/AGENTS.md @@ -111,6 +111,7 @@ AST enforcement, sub-package layer contracts, and architectural invariant tests. | `test_cmd_spec_resume_no_string_match.py` | AST guard: no `"--resume" in ` patterns in production code — use CmdSpec.is_resume instead | | `test_subagent_filter_guard.py` | AST guard: all assistant-record NDJSON processing sites must use _is_parent_assistant_record or _is_parent_assistant predicate | | `test_swap_labels_guard.py` | AST guard: direct swap_labels calls in fleet/ must go through cleanup_orphaned_labels | +| `test_invariant_registry_coverage.py` | Meta-test: every InvariantDef has resolvable gate_target, non-empty fields, and source_doc containing prohibition prose | | `test_issue_url_extraction_guard.py` | AST guard: raw `.get('issue_url')` / `.get('issue_urls')` banned in fleet/; dual-key asserted in fleet_claim_guard.py | | `test_enqueue_ready_type_enforcement.py` | AST guard: mutation methods (_enqueue_direct, _enable_auto_merge_direct) must accept EnqueueReady, not str | | `test_origin_isolation_contract.py` | AST + shell lint guard: no hardcoded "origin" in git remote operations outside allowlist; shell scripts must try upstream before origin | diff --git a/tests/arch/test_invariant_registry_coverage.py b/tests/arch/test_invariant_registry_coverage.py new file mode 100644 index 000000000..96c1d774f --- /dev/null +++ b/tests/arch/test_invariant_registry_coverage.py @@ -0,0 +1,151 @@ +"""Meta-test: every InvariantDef must have a resolvable gate_target, +non-empty required fields, and a source_doc that still contains the +original prohibition prose anchor. +""" + +from __future__ import annotations + +import importlib.resources +import re +from pathlib import Path + +import pytest + +from autoskillit.core.types import INVARIANT_REGISTRY + +pytestmark = [pytest.mark.layer("arch"), pytest.mark.small] + +_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent +_SRC_ROOT = _PROJECT_ROOT / "src" / "autoskillit" + + +def _resolve_gate_target(target: str) -> bool: + if "/" in target or target.endswith(".py"): + pkg = Path(str(importlib.resources.files("autoskillit"))) + if (pkg / target).is_file(): + return True + if (pkg / "hooks" / target).is_file(): + return True + return False + if "." in target: + try: + importlib.import_module(target) + return True + except ImportError: + return False + try: + importlib.import_module(target) + return True + except ImportError: + return False + + +def _extract_prose_anchor(prohibition: str) -> str: + # 1. Parenthetical content — first item + m = re.search(r"\(([^)]+)\)", prohibition) + if m: + first_item = m.group(1).split(",")[0].strip().strip("`") + if len(first_item) > 2: + return first_item + + # 2. CLI flag (--word) + m = re.search(r"(--\w+)", prohibition) + if m: + return m.group(1) + + # 3. Underscore identifier + m = re.search(r"(\w+_\w+)", prohibition) + if m: + return m.group(1) + + # 4. Slash-separated path + m = re.search(r"(\w+/\w+)", prohibition) + if m: + return m.group(1) + + # 5. Fallback: extract subject before verb, take last 2+ words. + # When the second-to-last word ends with ":" (a YAML field token like "cmd:"), + # return that token alone — it is more precise than "token: next_word". + for verb in ( + "is prohibited", + "are prohibited", + "is blocked", + "are blocked", + "are banned", + "cannot be called", + "cannot be", + ): + idx = prohibition.lower().find(verb) + if idx > 0: + subject = prohibition[:idx].strip() + words = subject.split() + if len(words) >= 2: + tail = words[-2:] + if tail[0].endswith(":"): + return tail[0] + return " ".join(tail) + return subject + + # Final fallback: longest word (>= 5 chars) as a distinctive anchor. + # A 30-char prefix is unreliable — it may straddle a line break in source_doc. + words = [w.strip(".,!?;:'\"()[]{}") for w in prohibition.split()] + long_words = sorted((w for w in words if len(w) >= 5), key=len, reverse=True) + return long_words[0] if long_words else prohibition[:30] + + +def _collect_source_doc_files(source_doc: str) -> list[Path]: + if "/" in source_doc: + return [_PROJECT_ROOT / source_doc] + if source_doc == "AGENTS.md": + candidates = [_PROJECT_ROOT / "AGENTS.md"] + candidates.extend(p for p in _SRC_ROOT.rglob("AGENTS.md") if "__pycache__" not in str(p)) + return candidates + if source_doc == "SKILL.md": + candidates = [] + for subdir in ("skills", "skills_extended"): + d = _SRC_ROOT / subdir + if d.is_dir(): + candidates.extend(p for p in d.rglob("SKILL.md") if "__pycache__" not in str(p)) + # Guard prohibitions that map to source_doc="SKILL.md" are also + # documented in AGENTS.md files (e.g. hooks/guards/AGENTS.md). + candidates.extend(p for p in _SRC_ROOT.rglob("AGENTS.md") if "__pycache__" not in str(p)) + return candidates + return [_PROJECT_ROOT / source_doc] + + +def test_every_gate_target_resolves(): + for key, inv in INVARIANT_REGISTRY.items(): + assert _resolve_gate_target(inv.gate_target), ( + f"{key}: gate_target {inv.gate_target!r} " + "does not resolve to an existing file or module" + ) + + +def test_every_prohibition_appears_in_registry(): + assert INVARIANT_REGISTRY, "INVARIANT_REGISTRY must not be empty" + for key, inv in INVARIANT_REGISTRY.items(): + assert inv.id, f"{key}: id is empty" + assert inv.prohibition, f"{key}: prohibition is empty" + assert inv.source_doc, f"{key}: source_doc is empty" + assert inv.gate_target, f"{key}: gate_target is empty" + assert inv.enforcement_layer, f"{key}: enforcement_layer is empty" + assert inv.backends, f"{key}: backends is empty" + + +def test_no_orphaned_registry_entries(): + content_cache: dict[str, str] = {} + for key, inv in INVARIANT_REGISTRY.items(): + if inv.source_doc not in content_cache: + candidates = _collect_source_doc_files(inv.source_doc) + existing = [p for p in candidates if p.is_file()] + assert existing, f"{key}: source_doc {inv.source_doc!r} has no matching files on disk" + content_cache[inv.source_doc] = "\n".join( + p.read_text(encoding="utf-8") for p in existing + ) + + combined = content_cache[inv.source_doc] + anchor = _extract_prose_anchor(inv.prohibition) + assert anchor.lower() in combined.lower(), ( + f"{key}: prose anchor {anchor!r} (from prohibition " + f"{inv.prohibition!r}) not found in any {inv.source_doc} file" + )