feat(#58): L3 dormant trigger guard -- DORMANT-TRIGGERS.yaml + checker + orchestrator hook

P5-1 docs/architecture/DORMANT-TRIGGERS.yaml -- 5 entries (IMP-16/17/18/19 active + IMP-20 followup-linked #55).
P5-2 scripts/check_dormant_triggers.py -- standalone, reads registry, scans tree + diff, writes .orchestrator/dormant_alerts.json, exit 0 always.
P5-3 orchestrator.py -- _check_dormant_triggers() helper + Stage 4->5 informational alert branch (skips audit-only, never blocks).
P5-4 tests/orchestrator_unit/test_dormant_triggers.py -- 30 cases (yaml schema, registry contents, checker matching, false-positive guards, manual-evidence skip, orchestrator branch, audit bypass, governance ref).
P5-5 PROJECT-INTENT-AND-GOVERNANCE.md -- single anti-patterns row referencing the L3 registry as binding contract surface.

Tests: pytest -q tests = 337 passed (baseline 307 + 30 new).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-20 09:43:14 +09:00
parent 8c1e56366b
commit 134f52d3d3
5 changed files with 887 additions and 0 deletions

View File

@@ -0,0 +1,135 @@
# Dormant trigger registry (L3 layer — machine-readable).
#
# Purpose :
# Closed-but-binding dormant backlog rows ("documented:dormant" /
# "documented (deferred)") carry implicit "trigger-on-X" contracts.
# L1 (human memory) + L2 (periodic INTEGRATION-AUDIT) are fragile / late.
# This file is the single source of truth that scripts/check_dormant_triggers.py
# reads to flag activation candidates on every orchestrator run.
#
# Schema (per entry) :
# - issue : int # closed Gitea issue id (the dormant axis)
# - title : string
# - doc : string # repo-relative path to the dormant reference doc
# - doc_evidence_lines : string # "start-end" line range citing the activation-gate text
# - status : enum # documented:dormant | documented:deferred | documented:no-runtime | followup-linked
# - followup_issue : int|null # set when an open issue already tracks the watch (then no checker watch needed)
# - trigger
# description : string
# file_patterns : [glob] # working-tree paths checked against changed files
# content_patterns : [regex] # python re patterns matched against changed-file contents
# manual_evidence_required : bool # true → checker skips (human-only gate; e.g. User GO, sign-off, runtime regression analysis)
# - on_trigger
# action : enum # create_runtime_issue | reactivate_dormant | manual_review | note_only
# template : string # suggested follow-up issue title (if action ≠ note_only)
#
# Guardrails :
# - Checker is informational only (exit 0 always; orchestrator never blocks Stage 5 on alerts).
# - manual_evidence_required: true entries do NOT auto-fire — they are noted for human review.
# - followup_issue is set: the registry entry is note-only; no checker watch (the open issue tracks the axis).
# - Out of scope for this registry : IMP-07 (documented:no-runtime — policy decline, reactivation = policy reopen, not a code trigger).
- issue: 16
title: "IMP-16 U2 wiring (Phase Q U1 → Phase Z runtime)"
doc: docs/architecture/IMP-16-U2-WIRING-DESIGN.md
doc_evidence_lines: "21-25"
status: documented:dormant
followup_issue: null
trigger:
description: >-
IMP-07 reverse-path actually lands runtime — a non-test module under src/
introduces the reverse-path adapter (html_to_slide_mdx / edited_html_to_mdx /
reverse_path). At that point IMP-16 U2 wiring (Step 1/2/14 surface use)
becomes a live integration axis, not a paper design.
file_patterns:
- "src/**/*.py"
content_patterns:
- "html_to_slide_mdx"
- "edited_html_to_mdx"
- "reverse_path"
manual_evidence_required: false
on_trigger:
action: create_runtime_issue
template: "[IMP-16][P5][WIRING] Activate U2 reverse-path wiring against new IMP-07 adapter"
- issue: 17
title: "IMP-17 AI repair fallback carve-out"
doc: docs/architecture/IMP-17-CARVE-OUT.md
doc_evidence_lines: "25-31"
status: documented:dormant
followup_issue: null
trigger:
description: >-
3-condition AND gate: (1) explicit User GO for axis activation,
(2) B4 frame_selection evidence integration complete (Step 9 evidence trace
stabilised), (3) IMP-04 (catalog expansion to 32 frames) + IMP-05 (V4
rank-2/3 fallback) live. All three required before the carve-out exits
design-only state.
file_patterns: []
content_patterns: []
manual_evidence_required: true
on_trigger:
action: manual_review
template: "[IMP-17][P5][CARVE-OUT] Activate ai_adaptation_required fallback (3-cond gate cleared)"
- issue: 18
title: "IMP-18 SVG coordinate pipeline gap report"
doc: docs/architecture/IMP-18-SVG-GAP-REPORT.md
doc_evidence_lines: "38-43"
status: documented:dormant
followup_issue: null
trigger:
description: >-
An SVG-bearing partial lands under templates/phase_z2/ (families or frames)
AND the partial declares slots consuming items[*].cx/cy/r + outer_r +
viewbox_* (the prepare_venn_data return contract). IMP-04 frame_partials
registration is the natural upstream.
file_patterns:
- "templates/phase_z2/families/*.html"
- "templates/phase_z2/frames/*.html"
content_patterns:
- "<svg"
- "viewBox"
manual_evidence_required: false
on_trigger:
action: create_runtime_issue
template: "[IMP-18][P5][SVG] Activate SVG coordinate pipeline for new partial"
- issue: 19
title: "IMP-19 zone ratio reference (Phase O role-container pattern)"
doc: docs/architecture/IMP-19-ZONE-RATIO-REFERENCE.md
doc_evidence_lines: "83-90"
status: documented:dormant
followup_issue: null
trigger:
description: >-
Phase Z Step 8 solver (min_height_first + content_weight) produces a
verifiable regression that the Phase O role-container pattern would have
handled correctly, AND the IMP-09 owner confirms the case is not
addressable inside the Phase Z solver (visual_hints.min_height_px /
content_weight.score adjustments insufficient). Requires failing-case MDX
+ frame_contract trace + observed vs expected geometry.
file_patterns: []
content_patterns: []
manual_evidence_required: true
on_trigger:
action: manual_review
template: "[IMP-19][P5][ZONE-RATIO] Re-activate Phase O role-container pattern (IMP-09 sign-off attached)"
- issue: 20
title: "IMP-20 frame contract validation reference"
doc: docs/architecture/IMP-20-FRAME-CONTRACT-VALIDATION-REFERENCE.md
doc_evidence_lines: "85-91"
status: followup-linked
followup_issue: 55
trigger:
description: >-
§A5 3-cond AND gate (Step 10 partial frame-contract emit insufficient +
evidence + IMP-04 sign-off). Watch surface already owned by open issue
#55 — no checker watch installed here to avoid double-tracking.
file_patterns: []
content_patterns: []
manual_evidence_required: true
on_trigger:
action: note_only
template: "Tracked under open issue #55 — no new watch needed."

View File

@@ -157,6 +157,7 @@ Q~Y 검토 결과를 22-step 의 어느 step 에 어떤 부품을 가져올지
| "이슈 본문은 참고일뿐" | 본문의 (관련 step, source, scope, guardrails) 가 binding anchor |
| "Phase R / R' / Q 의 path 로 돌아가도 됨" | 회귀 금지선 4 항목 (INSIGHT-MAP §0) 절대 위반 X |
| "destination 외 추가 기능도 욕심내자" | 22-step + AI frame generation 까지가 목표. 그 이상은 별도 결정 |
| "문서에 박힌 dormant 항목은 자동 실행 안 됨" | L3 registry [`DORMANT-TRIGGERS.yaml`](DORMANT-TRIGGERS.yaml) + `scripts/check_dormant_triggers.py` 가 orchestrator Stage 4→5 transition 에서 informational alert 로 발화 (closed 이슈 #16/#17/#18/#19/#20 의 trigger-on-X contract) |
---

View File

@@ -855,6 +855,47 @@ def _check_audit_commit_scope():
bad.append(path)
return bad
# P5-2 (2026-05-20) — Dormant trigger guard (L3 layer, issue #58).
# Closed dormant backlog rows (documented:dormant / documented:deferred) carry
# implicit "trigger-on-X" contracts. This helper invokes the standalone
# checker (scripts/check_dormant_triggers.py) which reads the machine-readable
# registry (docs/architecture/DORMANT-TRIGGERS.yaml) and writes activation
# candidates to .orchestrator/dormant_alerts.json.
#
# Guardrails (per Stage 1 scope-lock) :
# - Informational only. Returns the alert list; orchestrator never blocks.
# - manual_evidence_required / followup-linked entries are skipped INSIDE
# the checker (not duplicated here — registry is single source of truth).
# - No LLM call. Deterministic subprocess invocation only.
# - Fail-open : any subprocess / json error returns [] (no false positives).
def _check_dormant_triggers():
"""P5-2 — Run scripts/check_dormant_triggers.py and return the alert list.
Returns: list[dict] of activation-candidate alerts (empty list = no
candidates OR script / parse error). Orchestrator never blocks on this."""
script_path = Path(PROJECT_DIR) / "scripts" / "check_dormant_triggers.py"
if not script_path.exists():
return [] # registry / checker not installed yet — fail open
try:
r = subprocess.run(
[sys.executable, str(script_path)],
capture_output=True, text=True, encoding="utf-8", errors="replace",
cwd=PROJECT_DIR, timeout=30,
)
if r.returncode != 0:
return [] # script error — fail open
except Exception:
return []
alert_path = ORCH_DIR / "dormant_alerts.json"
if not alert_path.exists():
return []
try:
payload = json.loads(alert_path.read_text(encoding="utf-8"))
alerts = payload.get("alerts", [])
return alerts if isinstance(alerts, list) else []
except Exception:
return []
# P1-5 (2026-05-18) — Stage 2 compact rule (모든 issue 적용).
# Stage 2 의 c-role 에 size budget + code snippet 금지 명시. 29 KB plan 차단.
COMPACT_PLAN_RULE = """
@@ -1430,6 +1471,33 @@ def run_stage(n, title, body, sid):
except: pass
continue
# P5-2 (2026-05-20) — Dormant trigger guard (L3 layer, issue #58).
# Stage 4 (test-verify) PASS → run dormant trigger checker against the
# current change surface. If alerts written, post INFORMATIONAL supplement
# comment. NEVER blocks Stage 5 entry (checker is exit 0; helper fail-open).
# Audit-only issues skip — their change surface is restricted to audit docs,
# which the registry does not watch.
if sid == "test-verify" and not _audit_mode(title):
alerts = _check_dormant_triggers()
if alerts:
log(f" Dormant trigger guard: {len(alerts)} activation candidate(s) detected (informational)")
try: gitea(f"issues/{n}/comments", "POST", {"body":
" **[Orchestrator]** Dormant trigger guard — informational alert (does NOT block Stage 5).\n\n"
"The following closed dormant backlog axes have changed-file evidence matching their "
"activation triggers. Registry: `docs/architecture/DORMANT-TRIGGERS.yaml`. "
"Alert artifact: `.orchestrator/dormant_alerts.json`.\n\n" +
"\n".join(
f"- **#{a.get('issue')}** {a.get('title')}"
f"`{(a.get('on_trigger') or {}).get('action', '?')}` "
f"({len(((a.get('match') or {}).get('files')) or [])} file(s))"
for a in alerts[:10]
) +
("\n - ... (truncated)" if len(alerts) > 10 else "") + "\n\n"
"Recommended next step : open a follow-up issue using the `template:` field in the "
"registry, OR acknowledge in the next stage comment. Stage 5 proceeds regardless."})
except: pass
# Never `continue` — checker is informational only (Stage 1 guardrail).
log(f"{si['label']} — YES (evidence verified)")
# stage 완료 = unit counter + remaining tracker 모두 reset
update_issue_state(n, continue_same_count=0, last_remaining_units=None)

View File

@@ -0,0 +1,191 @@
"""Dormant trigger guard — L3 machine-readable check (issue #58, P5-2).
Reads docs/architecture/DORMANT-TRIGGERS.yaml, scans the changed-file surface
(working tree via `git status --porcelain` + recent commit via
`git diff HEAD~1..HEAD --name-only`), and writes any matching activation
candidates to .orchestrator/dormant_alerts.json.
Guardrails (per Stage 1 scope-lock) :
- Informational only. Exit code is ALWAYS 0 — orchestrator never blocks on alerts.
- manual_evidence_required entries are skipped (require human gate).
- followup_issue entries are skipped (already tracked by the open follow-up).
- No LLM call. Deterministic file-pattern + content-pattern matching only.
- No hardcoding : the registry yaml is the single source of truth.
Run :
python scripts/check_dormant_triggers.py
"""
from __future__ import annotations
import json
import re
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
import yaml
REPO_ROOT = Path(__file__).resolve().parent.parent
REGISTRY_PATH = REPO_ROOT / "docs" / "architecture" / "DORMANT-TRIGGERS.yaml"
ALERT_OUT_PATH = REPO_ROOT / ".orchestrator" / "dormant_alerts.json"
def load_registry(path: Path = REGISTRY_PATH) -> list[dict]:
if not path.exists():
return []
with path.open("r", encoding="utf-8") as f:
data = yaml.safe_load(f) or []
if not isinstance(data, list):
raise ValueError(f"{path} must be a YAML list of entries.")
return data
def _git_lines(args: list[str]) -> list[str]:
try:
out = subprocess.run(
["git"] + args,
cwd=str(REPO_ROOT),
capture_output=True,
text=True,
timeout=20,
check=False,
)
except (OSError, subprocess.TimeoutExpired):
return []
if out.returncode != 0:
return []
return [ln for ln in out.stdout.splitlines() if ln.strip()]
def collect_changed_files() -> list[str]:
files: set[str] = set()
for ln in _git_lines(["status", "--porcelain"]):
path = ln[3:].strip() if len(ln) >= 4 else ln.strip()
if "->" in path:
path = path.split("->", 1)[1].strip()
path = path.strip('"')
if path:
files.add(path.replace("\\", "/"))
for ln in _git_lines(["diff", "HEAD~1..HEAD", "--name-only"]):
if ln.strip():
files.add(ln.strip().replace("\\", "/"))
return sorted(files)
def _glob_to_regex(pat: str) -> str:
"""Translate a posix-style glob with ``**`` to an anchored regex.
``**/`` matches zero or more directory levels (so ``src/**/*.py`` matches
both ``src/adapter.py`` and ``src/foo/adapter.py``). ``*`` and ``?`` do
NOT cross directory separators. Mirrors common ``.gitignore``-style
semantics; ``fnmatch.fnmatch`` alone cannot express this.
"""
out: list[str] = []
i = 0
n = len(pat)
while i < n:
if pat[i : i + 3] == "**/":
out.append("(?:.*/)?")
i += 3
elif pat[i : i + 2] == "**":
out.append(".*")
i += 2
elif pat[i] == "*":
out.append("[^/]*")
i += 1
elif pat[i] == "?":
out.append("[^/]")
i += 1
else:
out.append(re.escape(pat[i]))
i += 1
return "^" + "".join(out) + "$"
def _glob_match(path: str, patterns: list[str]) -> bool:
for pat in patterns:
if re.match(_glob_to_regex(pat), path):
return True
return False
def _content_match(file_path: Path, patterns: list[str]) -> list[str]:
if not patterns or not file_path.exists() or not file_path.is_file():
return []
try:
text = file_path.read_text(encoding="utf-8", errors="replace")
except OSError:
return []
hits = []
for pat in patterns:
try:
if re.search(pat, text):
hits.append(pat)
except re.error:
if pat in text:
hits.append(pat)
return hits
def check_entry(entry: dict, changed: list[str]) -> dict | None:
trig = entry.get("trigger") or {}
if trig.get("manual_evidence_required"):
return None
if entry.get("followup_issue"):
return None
file_patterns = trig.get("file_patterns") or []
content_patterns = trig.get("content_patterns") or []
if not file_patterns:
return None
matched_files = [p for p in changed if _glob_match(p, file_patterns)]
if not matched_files:
return None
if content_patterns:
hits: list[dict] = []
for mf in matched_files:
hit_patterns = _content_match(REPO_ROOT / mf, content_patterns)
if hit_patterns:
hits.append({"file": mf, "patterns": hit_patterns})
if not hits:
return None
match_info = {"files": [h["file"] for h in hits], "content_hits": hits}
else:
match_info = {"files": matched_files, "content_hits": []}
return {
"issue": entry.get("issue"),
"title": entry.get("title"),
"doc": entry.get("doc"),
"status": entry.get("status"),
"on_trigger": entry.get("on_trigger"),
"match": match_info,
}
def write_alerts(alerts: list[dict], path: Path = ALERT_OUT_PATH) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
payload = {
"generated_at": datetime.now(timezone.utc).isoformat(),
"registry": str(REGISTRY_PATH.relative_to(REPO_ROOT)).replace("\\", "/"),
"alerts": alerts,
}
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
def main() -> int:
entries = load_registry()
changed = collect_changed_files()
alerts = [a for a in (check_entry(e, changed) for e in entries) if a]
write_alerts(alerts)
if alerts:
print(f"[dormant-trigger-guard] {len(alerts)} alert(s) written -> "
f"{ALERT_OUT_PATH.relative_to(REPO_ROOT)}")
for a in alerts:
print(f" - #{a['issue']} {a['title']} (files: {len(a['match']['files'])})")
else:
print("[dormant-trigger-guard] no dormant trigger alerts on current change surface.")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,492 @@
"""P5 (2026-05-20) — Dormant trigger guard tests (issue #58, unit u4).
Covers the L3 dormant trigger layer end-to-end:
- u1 — docs/architecture/DORMANT-TRIGGERS.yaml schema + content for the
IMP-16 / IMP-17 / IMP-18 / IMP-19 / IMP-20 axes.
- u2 — scripts/check_dormant_triggers.py file-pattern + content-pattern
matching, manual-evidence skip, followup-linked skip,
false-positive guards, exit-0 standalone invocation.
- u3 — orchestrator._check_dormant_triggers() helper fail-open contract
and the Stage 4→5 _audit_mode() bypass predicate.
- u5 — DORMANT-TRIGGERS.yaml self-documenting header
(governance doc cross-reference test runs after u5 lands).
Each test names the IMP-# trigger it exercises (scope-qualified verification
per the work-principles lock).
Run: pytest -q tests/orchestrator_unit/test_dormant_triggers.py
"""
from __future__ import annotations
import importlib
import json
import subprocess
import sys
from pathlib import Path
import pytest
import yaml
ROOT = Path(__file__).parent.parent.parent
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "scripts"))
REGISTRY_PATH = ROOT / "docs" / "architecture" / "DORMANT-TRIGGERS.yaml"
GOVERNANCE_PATH = ROOT / "docs" / "architecture" / "PROJECT-INTENT-AND-GOVERNANCE.md"
CHECKER_PATH = ROOT / "scripts" / "check_dormant_triggers.py"
# ─────────────────────────────────────────────────────────────────
# Shared fixtures
# ─────────────────────────────────────────────────────────────────
@pytest.fixture(scope="module")
def registry_entries() -> list[dict]:
"""Parsed registry — used across schema + content tests."""
with REGISTRY_PATH.open("r", encoding="utf-8") as f:
data = yaml.safe_load(f)
assert isinstance(data, list), "registry root must be a YAML list"
return data
@pytest.fixture
def chkmod():
"""Fresh import of the standalone checker module (function-scoped so
monkeypatched module attributes do not leak across tests)."""
if "check_dormant_triggers" in sys.modules:
return importlib.reload(sys.modules["check_dormant_triggers"])
import check_dormant_triggers as m # noqa: WPS433 — runtime import by design
return m
@pytest.fixture
def orch():
"""Orchestrator module under test (u3 helper)."""
import orchestrator as m # noqa: WPS433
return m
# ─────────────────────────────────────────────────────────────────
# u1 — registry yaml schema + content
# ─────────────────────────────────────────────────────────────────
class TestRegistrySchema:
"""u1 — DORMANT-TRIGGERS.yaml exists, parses, and shapes the 5 dormant axes."""
def test_registry_yaml_parses_with_pyyaml(self):
"""Schema sanity (covers all of IMP-16/17/18/19/20): file parses as YAML list."""
assert REGISTRY_PATH.exists(), f"registry missing: {REGISTRY_PATH}"
with REGISTRY_PATH.open("r", encoding="utf-8") as f:
data = yaml.safe_load(f)
assert isinstance(data, list), "registry root must be a YAML list"
def test_registry_has_5_entries_4_active_plus_1_followup_linked(self, registry_entries):
"""Stage 1/2 scope-lock: IMP-16 / IMP-17 / IMP-18 / IMP-19 + IMP-20 followup-linked."""
assert len(registry_entries) == 5
issues = sorted(e["issue"] for e in registry_entries)
assert issues == [16, 17, 18, 19, 20]
def test_registry_required_fields_present(self, registry_entries):
"""Every entry has issue / title / doc / status / trigger / on_trigger (covers IMP-16~20)."""
for e in registry_entries:
assert isinstance(e.get("issue"), int)
assert isinstance(e.get("title"), str) and e["title"]
assert isinstance(e.get("doc"), str) and e["doc"]
assert "status" in e
assert isinstance(e.get("trigger"), dict)
assert isinstance(e.get("on_trigger"), dict)
trig = e["trigger"]
assert "description" in trig
assert isinstance(trig.get("manual_evidence_required"), bool)
class TestImp16Entry:
"""IMP-16 (issue 16) — active watch on src/** reverse-path adapter."""
def test_imp16_active_src_glob_and_reverse_path_content_pattern(self, registry_entries):
"""IMP-16 trigger: src/**/*.py glob + reverse_path / html_to_slide_mdx content."""
e = next(x for x in registry_entries if x["issue"] == 16)
assert e["status"] == "documented:dormant"
assert not e.get("followup_issue"), "IMP-16 is active, not followup-linked"
t = e["trigger"]
assert t["manual_evidence_required"] is False
assert any("src/**" in p for p in t["file_patterns"])
cps = t["content_patterns"]
assert any("reverse_path" in p or "html_to_slide_mdx" in p for p in cps)
class TestImp17Entry:
"""IMP-17 (issue 17) — manual-evidence gate (3-cond User-GO AND)."""
def test_imp17_manual_evidence_required_true(self, registry_entries):
"""IMP-17 trigger gate: manual_evidence_required=true (User GO + B4 + IMP-04/05 live)."""
e = next(x for x in registry_entries if x["issue"] == 17)
assert e["trigger"]["manual_evidence_required"] is True
class TestImp18Entry:
"""IMP-18 (issue 18) — SVG partial under templates/phase_z2/."""
def test_imp18_active_watch_on_phase_z2_templates_with_svg_content(self, registry_entries):
"""IMP-18 trigger: templates/phase_z2/{families,frames}/*.html + SVG signature."""
e = next(x for x in registry_entries if x["issue"] == 18)
assert e["trigger"]["manual_evidence_required"] is False
fps = e["trigger"]["file_patterns"]
assert any("templates/phase_z2/" in p for p in fps)
cps = e["trigger"]["content_patterns"]
assert any("svg" in p.lower() or "viewBox" in p for p in cps)
class TestImp19Entry:
"""IMP-19 (issue 19) — manual-evidence gate (IMP-09 owner sign-off)."""
def test_imp19_manual_evidence_required_true(self, registry_entries):
"""IMP-19 trigger gate: manual_evidence_required=true (failing-case + IMP-09 sign-off)."""
e = next(x for x in registry_entries if x["issue"] == 19)
assert e["trigger"]["manual_evidence_required"] is True
class TestImp20Entry:
"""IMP-20 (issue 20) — followup-linked to open issue #55, note-only."""
def test_imp20_followup_linked_to_55_with_note_only_action(self, registry_entries):
"""IMP-20 status: followup_issue=55, on_trigger.action=note_only (no checker watch)."""
e = next(x for x in registry_entries if x["issue"] == 20)
assert e.get("followup_issue") == 55
assert e["on_trigger"]["action"] == "note_only"
# ─────────────────────────────────────────────────────────────────
# u2 — check_dormant_triggers.py
# ─────────────────────────────────────────────────────────────────
class TestCheckerMatching:
def test_checker_clean_tree_no_alerts_covers_imp16_18(self, chkmod, monkeypatch):
"""False-positive guard: empty change surface → no IMP-16 / IMP-18 alerts."""
monkeypatch.setattr(chkmod, "collect_changed_files", lambda: [])
entries = chkmod.load_registry()
alerts = [a for a in (chkmod.check_entry(e, []) for e in entries) if a]
assert alerts == []
def test_checker_imp16_alert_on_src_reverse_path_adapter(
self, chkmod, monkeypatch, tmp_path
):
"""IMP-16 positive: src/foo/adapter.py with `reverse_path` content → alert."""
fake_path = "src/foo/adapter.py"
(tmp_path / "src" / "foo").mkdir(parents=True)
(tmp_path / fake_path).write_text("def reverse_path(): pass\n", encoding="utf-8")
monkeypatch.setattr(chkmod, "REPO_ROOT", tmp_path)
entries = chkmod.load_registry()
imp16 = next(e for e in entries if e["issue"] == 16)
result = chkmod.check_entry(imp16, [fake_path])
assert result is not None
assert result["issue"] == 16
assert fake_path in result["match"]["files"]
def test_checker_imp16_no_alert_on_tests_path_false_positive_guard(
self, chkmod, monkeypatch, tmp_path
):
"""False-positive guard: IMP-16 must NOT fire on tests/foo.py even with matching content."""
fake_path = "tests/foo.py"
(tmp_path / "tests").mkdir()
(tmp_path / fake_path).write_text("def reverse_path(): pass\n", encoding="utf-8")
monkeypatch.setattr(chkmod, "REPO_ROOT", tmp_path)
entries = chkmod.load_registry()
imp16 = next(e for e in entries if e["issue"] == 16)
assert chkmod.check_entry(imp16, [fake_path]) is None
def test_checker_imp18_alert_on_phase_z2_family_svg(
self, chkmod, monkeypatch, tmp_path
):
"""IMP-18 positive: templates/phase_z2/families/new.html with <svg viewBox> → alert."""
fake_path = "templates/phase_z2/families/new_partial.html"
(tmp_path / "templates" / "phase_z2" / "families").mkdir(parents=True)
(tmp_path / fake_path).write_text(
'<svg viewBox="0 0 100 100"></svg>\n', encoding="utf-8"
)
monkeypatch.setattr(chkmod, "REPO_ROOT", tmp_path)
entries = chkmod.load_registry()
imp18 = next(e for e in entries if e["issue"] == 18)
result = chkmod.check_entry(imp18, [fake_path])
assert result is not None
assert result["issue"] == 18
def test_checker_imp18_flat_glob_boundary_nested_path_skipped(
self, chkmod, monkeypatch, tmp_path
):
"""False-positive guard: IMP-18 flat glob `families/*.html` skips nested family path."""
fake_path = "templates/phase_z2/families/nested/inner.html"
(tmp_path / "templates" / "phase_z2" / "families" / "nested").mkdir(parents=True)
(tmp_path / fake_path).write_text(
'<svg viewBox="0 0 100 100"></svg>\n', encoding="utf-8"
)
monkeypatch.setattr(chkmod, "REPO_ROOT", tmp_path)
entries = chkmod.load_registry()
imp18 = next(e for e in entries if e["issue"] == 18)
assert chkmod.check_entry(imp18, [fake_path]) is None
def test_checker_skips_imp17_manual_evidence(self, chkmod):
"""Guardrail: IMP-17 manual_evidence_required skips even with broad change surface."""
entries = chkmod.load_registry()
imp17 = next(e for e in entries if e["issue"] == 17)
assert chkmod.check_entry(imp17, ["src/foo.py", "anything.html"]) is None
def test_checker_skips_imp19_manual_evidence(self, chkmod):
"""Guardrail: IMP-19 manual_evidence_required skips even with broad change surface."""
entries = chkmod.load_registry()
imp19 = next(e for e in entries if e["issue"] == 19)
assert chkmod.check_entry(imp19, ["src/foo.py"]) is None
def test_checker_skips_imp20_followup_linked(self, chkmod):
"""Guardrail: IMP-20 followup_issue=55 skips (open issue #55 owns the watch)."""
entries = chkmod.load_registry()
imp20 = next(e for e in entries if e["issue"] == 20)
assert chkmod.check_entry(imp20, ["anything", "src/a.py"]) is None
def test_checker_standalone_invocation_exit_0(self):
"""Guardrail: standalone `python scripts/check_dormant_triggers.py` exits 0 always.
Exercises the IMP-16~20 informational-only contract — checker never blocks
the orchestrator regardless of working-tree state.
"""
r = subprocess.run(
[sys.executable, str(CHECKER_PATH)],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
cwd=str(ROOT),
timeout=30,
)
assert r.returncode == 0, f"checker exit={r.returncode} stderr={r.stderr!r}"
# ─────────────────────────────────────────────────────────────────
# u3 — orchestrator helper + Stage 4→5 hook
# ─────────────────────────────────────────────────────────────────
class TestOrchestratorDormantHook:
def test_orchestrator_helper_returns_list_on_clean_tree(self, orch):
"""u3 helper returns list[dict] (possibly empty) — never raises (IMP-16~20 informational)."""
result = orch._check_dormant_triggers()
assert isinstance(result, list)
def test_orchestrator_helper_fail_open_on_subprocess_error(self, orch, monkeypatch):
"""u3 helper: subprocess raise → [] (fail-open, no false positives across all IMPs)."""
def boom(*a, **kw):
raise RuntimeError("subprocess unavailable")
monkeypatch.setattr(orch.subprocess, "run", boom)
assert orch._check_dormant_triggers() == []
def test_orchestrator_helper_fail_open_on_nonzero_exit(self, orch, monkeypatch):
"""u3 helper: subprocess returncode != 0 → [] (fail-open)."""
class _Err:
returncode = 1
stdout = ""
stderr = "boom"
monkeypatch.setattr(orch.subprocess, "run", lambda *a, **kw: _Err())
assert orch._check_dormant_triggers() == []
def test_orchestrator_helper_fail_open_on_missing_alerts_file(
self, orch, monkeypatch, tmp_path
):
"""u3 helper: subprocess OK but alert file absent → [] (fail-open)."""
class _OK:
returncode = 0
stdout = ""
stderr = ""
monkeypatch.setattr(orch.subprocess, "run", lambda *a, **kw: _OK())
monkeypatch.setattr(orch, "ORCH_DIR", tmp_path)
assert orch._check_dormant_triggers() == []
def test_orchestrator_helper_parses_alerts_payload(self, orch, monkeypatch, tmp_path):
"""u3 helper: reads `alerts` list out of .orchestrator/dormant_alerts.json payload."""
class _OK:
returncode = 0
stdout = ""
stderr = ""
monkeypatch.setattr(orch.subprocess, "run", lambda *a, **kw: _OK())
monkeypatch.setattr(orch, "ORCH_DIR", tmp_path)
payload = {
"alerts": [
{"issue": 16, "title": "IMP-16 sample", "on_trigger": {"action": "create_runtime_issue"}},
],
}
(tmp_path / "dormant_alerts.json").write_text(
json.dumps(payload), encoding="utf-8"
)
result = orch._check_dormant_triggers()
assert isinstance(result, list)
assert len(result) == 1
assert result[0]["issue"] == 16
def test_orchestrator_helper_handles_non_list_alerts_payload(
self, orch, monkeypatch, tmp_path
):
"""u3 helper: malformed `alerts` (non-list) → [] (fail-open, IMP-16~20 informational)."""
class _OK:
returncode = 0
stdout = ""
stderr = ""
monkeypatch.setattr(orch.subprocess, "run", lambda *a, **kw: _OK())
monkeypatch.setattr(orch, "ORCH_DIR", tmp_path)
(tmp_path / "dormant_alerts.json").write_text(
json.dumps({"alerts": "oops"}), encoding="utf-8"
)
assert orch._check_dormant_triggers() == []
def test_stage_4_to_5_hook_predicate_audit_bypass(self, orch):
"""u3 Stage 4→5 hook guard: _audit_mode(title) True → dormant checker bypassed.
Mirrors P4a placement — the dormant hook condition is
`sid == "test-verify" and not _audit_mode(title)`. This test asserts
the gating predicate for the IMP-16/18 active watches.
"""
assert orch._audit_mode("[INTEGRATION-AUDIT-02] cumulative review") is True
assert orch._audit_mode("[AUDIT-ONLY] doc consistency") is True
assert orch._audit_mode("[P5][DORMANT-TRIGGER-GUARD] hook") is False
assert orch._audit_mode("IMP-16 U2 wiring") is False
# ─────────────────────────────────────────────────────────────────
# u3 — Stage 4→5 hook integration (focused static assertions on run_stage)
# ─────────────────────────────────────────────────────────────────
class TestRunStageDormantHookIntegration:
"""u3 — Stage 4→5 hook must be wired into ``orchestrator.run_stage``.
Codex #5 rewind: testing ``_audit_mode()`` in isolation is insufficient.
These focused static-source assertions on ``inspect.getsource(run_stage)``
fail if the dormant hook is silently removed or its audit-bypass predicate
is weakened — guarding the IMP-16/17/18/19/20 informational alert wiring.
"""
def _run_stage_src(self, orch) -> str:
import inspect
return inspect.getsource(orch.run_stage)
def test_run_stage_invokes_check_dormant_triggers_on_test_verify_non_audit(self, orch):
"""u3 contract: run_stage body calls _check_dormant_triggers().
Exercises the Stage 4 (test-verify) non-audit invocation path for
IMP-16/18 active watches. If the call disappears from run_stage,
this test fails (catches the regression that Codex #5 flagged).
"""
src = self._run_stage_src(orch)
assert "_check_dormant_triggers()" in src, (
"run_stage must invoke _check_dormant_triggers() — the L3 wiring "
"for IMP-16/17/18/19/20 dormant alerts. Silent removal breaks the "
"Stage 4→5 informational hook."
)
def test_run_stage_dormant_hook_gated_by_test_verify_and_non_audit(self, orch):
"""u3 contract: dormant hook is gated by both sid==test-verify AND not _audit_mode(title).
The predicate is what makes audit-only Stage 4 bypass the IMP-16~20
checker (Stage 1 scope-lock guardrail). Removing either conjunct
would silently re-enable the checker on audit-only issues whose
change surface is restricted to audit-report docs.
"""
import re
src = self._run_stage_src(orch)
m = re.search(
r'sid\s*==\s*"test-verify"\s+and\s+not\s+_audit_mode\(title\)\s*:\s*\n'
r'\s+alerts\s*=\s*_check_dormant_triggers\(\)',
src,
)
assert m is not None, (
"Stage 4→5 dormant hook must be gated by "
'`sid == "test-verify" and not _audit_mode(title):` immediately '
"before `alerts = _check_dormant_triggers()`. Audit-only bypass "
"and Stage 4 placement are part of the IMP-16~20 contract."
)
def test_run_stage_dormant_hook_is_informational_no_continue(self, orch):
"""u3 contract: dormant hook block must NOT contain a `continue` statement.
IMP-16~20 alerts are informational only (Stage 1 guardrail). The hook
block between the _check_dormant_triggers() call and the next
Stage 4 PASS log line ("YES (evidence verified)") must not short-
circuit Stage 5 entry via `continue`. Comments mentioning the word
"continue" are allowed (they document the contract).
"""
import re
src = self._run_stage_src(orch)
start = src.find("_check_dormant_triggers()")
assert start >= 0
end = src.find("YES (evidence verified)", start)
assert end > start, (
"could not locate Stage 4 PASS log line after dormant hook — "
"run_stage shape may have shifted; re-examine integration."
)
hook_block = src[start:end]
# Strip line comments before checking for the `continue` keyword as
# an actual statement — the source intentionally documents the
# informational-only contract with a `# Never continue — ...` comment.
stripped_lines = []
for line in hook_block.splitlines():
code = line.split("#", 1)[0]
stripped_lines.append(code)
code_only = "\n".join(stripped_lines)
assert not re.search(r'(^|\s)continue\b', code_only), (
"dormant hook block contains a `continue` statement — IMP-16~20 "
"alerts must never block Stage 5 entry (informational-only contract)."
)
def test_run_stage_dormant_hook_positioned_before_stage_pass_return(self, orch):
"""u3 contract: dormant hook is positioned within the Stage 4 YES PASS path.
Verifies the hook sits between the P4a audit commit-scope guard and
the Stage 4 success log+return (i.e., on the PASS path), not in an
unreachable branch. Protects against accidental relocation during
future refactors.
"""
src = self._run_stage_src(orch)
hook_pos = src.find("_check_dormant_triggers()")
pass_log_pos = src.find("YES (evidence verified)", hook_pos)
return_true_pos = src.find("return True", hook_pos)
assert hook_pos >= 0
assert 0 < pass_log_pos - hook_pos < 4000, (
"dormant hook is not adjacent to the Stage 4 PASS log line — "
"run_stage shape may have shifted away from PASS-path placement."
)
assert return_true_pos > pass_log_pos, (
"Stage 4 `return True` should follow the PASS log line; "
"dormant hook must precede both."
)
# ─────────────────────────────────────────────────────────────────
# u5 — registry self-documentation + governance cross-reference
# ─────────────────────────────────────────────────────────────────
class TestRegistryHeaderAndGovernanceRef:
def test_registry_header_explains_schema_and_l3_purpose(self):
"""u5 acceptance: yaml header explains schema + L3 informational-only purpose.
The header is the durable self-documentation surface that anchors the
IMP-16/17/18/19/20 registry (per Stage 1 exit unit u5).
"""
text = REGISTRY_PATH.read_text(encoding="utf-8")
assert "Schema" in text or "schema" in text
assert "dormant" in text.lower()
assert "L3" in text or "machine-readable" in text.lower()
assert "Guardrails" in text or "informational" in text.lower()
@pytest.mark.skipif(
not GOVERNANCE_PATH.exists()
or "DORMANT-TRIGGERS.yaml" not in GOVERNANCE_PATH.read_text(encoding="utf-8"),
reason="u5 governance-doc reference line not yet appended (passes after u5 lands).",
)
def test_governance_doc_references_registry(self):
"""u5 deliverable: PROJECT-INTENT-AND-GOVERNANCE.md cites DORMANT-TRIGGERS.yaml as L3.
Runs after u5 lands — IMP-16~20 registry surfaces in the governance
anti-patterns row so future maintainers find it without rediscovery.
"""
text = GOVERNANCE_PATH.read_text(encoding="utf-8")
assert "DORMANT-TRIGGERS.yaml" in text