#!/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 sys from dataclasses import dataclass 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" EXPECTED_STATUS_COUNTS = { "done": 25, "planned": 0, } EXPECTED_OWNER_COLUMN_COUNTS = { "block": 2, "decide": 7, "ext": 2, } EXPECTED_DECISION_DETAIL_ROWS = 7 EXPECTED_EXTERNAL_GATE_ROWS = 6 REQUIRED_SHARED_PHRASES = ( "M2 digest worker + memory focused 30 passed", "M2 주변 회귀 87 passed", "one-shot worker/runner/default-off scheduler", "실 provider 장시간 운영", "임상 골든셋", "재압축", "블로커 2 · 결정 7 · 외부조율 2", "decisions 7건(owner 결정)", "결정 7개", "MASTERPLAN_REVISIONS F-10 승인", ) 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개", ) @dataclass(frozen=True) class Check: name: str passed: bool detail: str class DashboardStructureParser(HTMLParser): def __init__(self) -> None: super().__init__(convert_charrefs=True) self.status_counts: dict[str, int] = {} 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'

', dashboard, ) def run_checks(paths: argparse.Namespace) -> dict[str, Any]: dashboard = _read(paths.dashboard) source_gaps = _read(paths.source_gaps) backlog = _read(paths.backlog) testing = _read(paths.testing) local_development = _read(paths.local_development) joined_docs = "\n".join([dashboard, source_gaps, backlog, testing, local_development]) checks: list[Check] = [] structure = _dashboard_structure(dashboard) counts = structure.status_counts for status, expected in EXPECTED_STATUS_COUNTS.items(): actual = counts.get(status, 0) checks.append( Check( name=f"status_count_{status}", passed=actual == expected, detail=f"expected={expected} actual={actual}", ) ) doing_count = counts.get("doing", 0) checks.append( Check( name="no_doing_cards", passed=doing_count == 0 and ">CHECK<" not in dashboard and "s-check" not in dashboard, detail=f"doing={doing_count}", ) ) planned = _planned_cards(dashboard) checks.append( Check( name="planned_cards_are_gate_framed", passed=all(("GATE" in card or "후속" in card or "남은" in card) for card in planned), detail=f"planned_cards={len(planned)}", ) ) for owner_column, expected in EXPECTED_OWNER_COLUMN_COUNTS.items(): actual = structure.owner_column_counts.get(owner_column, 0) checks.append( Check( name=f"owner_column_count_{owner_column}", passed=actual == expected, detail=f"expected={expected} actual={actual}", ) ) expected_owner_total = sum(EXPECTED_OWNER_COLUMN_COUNTS.values()) actual_owner_total = sum(structure.owner_column_counts.values()) checks.append( Check( name="owner_action_total", passed=actual_owner_total == expected_owner_total, detail=f"expected={expected_owner_total} actual={actual_owner_total}", ) ) checks.append( Check( name="external_gate_rows_visible", passed=structure.external_gate_rows == EXPECTED_EXTERNAL_GATE_ROWS, detail=f"expected={EXPECTED_EXTERNAL_GATE_ROWS} actual={structure.external_gate_rows}", ) ) decision_detail_rows = structure.decision_panel_rows checks.append( Check( name="decision_detail_rows", passed=decision_detail_rows == EXPECTED_DECISION_DETAIL_ROWS, detail=f"expected={EXPECTED_DECISION_DETAIL_ROWS} actual={decision_detail_rows}", ) ) for phrase in REQUIRED_SHARED_PHRASES: checks.append( Check( name=f"required_phrase::{phrase}", passed=phrase in joined_docs, detail="present" if phrase in joined_docs else "missing", ) ) for phrase in FORBIDDEN_STALE_PHRASES: checks.append( Check( name=f"forbidden_phrase::{phrase}", passed=phrase not in joined_docs, detail="absent" if phrase not in joined_docs else "present", ) ) return { "schema": "vignette.dev_dashboard_ssot_check.v1", "passed": all(check.passed for check in checks), "status_counts": counts, "checks": [check.__dict__ for check in checks], } def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--dashboard", type=Path, default=DEFAULT_DASHBOARD) parser.add_argument("--source-gaps", type=Path, default=DEFAULT_SOURCE_GAPS) parser.add_argument("--backlog", type=Path, default=DEFAULT_BACKLOG) parser.add_argument("--testing", type=Path, default=DEFAULT_TESTING) parser.add_argument("--local-development", type=Path, default=DEFAULT_LOCAL_DEVELOPMENT) parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON.") return parser def main(argv: list[str] | None = None) -> int: args = build_parser().parse_args(argv) report = run_checks(args) if args.json: print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True)) else: status = "PASS" if report["passed"] else "FAIL" print(f"{status} dev dashboard SSOT check") for check in report["checks"]: marker = "PASS" if check["passed"] else "FAIL" print(f"{marker} {check['name']} {check['detail']}") return 0 if report["passed"] else 1 if __name__ == "__main__": raise SystemExit(main())