#!/usr/bin/env python3 """Validate that the dev dashboard stays aligned with current SSOT claims.""" from __future__ import annotations import argparse import json import re import shutil import subprocess from dataclasses import dataclass from functools import lru_cache from html.parser import HTMLParser from pathlib import Path from typing import Any REPO_ROOT = Path(__file__).resolve().parents[1] DEFAULT_DASHBOARD = REPO_ROOT / "docs" / "dev_dashboard.html" DEFAULT_SOURCE_GAPS = REPO_ROOT / "docs" / "guides" / "source-docs-and-gaps.md" DEFAULT_BACKLOG = REPO_ROOT / "docs" / "ops" / "backlog-2026-06-26.md" DEFAULT_TESTING = REPO_ROOT / "docs" / "guides" / "testing.md" DEFAULT_LOCAL_DEVELOPMENT = REPO_ROOT / "docs" / "guides" / "local-development.md" DEFAULT_TODO = REPO_ROOT / "docs" / "TODO.md" EXPECTED_STATUS_COUNTS = { "done": 32, "doing": 3, "planned": 0, } EXPECTED_OWNER_COLUMN_COUNTS = { "block": 2, "decide": 0, "ext": 2, } EXPECTED_CRITICAL_CARD_COUNT = 3 EXPECTED_DECISION_DETAIL_ROWS = 0 EXPECTED_EXTERNAL_GATE_ROWS = 6 EXPECTED_OUTCOME_GATES = tuple(f"G{index}" for index in range(9)) EXPECTED_AOS_IDS = tuple(f"AOS-{index:03d}" for index in range(1, 13)) PLAYWRIGHT_ROOT = REPO_ROOT / "apps" / "web" REQUIRED_SHARED_PHRASES = ( "M2 digest worker + memory focused 30 passed", "M2 주변 회귀 87 passed", "one-shot worker/runner/default-off scheduler", "실 provider 장시간 운영", "임상 골든셋", "재압축", "블로커 2 · 결정 0 · 외부조율 2", "owner 결정 7건은 2026-06-30 전건 확정", "결정 0개(전건 확정)", "MASTERPLAN_REVISIONS F-10 승인", "Public API 119 paths · G1~G8", "error code 1033", "Docker Desktop/DB 재기동", "watchdog healthy: engine, api, web-preview, cloudflared, public-api", "dev-dashboard E2E redteam 10 passed", ) FORBIDDEN_STALE_PHRASES = ( "M2 주변 회귀 82 passed", "one-shot digest worker + memory focused 25 passed", "M2 one-shot digest worker + memory focused 25 passed", "focused 26 passed", "운영 background scheduler", "최신 LTS", "최종 로컬 풀스택", "레이아웃 포커스(재설계 화면) | `session-layout`·`session-review`·`admin`·`learner`·`settings`·`teacher`, `@single-run` 제외 | desktop+mobile 병렬 | **58**", "M2는 보수적 identity/agreement pinned_fact 자동 실적재, append-only history, 명시적 상담 약속 철회 contradiction, episodic embedding writer, 다음 턴 EngineMessage 주입 회귀까지 완료했다.", "focused X2/backend 40 passed", "계획·블로커", "자유연습 기본값
수정안",
"decisions 8건(owner 결정)",
"결정 8개",
"블로커 2 · 결정 7 · 외부조율 2",
"decisions 7건(owner 결정)",
"결정 7개",
"블로커 2 · 결정 3 · 외부조율 2",
"decisions 3건(owner 결정)",
"결정 3개",
"Public API prod OK",
"Public API 530 BLOCK",
"API: prod/dev OK",
)
@dataclass(frozen=True)
class Check:
name: str
passed: bool
detail: str
@dataclass(frozen=True)
class PlaywrightInventory:
tests: int
files: int
def _parse_playwright_inventory(output: str) -> PlaywrightInventory:
match = re.search(r"^Total:\s+(\d+) tests in (\d+) files$", output, re.MULTILINE)
if match is None:
raise ValueError("playwright --list summary is missing")
return PlaywrightInventory(tests=int(match.group(1)), files=int(match.group(2)))
@lru_cache(maxsize=1)
def _collect_playwright_inventory() -> PlaywrightInventory:
executable = shutil.which("npx.cmd") or shutil.which("npx")
if executable is None:
raise RuntimeError("npx is unavailable for Playwright inventory")
completed = subprocess.run(
[executable, "playwright", "test", "--list"],
cwd=PLAYWRIGHT_ROOT,
check=False,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=120,
)
if completed.returncode != 0:
detail = (completed.stderr or completed.stdout).strip()
raise RuntimeError(
f"playwright --list failed ({completed.returncode}): {detail[-500:]}"
)
return _parse_playwright_inventory(completed.stdout)
class DashboardStructureParser(HTMLParser):
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self.status_counts: dict[str, int] = {
status: 0 for status in EXPECTED_STATUS_COUNTS
}
self.owner_column_counts: dict[str, int] = {}
self.decision_panel_rows = 0
self.external_gate_rows = 0
self._div_stack: list[str | None] = []
self._owner_column_stack: list[str] = []
self._decision_panel_stack: list[bool] = []
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
attrs_dict = dict(attrs)
if tag == "div":
owner_column = attrs_dict.get("data-owner-col")
in_decision_panel = attrs_dict.get("id") == "panel-decisions" or (
bool(self._decision_panel_stack) and self._decision_panel_stack[-1]
)
self._div_stack.append(owner_column)
self._decision_panel_stack.append(in_decision_panel)
if owner_column is not None:
self._owner_column_stack.append(owner_column)
if tag == "span":
classes = attrs_dict.get("class", "")
if "task-status" in classes and "s-plan" in classes:
self.external_gate_rows += 1
if (
self._decision_panel_stack
and self._decision_panel_stack[-1]
and "task-status" in classes
and "s-risk" in classes
):
self.decision_panel_rows += 1
if tag != "article":
return
status = attrs_dict.get("data-status")
if status:
self.status_counts[status] = self.status_counts.get(status, 0) + 1
if attrs_dict.get("data-owner") != "1" or not self._owner_column_stack:
return
owner_column = self._owner_column_stack[-1]
self.owner_column_counts[owner_column] = self.owner_column_counts.get(owner_column, 0) + 1
def handle_endtag(self, tag: str) -> None:
if tag != "div" or not self._div_stack:
return
owner_column = self._div_stack.pop()
if self._decision_panel_stack:
self._decision_panel_stack.pop()
if owner_column is not None and self._owner_column_stack:
self._owner_column_stack.pop()
def _read(path: Path) -> str:
return path.read_text(encoding="utf-8")
def _dashboard_structure(dashboard: str) -> DashboardStructureParser:
parser = DashboardStructureParser()
parser.feed(dashboard)
parser.close()
return parser
def _planned_cards(dashboard: str) -> list[str]:
return re.findall(
r'