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:
135
docs/architecture/DORMANT-TRIGGERS.yaml
Normal file
135
docs/architecture/DORMANT-TRIGGERS.yaml
Normal 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."
|
||||
@@ -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) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
191
scripts/check_dormant_triggers.py
Normal file
191
scripts/check_dormant_triggers.py
Normal 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())
|
||||
492
tests/orchestrator_unit/test_dormant_triggers.py
Normal file
492
tests/orchestrator_unit/test_dormant_triggers.py
Normal 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
|
||||
Reference in New Issue
Block a user