"""Structural checks for every master notebook in the library. Kernel-free: everything here reads the committed ``.ipynb`` JSON with nbformat — no study venv, no execution. Content and engine pins stay in each master's own ``tests/`` (run in its venv); this layer guards only STRUCTURE: the notebook parses, was executed cleanly top-to-bottom, and (for notebook-first masters) carries the tagged-cell taxonomy the Assessment Pattern requires. Classification is explicit and non-silent: every notebook on disk must be listed in exactly one of NOTEBOOK_FIRST or GRANDFATHERED (a completeness test enforces it), so a new master cannot dodge the suite, and every exemption carries its reason — grandfathered notebooks run the structural tier and skip the notebook-first tier with that reason shown by ``pytest -rs``. """ from __future__ import annotations from pathlib import Path from typing import Any import nbformat REPO = Path(__file__).resolve().parent.parent # Masters built on the notebook-first content model (Assessment Pattern): # full check set, including the tagged-cell taxonomy. NOTEBOOK_FIRST = { "assessments/CX_Discovery_Workshop/notebooks/cx_discovery.ipynb", } # Structural tier only, each with its recorded reason (see CLAUDE.md, # Known liabilities). Redesigning one of these to notebook-first means # moving it up to NOTEBOOK_FIRST — never deleting it from here silently. GRANDFATHERED = { "assessments/CX_AI_Diagnostic/notebooks/diagnostic.ipynb": "generated notebook; pre-dates the notebook-first model (redesign pending)", "studies/202512_TEI_Genesys_CX_Cloud/notebooks/business_case.ipynb": "pre-tag TEI master (redesign pending)", "studies/202602_TEI_Amazon_Connect/notebooks/business_case.ipynb": "pre-tag TEI master (redesign pending)", "studies/202607_CTM_GenesysCX/notebooks/ctm_business_case_corrected.ipynb": "real-client study, frozen (see CLAUDE.md known liabilities)", "studies/202607_CTM_GenesysCX/notebooks/ctm_business_case_no_current_state.ipynb": "real-client study, frozen (see CLAUDE.md known liabilities)", "studies/202607_CTM_GenesysCX/notebooks/ctm_business_case_virtual_agents.ipynb": "real-client study, frozen (see CLAUDE.md known liabilities)", "studies/202607_CTM_GenesysCX/notebooks/ctm_migration_wfm.ipynb": "real-client study, frozen (see CLAUDE.md known liabilities)", "studies/202607_CTM_GenesysCX/notebooks/ctm_token_calculator.ipynb": "real-client study, frozen (see CLAUDE.md known liabilities)", "template/MercuryNotebook/notebooks/business_case.ipynb": "template encodes the py-engine model (rework pending)", } ALL_CLASSIFIED = sorted(NOTEBOOK_FIRST | set(GRANDFATHERED)) # Tags that may appear at most once per notebook. UNIQUE_TAGS = ("topic-bank", "engagement-data", "gate", "data-appendix") def discover() -> list[str]: """Every notebook on disk under the master roots (repo-relative).""" found: list[str] = [] for base in ("studies", "assessments", "template"): root = REPO / base if not root.is_dir(): continue for p in root.rglob("*.ipynb"): if ".ipynb_checkpoints" in p.parts or ".venv" in p.parts: continue # Mercury app shadow copies are runtime artifacts, not masters # (layout varies by mercury version: .mercury_sessions/ or a # __mercury__ suffix beside the notebook). if ".mercury_sessions" in p.parts or "__mercury__" in p.name: continue if p.parent.name != "notebooks": continue found.append(p.relative_to(REPO).as_posix()) return sorted(found) def load(rel: str) -> Any: return nbformat.read(REPO / rel, as_version=4) def cell_tags(cell: Any) -> list[str]: return list(cell.metadata.get("tags", [])) def cells_tagged(nb: Any, tag: str) -> list[int]: return [i for i, c in enumerate(nb.cells) if tag in cell_tags(c)] def execution_problem(nb: Any) -> str | None: """None if executed cleanly top-to-bottom, else what's wrong. Non-empty code cells must carry integer execution counts, strictly increasing 1..N in document order (proof of one clean linear run); empty cells may be unexecuted (``None``). """ prev = 0 for i, c in enumerate(nb.cells): if c.cell_type != "code" or not c.source.strip(): continue ec = c.get("execution_count") if not isinstance(ec, int): return f"cell {i} has no execution count (notebook not executed?)" if ec != prev + 1: return f"cell {i} has execution count {ec}, expected {prev + 1}" prev = ec return None def error_outputs(nb: Any) -> list[int]: return [ i for i, c in enumerate(nb.cells) if c.cell_type == "code" and any(o.get("output_type") == "error" for o in c.get("outputs", [])) ]