Skip to content

Commit 78f755d

Browse files
Trecekclaude
andauthored
Implementation Plan: T5-P5-A2-WP1 — Invariant Registry Coverage Meta-Test (#4143)
## Summary Add a load-bearing meta-test at `tests/arch/test_invariant_registry_coverage.py` that validates every `InvariantDef` in `INVARIANT_REGISTRY` has a resolvable `gate_target`, non-empty required fields, and a `source_doc` that still contains the original prohibition prose. Three test functions plus a helper. Update `tests/arch/AGENTS.md` file-table with the new entry. Closes #4033 ## Implementation Plan Plan file: `/home/talon/projects/autoskillit-runs/impl-20260628-082224-932884/.autoskillit/temp/make-plan/t5-p5-a2-wp1-invariant-registry-meta-test_plan_2026-06-28_090000.md` 🤖 Generated with [Claude Code](https://claude.com/claude-code) via AutoSkillit <!-- autoskillit:pipeline-signature steps=prepare_pr,run_arch_lenses,compose_pr,annotate_pr_diff,review_pr --> ## Token Usage Summary | Step | Model | count | uncached | output | cache_read | peak_ctx | turns | cache_write | time | |------|-------|-------|----------|--------|------------|----------|-------|-------------|------| | plan* | opus[1m] | 1 | 2.3k | 33.2k | 2.1M | 103.0k | 56 | 87.0k | 26m 44s | | verify* | sonnet | 1 | 284 | 55.7k | 3.4M | 147.0k | 103 | 128.5k | 17m 35s | | implement* | MiniMax-M3 | 1 | 66.3k | 7.1k | 1.5M | 0 | 58 | 0 | 3m 20s | | audit_impl* | sonnet | 1 | 44 | 7.5k | 163.9k | 42.2k | 13 | 31.5k | 4m 1s | | prepare_pr* | MiniMax-M3 | 1 | 42.0k | 2.0k | 152.2k | 0 | 13 | 0 | 38s | | compose_pr* | MiniMax-M3 | 1 | 35.8k | 1.3k | 140.7k | 0 | 13 | 0 | 30s | | **Total** | | | 146.7k | 106.9k | 7.5M | 147.0k | | 247.0k | 52m 50s | \* *Step used a non-Anthropic provider; caching behavior may differ.* ## Token Efficiency | Step | LoC Changed | cache_read/LoC | cache_write/LoC | output/LoC | |------|-------------|----------------|-----------------|------------| | plan | 0 | — | — | — | | verify | 0 | — | — | — | | implement | 139 | 10950.9 | 0.0 | 51.1 | | audit_impl | 0 | — | — | — | | prepare_pr | 0 | — | — | — | | compose_pr | 0 | — | — | — | | **Total** | **139** | 54167.3 | 1776.9 | 769.0 | ## Model Usage Breakdown | Model | steps | uncached | output | cache_read | cache_write | time | |-------|-------|----------|--------|------------|-------------|------| | opus[1m] | 1 | 2.3k | 33.2k | 2.1M | 87.0k | 26m 44s | | sonnet | 2 | 328 | 63.3k | 3.6M | 160.0k | 21m 37s | | MiniMax-M3 | 3 | 144.1k | 10.4k | 1.8M | 0 | 4m 28s | --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 6ccbe29 commit 78f755d

2 files changed

Lines changed: 152 additions & 0 deletions

File tree

tests/arch/AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ AST enforcement, sub-package layer contracts, and architectural invariant tests.
111111
| `test_cmd_spec_resume_no_string_match.py` | AST guard: no `"--resume" in <expr>` patterns in production code — use CmdSpec.is_resume instead |
112112
| `test_subagent_filter_guard.py` | AST guard: all assistant-record NDJSON processing sites must use _is_parent_assistant_record or _is_parent_assistant predicate |
113113
| `test_swap_labels_guard.py` | AST guard: direct swap_labels calls in fleet/ must go through cleanup_orphaned_labels |
114+
| `test_invariant_registry_coverage.py` | Meta-test: every InvariantDef has resolvable gate_target, non-empty fields, and source_doc containing prohibition prose |
114115
| `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 |
115116
| `test_enqueue_ready_type_enforcement.py` | AST guard: mutation methods (_enqueue_direct, _enable_auto_merge_direct) must accept EnqueueReady, not str |
116117
| `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 |
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
"""Meta-test: every InvariantDef must have a resolvable gate_target,
2+
non-empty required fields, and a source_doc that still contains the
3+
original prohibition prose anchor.
4+
"""
5+
6+
from __future__ import annotations
7+
8+
import importlib.resources
9+
import re
10+
from pathlib import Path
11+
12+
import pytest
13+
14+
from autoskillit.core.types import INVARIANT_REGISTRY
15+
16+
pytestmark = [pytest.mark.layer("arch"), pytest.mark.small]
17+
18+
_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
19+
_SRC_ROOT = _PROJECT_ROOT / "src" / "autoskillit"
20+
21+
22+
def _resolve_gate_target(target: str) -> bool:
23+
if "/" in target or target.endswith(".py"):
24+
pkg = Path(str(importlib.resources.files("autoskillit")))
25+
if (pkg / target).is_file():
26+
return True
27+
if (pkg / "hooks" / target).is_file():
28+
return True
29+
return False
30+
if "." in target:
31+
try:
32+
importlib.import_module(target)
33+
return True
34+
except ImportError:
35+
return False
36+
try:
37+
importlib.import_module(target)
38+
return True
39+
except ImportError:
40+
return False
41+
42+
43+
def _extract_prose_anchor(prohibition: str) -> str:
44+
# 1. Parenthetical content — first item
45+
m = re.search(r"\(([^)]+)\)", prohibition)
46+
if m:
47+
first_item = m.group(1).split(",")[0].strip().strip("`")
48+
if len(first_item) > 2:
49+
return first_item
50+
51+
# 2. CLI flag (--word)
52+
m = re.search(r"(--\w+)", prohibition)
53+
if m:
54+
return m.group(1)
55+
56+
# 3. Underscore identifier
57+
m = re.search(r"(\w+_\w+)", prohibition)
58+
if m:
59+
return m.group(1)
60+
61+
# 4. Slash-separated path
62+
m = re.search(r"(\w+/\w+)", prohibition)
63+
if m:
64+
return m.group(1)
65+
66+
# 5. Fallback: extract subject before verb, take last 2+ words.
67+
# When the second-to-last word ends with ":" (a YAML field token like "cmd:"),
68+
# return that token alone — it is more precise than "token: next_word".
69+
for verb in (
70+
"is prohibited",
71+
"are prohibited",
72+
"is blocked",
73+
"are blocked",
74+
"are banned",
75+
"cannot be called",
76+
"cannot be",
77+
):
78+
idx = prohibition.lower().find(verb)
79+
if idx > 0:
80+
subject = prohibition[:idx].strip()
81+
words = subject.split()
82+
if len(words) >= 2:
83+
tail = words[-2:]
84+
if tail[0].endswith(":"):
85+
return tail[0]
86+
return " ".join(tail)
87+
return subject
88+
89+
# Final fallback: longest word (>= 5 chars) as a distinctive anchor.
90+
# A 30-char prefix is unreliable — it may straddle a line break in source_doc.
91+
words = [w.strip(".,!?;:'\"()[]{}") for w in prohibition.split()]
92+
long_words = sorted((w for w in words if len(w) >= 5), key=len, reverse=True)
93+
return long_words[0] if long_words else prohibition[:30]
94+
95+
96+
def _collect_source_doc_files(source_doc: str) -> list[Path]:
97+
if "/" in source_doc:
98+
return [_PROJECT_ROOT / source_doc]
99+
if source_doc == "AGENTS.md":
100+
candidates = [_PROJECT_ROOT / "AGENTS.md"]
101+
candidates.extend(p for p in _SRC_ROOT.rglob("AGENTS.md") if "__pycache__" not in str(p))
102+
return candidates
103+
if source_doc == "SKILL.md":
104+
candidates = []
105+
for subdir in ("skills", "skills_extended"):
106+
d = _SRC_ROOT / subdir
107+
if d.is_dir():
108+
candidates.extend(p for p in d.rglob("SKILL.md") if "__pycache__" not in str(p))
109+
# Guard prohibitions that map to source_doc="SKILL.md" are also
110+
# documented in AGENTS.md files (e.g. hooks/guards/AGENTS.md).
111+
candidates.extend(p for p in _SRC_ROOT.rglob("AGENTS.md") if "__pycache__" not in str(p))
112+
return candidates
113+
return [_PROJECT_ROOT / source_doc]
114+
115+
116+
def test_every_gate_target_resolves():
117+
for key, inv in INVARIANT_REGISTRY.items():
118+
assert _resolve_gate_target(inv.gate_target), (
119+
f"{key}: gate_target {inv.gate_target!r} "
120+
"does not resolve to an existing file or module"
121+
)
122+
123+
124+
def test_every_prohibition_appears_in_registry():
125+
assert INVARIANT_REGISTRY, "INVARIANT_REGISTRY must not be empty"
126+
for key, inv in INVARIANT_REGISTRY.items():
127+
assert inv.id, f"{key}: id is empty"
128+
assert inv.prohibition, f"{key}: prohibition is empty"
129+
assert inv.source_doc, f"{key}: source_doc is empty"
130+
assert inv.gate_target, f"{key}: gate_target is empty"
131+
assert inv.enforcement_layer, f"{key}: enforcement_layer is empty"
132+
assert inv.backends, f"{key}: backends is empty"
133+
134+
135+
def test_no_orphaned_registry_entries():
136+
content_cache: dict[str, str] = {}
137+
for key, inv in INVARIANT_REGISTRY.items():
138+
if inv.source_doc not in content_cache:
139+
candidates = _collect_source_doc_files(inv.source_doc)
140+
existing = [p for p in candidates if p.is_file()]
141+
assert existing, f"{key}: source_doc {inv.source_doc!r} has no matching files on disk"
142+
content_cache[inv.source_doc] = "\n".join(
143+
p.read_text(encoding="utf-8") for p in existing
144+
)
145+
146+
combined = content_cache[inv.source_doc]
147+
anchor = _extract_prose_anchor(inv.prohibition)
148+
assert anchor.lower() in combined.lower(), (
149+
f"{key}: prose anchor {anchor!r} (from prohibition "
150+
f"{inv.prohibition!r}) not found in any {inv.source_doc} file"
151+
)

0 commit comments

Comments
 (0)