#!/usr/bin/env python3 # Copyright (c) 2025-2026, RTE (https://www.rte-france.com) # SPDX-License-Identifier: MPL-2.0 """Layer-1 static parity check: frontend/ vs standalone_interface.html. Extracts canonical inventories from the React source (the source of truth) and diffs them against the standalone HTML mirror. Emits a CI-friendly report with file:line anchors on every failure. Three inventories checked today: 1. ``InteractionType`` union (``frontend/src/types.ts``) — every event type the React app claims the standalone must support. 2. Backend API paths (``frontend/src/api.ts``) — every endpoint wired into the React HTTP client must also be wired into the standalone. 3. ``SettingsState`` interface (``frontend/src/hooks/useSettings.ts``) — every field the Settings modal exposes must be mirrored. For each ``interactionLogger.record('TYPE', { key1, key2, ... })`` call site, we also collect the union of detail keys emitted per event type in BOTH codebases and diff them — catches the ``voltage_range_changed { min_kv, max_kv }`` vs. spec ``{ min, max }`` class of regression. Run:: python scripts/check_standalone_parity.py # human output python scripts/check_standalone_parity.py --json # CI-friendly Exits non-zero on any FAIL-level finding so the script can gate CI. """ from __future__ import annotations import argparse import json import os import re import sys from collections import defaultdict from pathlib import Path from typing import Iterable REPO_ROOT = Path(__file__).resolve().parent.parent FRONTEND_SRC = REPO_ROOT / "frontend" / "src" # Standalone target: the hand-maintained mirror was decommissioned # 2026-04-20 (renamed to `standalone_interface_legacy.html` and # untracked via `.gitignore`). The canonical target is now the # auto-generated single-file bundle. Callers that want to audit a # different artifact can set `COSTUDY4GRID_STANDALONE_PATH` to any # path — the legacy file is still readable on disk if present. # # Resolution order when `COSTUDY4GRID_STANDALONE_PATH` is unset: # 1. `frontend/dist-standalone/standalone.html` — fresh local build # (gitignored; only present after `npm run build:standalone`). # 2. `frontend/dist-standalone/standalone_v*.html` — the latest # committed versioned snapshot (lexicographically greatest). # CI checks out this file since step 1's output is gitignored. # 3. `standalone_interface_legacy.html` — frozen last version of # the hand-maintained mirror, kept as a last-resort reference. _DIST_STANDALONE_DIR = REPO_ROOT / "frontend" / "dist-standalone" _DEFAULT_STANDALONE = _DIST_STANDALONE_DIR / "standalone.html" _LEGACY_STANDALONE = REPO_ROOT / "standalone_interface_legacy.html" def _resolve_standalone_path() -> Path: override = os.environ.get("COSTUDY4GRID_STANDALONE_PATH") if override: return Path(override) if _DEFAULT_STANDALONE.exists(): return _DEFAULT_STANDALONE if _DIST_STANDALONE_DIR.is_dir(): versioned = sorted(_DIST_STANDALONE_DIR.glob("standalone_v*.html")) if versioned: return versioned[-1] return _LEGACY_STANDALONE STANDALONE = _resolve_standalone_path() TYPES_TS = FRONTEND_SRC / "types.ts" API_TS = FRONTEND_SRC / "api.ts" USE_SETTINGS_TS = FRONTEND_SRC / "hooks" / "useSettings.ts" # --------------------------------------------------------------------- # Spec table — the replay contract from docs/features/interaction-logging.md # --------------------------------------------------------------------- # # Encoded per event type as `(required_keys, optional_keys)`. Both are # `frozenset` so the dict stays cheap to hash. Optional keys are # fields the spec marks "only populated when ..." (e.g. # `pending_branch` for `contingency_confirmed`) — they MAY be present # without being flagged as an extra. # # Source of truth: docs/features/interaction-logging.md § "Replay Contract: # Required Details Per Event Type". When the spec changes, update # this table in the same PR — the three-way-diff check (`--spec`) # compares FE and SA emissions against this. # # Events whose frontend details are passed via a bare identifier # (e.g. ``record('config_loaded', buildConfigInteractionDetails())``) # have SA compared to spec but FE can only be compared by a separate # static-analysis pass that resolves the identifier's return shape; # we don't attempt that here and mark FE as "deferred". _CONFIG_FIELDS = frozenset({ "network_path", "action_file_path", "layout_path", "output_folder_path", "min_line_reconnections", "min_close_coupling", "min_open_coupling", "min_line_disconnections", "min_pst", "min_load_shedding", "min_renewable_curtailment_actions", "min_redispatch", "allowed_action_types", "n_prioritized_actions", "lines_monitoring_path", "monitoring_factor", "pre_existing_overload_threshold", "ignore_reconnections", "pypowsybl_fast_mode", }) def _spec_row(required: set[str], optional: set[str] = frozenset()) -> dict: return {"required": frozenset(required), "optional": frozenset(optional)} SPEC_DETAILS: dict[str, dict] = { # --- Configuration & Study Loading --- "config_loaded": _spec_row(_CONFIG_FIELDS), "settings_opened": _spec_row({"tab"}), "settings_tab_changed": _spec_row({"from_tab", "to_tab"}), "settings_applied": _spec_row(_CONFIG_FIELDS), "settings_cancelled": _spec_row(set()), "path_picked": _spec_row({"type", "path"}), # --- Contingency (single + N-K multi-element) --- "contingency_selected": _spec_row({"element"}), "contingency_confirmed": _spec_row({"type"}, optional={"pending_branch"}), "contingency_element_added": _spec_row({"element"}), "contingency_element_removed": _spec_row({"element"}), "contingency_applied": _spec_row({"elements"}), # --- Two-Step Analysis --- "analysis_step1_started": _spec_row({"element"}), "analysis_step1_completed": _spec_row({ "element", "overloads_found", "n_overloads", "can_proceed", "dc_fallback", "message", }), "overload_toggled": _spec_row({"overload", "selected"}), "analysis_step2_started": _spec_row({ "element", "selected_overloads", "all_overloads", "monitor_deselected", # PR #134 — operator-selectable extra "lines to cut" forwarded # to the discovery engine as ``extra_lines_to_cut_ids``. "additional_lines_to_cut", }), "additional_line_to_cut_toggled": _spec_row({"line", "selected"}), "recommender_model_changed": _spec_row({"model"}, optional={"source"}), "suggested_actions_cleared": _spec_row({"n_cleared"}), "analysis_step2_completed": _spec_row({ "n_actions", "action_ids", "dc_fallback", "message", "pdf_url", }), "prioritized_actions_displayed": _spec_row({"n_actions"}), # --- Action --- "action_selected": _spec_row({"action_id"}), "action_deselected": _spec_row({"previous_action_id"}), "action_favorited": _spec_row({"action_id"}), "action_unfavorited": _spec_row({"action_id"}), "action_rejected": _spec_row({"action_id"}), "action_unrejected": _spec_row({"action_id"}), "manual_action_simulated": _spec_row({"action_id"}), "action_mw_resimulated": _spec_row({"action_id", "target_mw"}), "pst_tap_resimulated": _spec_row({"action_id", "target_tap"}), # --- Combined Actions --- "combine_modal_opened": _spec_row(set()), "combine_modal_closed": _spec_row(set()), "combine_pair_toggled": _spec_row({"action_id", "selected"}), "combine_pair_estimated": _spec_row({ "action1_id", "action2_id", "estimated_max_rho", "estimated_max_rho_line", }), "combine_pair_simulated": _spec_row({ "combined_id", "action1_id", "action2_id", "simulated_max_rho", }), # --- Visualization --- "diagram_tab_changed": _spec_row({"tab"}), "tab_detached": _spec_row({"tab"}), "tab_reattached": _spec_row({"tab"}), "tab_tied": _spec_row({"tab"}), "tab_untied": _spec_row({"tab"}), "view_mode_changed": _spec_row({"mode", "tab", "scope"}), # Overflow Analysis tab layout toggle ("Hierarchical" / "Geo"). # Start event carries the target mode; completion event adds # `cached` (boolean) or `error` (string). "overflow_layout_mode_toggled": _spec_row({"to"}), # Co-Study4Grid pin overlay on the overflow graph. "overflow_pins_toggled": _spec_row({"enabled"}), "overflow_pin_clicked": _spec_row({"actionId"}), "overflow_pin_double_clicked": _spec_row({"actionId", "substation"}), "overflow_layer_toggled": _spec_row({"key", "label", "visible"}), "overflow_select_all_layers": _spec_row({"visible"}), "overflow_node_double_clicked": _spec_row({"name"}), "voltage_range_changed": _spec_row({"min", "max"}), "asset_clicked": _spec_row({"action_id", "asset_name", "tab"}), "zoom_in": _spec_row({"tab"}), "zoom_out": _spec_row({"tab"}), "zoom_reset": _spec_row({"tab"}), "inspect_query_changed": _spec_row({"query"}, optional={"target_tab"}), "vl_names_toggled": _spec_row({"show"}), # --- Action Overview Diagram --- "overview_shown": _spec_row({"has_pins", "pin_count"}), "overview_hidden": _spec_row(set()), "overview_pin_clicked": _spec_row({"action_id"}), "overview_pin_double_clicked": _spec_row({"action_id"}), "overview_popover_closed": _spec_row({"reason"}), "overview_zoom_in": _spec_row(set()), "overview_zoom_out": _spec_row(set()), "overview_zoom_fit": _spec_row(set()), "overview_inspect_changed": _spec_row({"query", "action"}), "overview_filter_changed": _spec_row({"kind"}, optional={"category", "enabled", "threshold", "action_type"}), "overview_unsimulated_toggled": _spec_row({"enabled"}), "overview_unsimulated_pin_simulated": _spec_row({"action_id"}), # --- SLD Overlay --- "sld_overlay_opened": _spec_row({"vl_name", "action_id"}), "sld_overlay_tab_changed": _spec_row({"tab", "vl_name"}), "sld_overlay_closed": _spec_row(set()), "sld_edit_mode_toggled": _spec_row({"enabled"}), "sld_switch_toggled": _spec_row({"equipment_id"}), "sld_maneuver_removed": _spec_row({"equipment_ids"}), "sld_maneuver_focused": _spec_row({"equipment_id"}), "sld_edit_reset": _spec_row(set()), "sld_injection_staged": _spec_row({"equipment_id", "kind", "target_mw"}), "sld_injection_removed": _spec_row({"equipment_id"}), "sld_topology_simulated": _spec_row({"voltage_level_id", "switches"}, {"combined_with", "injections"}), # --- Session Management --- "session_saved": _spec_row({"output_folder"}), "session_reload_modal_opened": _spec_row(set()), "session_reloaded": _spec_row({"session_name"}), # --- Sidebar layout / contingency clear --- "sidebar_collapsed_toggled": _spec_row({"collapsed"}), "contingency_clear_requested": _spec_row({"had_analysis_state"}), # --- Theme --- "theme_toggled": _spec_row({"theme"}), # --- Smooth pan/zoom (GPU) preference --- "smooth_pan_zoom_toggled": _spec_row({"enabled", "mode"}), } # Event types whose details argument is a bare identifier or a # function call — the regex cannot see inside them. The auto-generated # standalone bundle now shares the React source's call pattern (both # emit `buildConfigInteractionDetails()`), so deferred treatment must # apply symmetrically: a bare-call details argument is a false-positive # mismatch on either side. _DEFERRED_TYPES = frozenset({"config_loaded", "settings_applied"}) # --------------------------------------------------------------------- # Extractors (React source → canonical sets) # --------------------------------------------------------------------- def extract_interaction_types(ts_path: Path) -> set[str]: """Parse the ``InteractionType`` union from types.ts. We only need the set of string literals between ``export type InteractionType =`` and the terminating ``;``. Comments, whitespace, and ``|`` separators are tolerated. """ src = ts_path.read_text(encoding="utf-8") m = re.search(r"export type InteractionType\s*=\s*(.*?);", src, re.DOTALL) if not m: raise RuntimeError(f"InteractionType union not found in {ts_path}") body = m.group(1) # Drop line comments and block comments. body = re.sub(r"//[^\n]*", "", body) body = re.sub(r"/\*.*?\*/", "", body, flags=re.DOTALL) return set(re.findall(r"'([a-z0-9_]+)'", body)) def extract_api_paths(api_path: Path) -> set[str]: """Pull every ``/api/...`` path out of the axios client. Matches the template-literal form the repo uses throughout, e.g. ``${API_BASE_URL}/api/run-analysis-step1``. """ src = api_path.read_text(encoding="utf-8") paths = set(re.findall(r"/api/[A-Za-z0-9_\-]+", src)) return paths def extract_settings_state_fields(hook_path: Path) -> set[str]: """Parse field names from the exported ``SettingsState`` interface. We only keep fields that have a paired ``set`` setter in the same interface — this is the useSettings convention and it filters out inline function-type signatures (e.g. ``buildConfigRequest: () => { network_path: string; ... }``) whose return-type body would otherwise leak snake-case identifiers into the extracted set. """ src = hook_path.read_text(encoding="utf-8") m = re.search(r"export interface SettingsState\s*\{(.*?)^\}", src, re.DOTALL | re.MULTILINE) if not m: raise RuntimeError(f"SettingsState interface not found in {hook_path}") body = m.group(1) body = re.sub(r"//[^\n]*", "", body) body = re.sub(r"/\*.*?\*/", "", body, flags=re.DOTALL) # Pair-based extraction: a field ``foo: T;`` counts only if # ``setFoo: (v: T) => void;`` also appears in the body. setters = set(re.findall(r"\bset([A-Z][A-Za-z0-9_]*)\s*:\s*\(", body)) setter_targets = {s[0].lower() + s[1:] for s in setters} fields: set[str] = set() for name in re.findall(r"^\s*([a-z][A-Za-z0-9_]*)\s*:\s*", body, re.MULTILINE): if name in setter_targets: fields.add(name) return fields RECORD_CALL = re.compile( r"interactionLogger\.record\(\s*['\"]([a-z0-9_]+)['\"]", ) RECORD_COMPLETION_CALL = re.compile( r"interactionLogger\.recordCompletion\(\s*['\"]([a-z0-9_]+)['\"]", ) def _extract_second_arg(src: str, after: int) -> tuple[str, int] | None: """Starting at `src[after] == ','`, return the source slice of the second argument to ``interactionLogger.record`` and the offset past it. Handles balanced ``{}`` nesting (so JSX and inline expressions like ``Object.keys({}).length`` don't truncate the capture), and handles bare identifiers / method calls (returns them verbatim so the caller can note it's "deferred"). Returns None if the call has no second argument. """ i = after # Expect `,` followed by whitespace, then the arg. if i >= len(src) or src[i] != ",": return None i += 1 while i < len(src) and src[i].isspace(): i += 1 if i >= len(src): return None start = i if src[i] == "{": # Balance braces; strings and comments can harbour unbalanced # braces so we skip them explicitly. depth = 0 while i < len(src): c = src[i] if c == "{": depth += 1 i += 1 elif c == "}": depth -= 1 i += 1 if depth == 0: return (src[start:i], i) elif c in "\"'": # Skip string literal quote = c i += 1 while i < len(src) and src[i] != quote: if src[i] == "\\": i += 2 else: i += 1 i += 1 elif c == "`": # Template literal — just skip to the matching ` i += 1 while i < len(src) and src[i] != "`": if src[i] == "\\": i += 2 else: i += 1 i += 1 elif src[i:i + 2] == "//": while i < len(src) and src[i] != "\n": i += 1 elif src[i:i + 2] == "/*": i += 2 while i < len(src) and src[i:i + 2] != "*/": i += 1 i += 2 else: i += 1 return None # Bare identifier / call expression — read until top-level `)` or # `,` at paren-depth 0. paren_depth = 0 while i < len(src): c = src[i] if c == "(": paren_depth += 1 elif c == ")": if paren_depth == 0: break paren_depth -= 1 elif c == "," and paren_depth == 0: break i += 1 return (src[start:i], i) def _strip_comments(src: str) -> str: """Remove ``//``-line and ``/* */``-block comments so they don't pollute the DETAIL_KEY walk. (The tokenizer above already skips comments while walking braces, but the body passed to DETAIL_KEY is the raw slice — stripping here is the simplest way to keep the key walker honest against call-site comments.) """ src = re.sub(r"//[^\n]*", "", src) src = re.sub(r"/\*.*?\*/", "", src, flags=re.DOTALL) return src # Match only property-start positions inside an object literal so we # capture keys (not value identifiers). Both explicit and JS shorthand # properties are supported: # { name: value, ... } → name # { name, ... } → name (shorthand, followed by `,`) # { name } → name (shorthand, followed by `}`) # The leading ``[{,]`` anchor forces "first token of a property". DETAIL_KEY = re.compile( r"[{,]\s*" r"([a-zA-Z_][a-zA-Z0-9_]*)" r"\s*(?=[:,}]|$)" ) def walk_record_calls( root: Path, patterns: Iterable[str], exclude: Iterable[str] = (), ) -> tuple[dict[str, set[str]], dict[str, list[tuple[str, int]]]]: """Collect ``{event_type: set(detail_keys)}`` + file:line anchors. For each ``interactionLogger.record('type', { a, b: 1, ... })``: - event_type → 'type' - detail_keys → {'a', 'b'} (keys of the object-literal argument when it is an object literal; if the argument is a bare identifier, the keys are skipped — the call still counts toward presence but not toward schema diffing). """ keys_by_type: dict[str, set[str]] = defaultdict(set) sites_by_type: dict[str, list[tuple[str, int]]] = defaultdict(list) excluded = tuple(exclude) files: list[Path] = [] for pattern in patterns: files.extend(root.rglob(pattern)) for path in sorted(set(files)): rel = str(path.relative_to(REPO_ROOT)) if any(ex in rel for ex in excluded): continue src = path.read_text(encoding="utf-8", errors="replace") # Use line-by-line search so we can cheaply attach line numbers. for m in RECORD_CALL.finditer(src): event_type = m.group(1) line_no = src.count("\n", 0, m.start()) + 1 sites_by_type[event_type].append((rel, line_no)) second = _extract_second_arg(src, m.end()) if second is None: continue details_src, _end = second if details_src.startswith("{"): # Strip comments before the key walk — a comment between # ``{`` and the first property would otherwise hide the # first key (the anchor ``[{,]\s*`` can't skip across # arbitrary non-whitespace text). cleaned = _strip_comments(details_src) for km in DETAIL_KEY.finditer(cleaned): keys_by_type[event_type].add(km.group(1)) return keys_by_type, sites_by_type def walk_record_completion_calls( root: Path, patterns: Iterable[str], exclude: Iterable[str] = (), ) -> set[str]: """Return the set of event types that appear in ``recordCompletion(...)`` calls. The start side is already handled by :func:`walk_record_calls`. """ seen: set[str] = set() excluded = tuple(exclude) for pattern in patterns: for path in root.rglob(pattern): rel = str(path.relative_to(REPO_ROOT)) if any(ex in rel for ex in excluded): continue src = path.read_text(encoding="utf-8", errors="replace") for m in RECORD_COMPLETION_CALL.finditer(src): seen.add(m.group(1)) return seen # --------------------------------------------------------------------- # Extractors (standalone HTML → standalone inventories) # --------------------------------------------------------------------- def walk_standalone_record_calls( html_path: Path, ) -> tuple[dict[str, set[str]], dict[str, list[tuple[str, int]]]]: """Same contract as :func:`walk_record_calls` but for the single-file standalone HTML. The standalone inlines React-like JSX + JS, so ``interactionLogger.record(...)`` literals look identical to the frontend ones. We re-use the same extractor. """ keys_by_type: dict[str, set[str]] = defaultdict(set) sites_by_type: dict[str, list[tuple[str, int]]] = defaultdict(list) src = html_path.read_text(encoding="utf-8", errors="replace") for m in RECORD_CALL.finditer(src): event_type = m.group(1) line_no = src.count("\n", 0, m.start()) + 1 sites_by_type[event_type].append((str(html_path.name), line_no)) second = _extract_second_arg(src, m.end()) if second is None: continue details_src, _end = second if details_src.startswith("{"): cleaned = _strip_comments(details_src) for km in DETAIL_KEY.finditer(cleaned): keys_by_type[event_type].add(km.group(1)) return keys_by_type, sites_by_type def extract_standalone_api_paths(html_path: Path) -> set[str]: """Return ``/api/...`` paths referenced in the standalone HTML. The standalone uses both ``fetch`` and ``axios`` / string literals; grepping the literal path is enough. """ src = html_path.read_text(encoding="utf-8", errors="replace") return set(re.findall(r"/api/[A-Za-z0-9_\-]+", src)) def extract_standalone_setting_fields(html_path: Path) -> set[str]: """Best-effort extraction of ``useState`` field identifiers in the standalone that plausibly correspond to Settings state. The standalone does not expose a ``SettingsState`` type, so we sniff for ``const [fieldName, setFieldName] = useState(...)`` and intersect with the canonical SettingsState field names. Fields the frontend exposes but that do not appear here are flagged as potentially missing. """ src = html_path.read_text(encoding="utf-8", errors="replace") names = set(re.findall(r"const\s+\[\s*([A-Za-z][A-Za-z0-9_]*)\s*,\s*set", src)) return names # --------------------------------------------------------------------- # Report rendering # --------------------------------------------------------------------- def fmt_sites(sites: list[tuple[str, int]], limit: int = 3) -> str: head = sites[:limit] rest = len(sites) - len(head) base = ", ".join(f"{p}:{ln}" for p, ln in head) return base + (f" (+{rest} more)" if rest > 0 else "") def run_checks() -> dict: """Execute every check and return a structured dict of findings. Top-level keys:: { "missing_event_types": [...], "orphan_event_types": [...], # in HTML, not in union "missing_completion": [...], # in FE, not in HTML "detail_key_drift": [{...}, ...], # per-event detail diff "missing_api_paths": [...], "missing_settings": [...], } """ canonical_events = extract_interaction_types(TYPES_TS) canonical_api = extract_api_paths(API_TS) canonical_settings = extract_settings_state_fields(USE_SETTINGS_TS) # Exclude all test files so the inventories reflect production # call sites only — `*.test.ts` / `*.test.tsx` emit events with # placeholder shapes (`{ path: '/data' }`) that would pollute the # details-key diff. fe_keys, fe_sites = walk_record_calls( FRONTEND_SRC, ("*.ts", "*.tsx"), exclude=(".test.ts", ".test.tsx"), ) fe_completion = walk_record_completion_calls( FRONTEND_SRC, ("*.ts", "*.tsx"), exclude=(".test.ts", ".test.tsx"), ) sa_keys, sa_sites = walk_standalone_record_calls(STANDALONE) sa_completion = set() # populated below if any recordCompletion calls present sa_src = STANDALONE.read_text(encoding="utf-8", errors="replace") for m in RECORD_COMPLETION_CALL.finditer(sa_src): sa_completion.add(m.group(1)) fe_event_types = set(fe_sites.keys()) sa_event_types = set(sa_sites.keys()) # Union membership — a canonical type the frontend emits but the # standalone never does. missing_event_types = sorted( t for t in canonical_events if t in fe_event_types and t not in sa_event_types ) # Standalone references a type the union has dropped or never had. orphan_event_types = sorted(t for t in sa_event_types if t not in canonical_events) # Async-completion gap — types where frontend calls # `recordCompletion(type)` but standalone never does. missing_completion = sorted(t for t in fe_completion if t not in sa_completion) # Per-event detail-key drift. Only compare events whose frontend # call site provides an object literal — when the React handler # passes a bare identifier (e.g. ``record('config_loaded', # buildConfigInteractionDetails())``) the regex cannot see into # the return value and the extracted key set would be empty, # producing false "drift" signals against a standalone that # correctly emits all documented keys. detail_key_drift: list[dict] = [] for t in sorted(fe_event_types & sa_event_types & canonical_events): fe = fe_keys.get(t, set()) sa = sa_keys.get(t, set()) if not fe: continue # frontend details are in a bare identifier if fe and sa and fe != sa: # A diff that is spec-conformant on both sides (only # optional keys are present on one side but not the other) # is not real drift — skip it rather than flag it as a # parity failure. The spec marks the difference as # intentional, typically when one codebase supports a # feature the other doesn't (e.g. detached-tab overlays). spec = SPEC_DETAILS.get(t) if spec is not None: required = spec["required"] optional = spec["optional"] symmetric_diff = (fe ^ sa) # Benign if every differing key is either optional or # both sides have all required keys. fe_ok = required <= fe sa_ok = required <= sa all_diffs_optional = symmetric_diff <= optional if fe_ok and sa_ok and all_diffs_optional: continue detail_key_drift.append({ "event_type": t, "frontend_keys": sorted(fe), "standalone_keys": sorted(sa), "missing_in_standalone": sorted(fe - sa), "extra_in_standalone": sorted(sa - fe), "frontend_sites": fe_sites[t][:3], "standalone_sites": sa_sites[t][:3], }) # Three-way diff vs the replay-contract spec # (docs/features/interaction-logging.md § Replay Contract). Distinct from the # FE-vs-SA drift: both codebases can agree yet still drift from the # contract, and conversely one side can be correct while the other # drifts. Encoding the spec here lets the report attribute each # finding to the side that needs to move. fe_spec_drift: list[dict] = [] sa_spec_drift: list[dict] = [] missing_spec_rows: list[str] = [] for t in sorted(canonical_events): spec = SPEC_DETAILS.get(t) if spec is None: missing_spec_rows.append(t) continue required = spec["required"] optional = spec["optional"] known = required | optional # Frontend side. if t in fe_event_types and t not in _DEFERRED_TYPES: fe = fe_keys.get(t, set()) # Empty object literal is a valid emission — only flag a # missing-required gap when the event actually carries a # non-empty shape or the spec requires anything. fe_missing = sorted(required - fe) if (fe or required) else [] fe_extras = sorted(fe - known) if fe_missing or fe_extras: fe_spec_drift.append({ "event_type": t, "spec_required": sorted(required), "spec_optional": sorted(optional), "frontend_keys": sorted(fe), "missing_required": fe_missing, "unknown_extras": fe_extras, "frontend_sites": fe_sites[t][:3], }) # Standalone side. if t in sa_event_types and t not in _DEFERRED_TYPES: sa = sa_keys.get(t, set()) sa_missing = sorted(required - sa) if (sa or required) else [] sa_extras = sorted(sa - known) if sa_missing or sa_extras: sa_spec_drift.append({ "event_type": t, "spec_required": sorted(required), "spec_optional": sorted(optional), "standalone_keys": sorted(sa), "missing_required": sa_missing, "unknown_extras": sa_extras, "standalone_sites": sa_sites[t][:3], }) sa_api = extract_standalone_api_paths(STANDALONE) missing_api_paths = sorted( p for p in canonical_api if p not in sa_api # Filter picker / PDF static routes handled outside axios and p not in {"/api/pick-path"} ) sa_settings = extract_standalone_setting_fields(STANDALONE) # The React side uses camelCase (``networkPath``); the standalone # historically mirrors the API payload (``network_path``) and uses # that as the useState key. Compare on a normalised form (lower + # underscores stripped) so we don't flag the convention drift as a # parity failure. def _norm(name: str) -> str: return re.sub(r"_", "", name).lower() sa_norm = {_norm(s) for s in sa_settings} missing_settings = sorted( s for s in canonical_settings if _norm(s) not in sa_norm ) return { "canonical_event_count": len(canonical_events), "frontend_event_count": len(fe_event_types), "standalone_event_count": len(sa_event_types), "missing_event_types": [ {"type": t, "frontend_sites": fe_sites[t][:3]} for t in missing_event_types ], "orphan_event_types": [ {"type": t, "standalone_sites": sa_sites[t][:3]} for t in orphan_event_types ], "missing_completion": missing_completion, "detail_key_drift": detail_key_drift, "fe_spec_drift": fe_spec_drift, "sa_spec_drift": sa_spec_drift, "missing_spec_rows": missing_spec_rows, "missing_api_paths": missing_api_paths, "missing_settings": missing_settings, } def render_human(report: dict) -> str: out: list[str] = [] out.append("STANDALONE PARITY REPORT") out.append("=" * 72) out.append( f"InteractionType union: {report['canonical_event_count']} types. " f"Frontend emits {report['frontend_event_count']}, " f"standalone emits {report['standalone_event_count']}." ) out.append("") fail = False # ---- Missing event types m = report["missing_event_types"] if m: fail = True out.append(f"[FAIL] {len(m)} event types emitted by the frontend but " f"not by the standalone:") for entry in m: out.append(f" - {entry['type']:35s} (fe: {fmt_sites(entry['frontend_sites'])})") out.append("") # ---- Orphan event types o = report["orphan_event_types"] if o: fail = True out.append(f"[FAIL] {len(o)} event types emitted by the standalone but " f"not declared in InteractionType:") for entry in o: out.append(f" - {entry['type']:35s} (sa: {fmt_sites(entry['standalone_sites'])})") out.append("") # ---- recordCompletion gap mc = report["missing_completion"] if mc: fail = True out.append(f"[FAIL] {len(mc)} async wait-point *_completed events emitted " f"by the frontend but never in the standalone:") for t in mc: out.append(f" - {t}") out.append("") # ---- Detail-key drift d = report["detail_key_drift"] if d: fail = True out.append(f"[FAIL] {len(d)} events whose `details` object-literal keys " f"diverge between frontend and standalone:") for entry in d: out.append(f" - {entry['event_type']}") out.append(f" frontend: {{{', '.join(entry['frontend_keys'])}}}") out.append(f" standalone: {{{', '.join(entry['standalone_keys'])}}}") if entry["missing_in_standalone"]: out.append(f" missing: {entry['missing_in_standalone']}") if entry["extra_in_standalone"]: out.append(f" extra: {entry['extra_in_standalone']}") out.append("") # ---- FE-vs-spec drift fs = report["fe_spec_drift"] if fs: fail = True out.append(f"[FAIL] {len(fs)} events where the frontend drifts from the " f"replay-contract spec (docs/features/interaction-logging.md):") for entry in fs: out.append(f" - {entry['event_type']}") out.append(f" spec required: {{{', '.join(entry['spec_required'])}}}") if entry["spec_optional"]: out.append(f" spec optional: {{{', '.join(entry['spec_optional'])}}}") out.append(f" frontend: {{{', '.join(entry['frontend_keys'])}}}") if entry["missing_required"]: out.append(f" missing: {entry['missing_required']}") if entry["unknown_extras"]: out.append(f" unknown extras: {entry['unknown_extras']}") out.append(f" fe sites: {fmt_sites(entry['frontend_sites'])}") out.append("") # ---- SA-vs-spec drift ss = report["sa_spec_drift"] if ss: fail = True out.append(f"[FAIL] {len(ss)} events where the standalone drifts from the " f"replay-contract spec (docs/features/interaction-logging.md):") for entry in ss: out.append(f" - {entry['event_type']}") out.append(f" spec required: {{{', '.join(entry['spec_required'])}}}") if entry["spec_optional"]: out.append(f" spec optional: {{{', '.join(entry['spec_optional'])}}}") out.append(f" standalone: {{{', '.join(entry['standalone_keys'])}}}") if entry["missing_required"]: out.append(f" missing: {entry['missing_required']}") if entry["unknown_extras"]: out.append(f" unknown extras: {entry['unknown_extras']}") out.append(f" sa sites: {fmt_sites(entry['standalone_sites'])}") out.append("") # ---- Spec rows still missing from SPEC_DETAILS mr = report.get("missing_spec_rows") or [] if mr: out.append(f"[WARN] {len(mr)} InteractionType values have no SPEC_DETAILS " f"entry — extend the spec table in this script:") for t in mr: out.append(f" - {t}") out.append("") # ---- API-path gap ma = report["missing_api_paths"] if ma: fail = True out.append(f"[FAIL] {len(ma)} API paths referenced by the frontend but " f"not by the standalone:") for p in ma: out.append(f" - {p}") out.append("") # ---- Settings-field gap ms = report["missing_settings"] if ms: fail = True out.append(f"[WARN] {len(ms)} SettingsState fields whose identifier was " f"not found in the standalone (best-effort grep):") for s in ms: out.append(f" - {s}") out.append("") if not fail: out.append("[OK] standalone_interface.html is in full Layer-1 parity with frontend/.") return "\n".join(out) def render_markdown(report: dict) -> str: """Render the findings as Markdown tables suitable for pasting into ``CLAUDE.md`` (§ "Machine-grounded findings"). This is what the ``--emit-markdown`` flag prints. The intent is that the audit table in the root CLAUDE.md is NEVER hand-edited — it is always regenerated from this function's output, so the doc and the script can never drift. """ out: list[str] = [] out.append( f"_Generated by `scripts/check_standalone_parity.py`._ " f"InteractionType union: **{report['canonical_event_count']}** types. " f"Frontend emits **{report['frontend_event_count']}**, " f"standalone emits **{report['standalone_event_count']}**." ) out.append("") me = report["missing_event_types"] if me: out.append(f"#### Event types emitted by the frontend but NOT by the standalone ({len(me)})") out.append("") out.append("| Event type | React source |") out.append("|---|---|") for entry in me: sites = fmt_sites(entry["frontend_sites"]) out.append(f"| `{entry['type']}` | `{sites}` |") out.append("") oe = report["orphan_event_types"] if oe: out.append(f"#### Event types emitted by the standalone but NOT in `InteractionType` ({len(oe)})") out.append("") out.append("| Event type | Standalone source |") out.append("|---|---|") for entry in oe: sites = fmt_sites(entry["standalone_sites"]) out.append(f"| `{entry['type']}` | `{sites}` |") out.append("") fs = report["fe_spec_drift"] if fs: out.append(f"#### Frontend drifts from the replay-contract spec ({len(fs)})") out.append("") out.append("| Event | Spec required | Frontend emits | Missing | React source |") out.append("|---|---|---|---|---|") for entry in fs: req = ", ".join(entry["spec_required"]) fe = ", ".join(entry["frontend_keys"]) or "(empty)" missing = ", ".join(entry["missing_required"]) or "—" sites = fmt_sites(entry["frontend_sites"]) out.append(f"| `{entry['event_type']}` | `{{{req}}}` | `{{{fe}}}` | `{missing}` | `{sites}` |") out.append("") ss = report["sa_spec_drift"] if ss: out.append(f"#### Standalone drifts from the replay-contract spec ({len(ss)})") out.append("") out.append("| Event | Spec required | Standalone emits | Missing | Standalone source |") out.append("|---|---|---|---|---|") for entry in ss: req = ", ".join(entry["spec_required"]) sa = ", ".join(entry["standalone_keys"]) or "(empty)" missing = ", ".join(entry["missing_required"]) or "—" sites = fmt_sites(entry["standalone_sites"]) out.append(f"| `{entry['event_type']}` | `{{{req}}}` | `{{{sa}}}` | `{missing}` | `{sites}` |") out.append("") d = report["detail_key_drift"] if d: out.append(f"#### FE ↔ SA details-key drift (no spec row) ({len(d)})") out.append("") out.append("| Event | Frontend | Standalone |") out.append("|---|---|---|") for entry in d: fe = ", ".join(entry["frontend_keys"]) or "(empty)" sa = ", ".join(entry["standalone_keys"]) or "(empty)" out.append(f"| `{entry['event_type']}` | `{{{fe}}}` | `{{{sa}}}` |") out.append("") ma = report["missing_api_paths"] if ma: out.append(f"#### API paths referenced by the frontend but not by the standalone ({len(ma)})") out.append("") for p in ma: out.append(f"- `{p}`") out.append("") mc = report["missing_completion"] if mc: out.append(f"#### `recordCompletion(...)` events emitted by the frontend but not the standalone ({len(mc)})") out.append("") for t in mc: out.append(f"- `{t}`") out.append("") if not out: out.append("_No findings — `standalone_interface.html` is in full Layer-1 parity with `frontend/`._") return "\n".join(out) def main() -> int: parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) parser.add_argument("--json", action="store_true", help="emit JSON instead of text") parser.add_argument( "--emit-markdown", action="store_true", help="render a Markdown report suitable for pasting into CLAUDE.md", ) parser.add_argument( "--allow-warn", action="store_true", help="exit 0 even if there are [WARN] findings (FAILs still exit 1)", ) args = parser.parse_args() report = run_checks() if args.json: print(json.dumps(report, indent=2, sort_keys=True)) elif args.emit_markdown: print(render_markdown(report)) else: print(render_human(report)) hard_fail = ( report["missing_event_types"] or report["orphan_event_types"] or report["missing_completion"] or report["detail_key_drift"] or report["fe_spec_drift"] or report["sa_spec_drift"] or report["missing_api_paths"] ) return 1 if hard_fail else 0 if __name__ == "__main__": sys.exit(main())