Co-Study4Grid / scripts /check_invariants.py
github-actions[bot]
Deploy 7688ef1
13d4e44
Raw
History Blame Contribute Delete
25.7 kB
#!/usr/bin/env python3
# Copyright (c) 2025-2026, RTE (https://www.rte-france.com)
# SPDX-License-Identifier: MPL-2.0
"""Layer-4 (static) — user-observable invariants.
Layers 1–3a / 3b all passed green while shipping six real bugs to
the user. Each bug belongs to a class the earlier layers can't
catch by construction:
- visual threshold values (palette, dim opacity)
- conditional rendering gates (when does X render?)
- field-semantic interpretation (max_rho_line vs topology target)
- auto-effects ordering (tab auto-switch, auto-zoom, deselect-stay)
- loading-state hygiene (spinner released when?)
- rendering performance (memoization guards)
Full runtime coverage requires a browser (Layer 3b Playwright). This
script takes the cheapest subset: STATIC invariants expressible as
source-level patterns. For each invariant we encode:
- a unique name,
- the React source expected to satisfy it + a regex proving it does,
- the standalone_interface.html source expected to satisfy it + a
matching regex,
- a human description of WHY the invariant matters and what user-
facing bug it regressions prevented.
A failing check means one side drifted from the invariant; the
output lists both file paths and the regex that missed.
Run::
python scripts/check_invariants.py # human text
python scripts/check_invariants.py --json # CI-friendly
Exits non-zero on any FAIL finding.
Scope: this check catches what CAN be proven statically. Pin
severity rendering ("red iff rho > MF"), loading-state release
timing, and auto-effect ordering all need browser execution to
assert RUNTIME behaviour — those live in the Vitest spec-
conformance + specific regression tests, and in the Playwright
Layer 3b spec. Layer 4 is the first gate.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
from typing import Iterable
import os
REPO_ROOT = Path(__file__).resolve().parent.parent
FRONTEND_SRC = REPO_ROOT / "frontend" / "src"
_DEFAULT_STANDALONE = REPO_ROOT / "frontend" / "dist-standalone" / "standalone.html"
_LEGACY_STANDALONE = REPO_ROOT / "standalone_interface_legacy.html"
STANDALONE = Path(
os.environ.get(
"COSTUDY4GRID_STANDALONE_PATH",
str(_DEFAULT_STANDALONE if _DEFAULT_STANDALONE.exists() else _LEGACY_STANDALONE),
)
)
class Invariant:
"""A single source-level assertion with a React side and a
standalone side. Each side has ``file_hint`` (file or file glob),
``pattern`` (regex to satisfy) and optional ``must_not`` (regex
whose presence is a failure, e.g. for "no hardcoded 0.9 threshold").
"""
def __init__(
self,
name: str,
description: str,
react: dict,
standalone: dict,
severity: str = "FAIL",
):
self.name = name
self.description = description
self.react = react
self.standalone = standalone
self.severity = severity # FAIL or WARN
def _search_in_paths(
paths: Iterable[Path],
pattern: str,
flags: int = 0,
) -> tuple[Path | None, re.Match | None]:
rx = re.compile(pattern, flags)
for p in paths:
try:
src = p.read_text(encoding="utf-8", errors="replace")
except Exception:
continue
m = rx.search(src)
if m:
return p, m
return None, None
def _paths_for_hint(hint: str) -> list[Path]:
# Standalone-side invariants: the existing regex patterns were
# written against the hand-maintained inline JSX in the legacy
# file. The auto-generated single-file bundle contains the
# compiled / bundled equivalent, but with minified variable
# names and `reactExports.*` namespacing that breaks those
# patterns. We intentionally target the legacy file here when
# it is still present on disk — the React-source invariants
# (which use `FRONTEND_SRC` hints) remain the authoritative
# assertion; the standalone side is a best-effort mirror check.
# When the legacy file is ultimately removed the standalone
# rows simply return "no path found" and the invariant reports
# as skipped rather than failing the whole script.
if hint in ("standalone_interface.html", "standalone_interface_legacy.html"):
candidates = [_LEGACY_STANDALONE, STANDALONE]
for cand in candidates:
if cand.exists():
return [cand]
return []
if "*" in hint:
return sorted((REPO_ROOT / Path(hint)).parent.glob(Path(hint).name))
p = REPO_ROOT / hint
if p.is_dir():
return sorted(p.rglob("*.ts")) + sorted(p.rglob("*.tsx"))
return [p] if p.exists() else []
INVARIANTS: list[Invariant] = [
Invariant(
name="pin_severity_uses_monitoringFactor",
description=(
"Pin severity palette must be threshold-parameterised by "
"monitoringFactor (red > MF, orange > MF-0.05, else green). "
"Hardcoding 0.9 / 1.0 silently misclassifies 'still "
"overloaded' cards as orange when MF != 0.95 (user bug, "
"commit 56643a8)."
),
react={
"file_hint": "frontend/src/utils/svg/actionPinData.ts",
# React's computeActionSeverity must reference monitoringFactor + the -0.05 band.
"pattern": r"computeActionSeverity[\s\S]*?monitoringFactor\s*-\s*0\.05",
},
standalone={
"file_hint": "standalone_interface.html",
"pattern": r"computeActionSeverity\s*=\s*\([^)]*monitoringFactor[^)]*\)[\s\S]*?monitoringFactor\s*-\s*0\.05",
# Red-flag: a hardcoded 1.0 or 0.9 rho comparison INSIDE
# the function body. `[\s\S]{0,600}?` keeps the scan
# bounded so we don't cross into unrelated helpers; the
# legitimate body is ~400 chars.
"must_not": r"computeActionSeverity\s*=\s*\([^)]*\)\s*=>\s*\{[\s\S]{0,600}?rho\s*[<>]=?\s*(?:0\.9\b|1\.0\b)",
},
),
Invariant(
name="combined_pairs_filter_estimated",
description=(
"Dashed-line combined-pair connections must render only "
"for SIMULATED pairs — not for recommender estimation-only "
"entries marked `is_estimated: true`. Without this "
"filter, the Overview is pre-polluted with curves before "
"the user has opened the Combine modal (commit 56643a8)."
),
react={
# The React side relies on STORAGE separation — estimated
# pairs live in `combined_actions`, simulated ones move to
# `actions` after simulation. `buildCombinedActionPins`
# iterates only `actions`. Check that storage split.
"file_hint": "frontend/src/utils/svg/actionPinData.ts",
"pattern": r"buildCombinedActionPins[\s\S]*?Object\.entries\(actions\)",
},
standalone={
# Standalone flattens both into `result.actions` so it
# needs an explicit `is_estimated` filter.
"file_hint": "standalone_interface.html",
"pattern": r"computeCombinedActionPins[\s\S]*?action\.is_estimated\s*===\s*true",
},
),
Invariant(
name="pin_resolver_is_topology_first",
description=(
"Pin anchor resolver must consult the action's TOPOLOGY "
"target (lines via getActionTargetLines → VLs via "
"getActionTargetVoltageLevels) BEFORE falling back to "
"max_rho_line — per docs/features/action-overview-diagram.md § "
"'Pin anchor resolution'. Reversing the order silently "
"drops simulated action cards whose post-action max_rho_line "
"happens not to be in the N-1 metadata (commit 5030b6c)."
),
react={
"file_hint": "frontend/src/utils/svg/actionPinData.ts",
# In resolveActionAnchor: getActionTargetLines appears
# before max_rho_line.
"pattern": r"resolveActionAnchor[\s\S]*?getActionTargetLines[\s\S]*?max_rho_line",
},
standalone={
"file_hint": "standalone_interface.html",
"pattern": r"resolveActionAnchor[\s\S]*?getActionTargetLines[\s\S]*?max_rho_line",
},
),
Invariant(
name="display_prioritized_switches_to_action_tab",
description=(
"`handleDisplayPrioritizedActions` must call setActiveTab('action') "
"so the operator lands on the Overview pin view the "
"moment the suggestions are merged. Without this, the "
"button silently appends cards to the sidebar with no "
"visual cue and the user doesn't see the map (commit 3c7863b)."
),
react={
"file_hint": "frontend/src/hooks/useAnalysis.ts",
"pattern": r"handleDisplayPrioritizedActions[\s\S]*?setActiveTab\s*\?\.\s*\(\s*['\"]action['\"]\s*\)",
},
standalone={
"file_hint": "standalone_interface.html",
"pattern": r"handleDisplayPrioritizedActions[\s\S]*?setActiveTab\s*\(\s*['\"]action['\"]\s*\)",
},
),
Invariant(
name="deselect_stays_on_action_tab",
description=(
"`handleActionSelect` deselect path (actionId === selectedActionId "
"and !force) must NOT call setActiveTab('n-1') — the user "
"should fall through to the Overview on the same tab "
"(PR #93 / commit dbc05f8). Otherwise unselecting a "
"pin snaps the view to N-1 and loses the pin map."
),
react={
"file_hint": "frontend/src/hooks/useDiagrams.ts",
# React's handleActionSelect: the deselect branch doesn't setActiveTab.
# We assert the deselect branch exists AND doesn't reference setActiveTab.
"pattern": r"action_deselected", # sanity — handler exists
"must_not": r"action_deselected[\s\S]{0,400}?setActiveTab\s*\(\s*['\"]n-1['\"]\s*\)",
},
standalone={
"file_hint": "standalone_interface.html",
"pattern": r"action_deselected",
"must_not": r"action_deselected[\s\S]{0,400}?setActiveTab\s*\(\s*['\"]n-1['\"]\s*\)",
},
),
Invariant(
name="simulate_button_releases_before_diagram",
description=(
"Manual-action + combined-action simulation handlers must "
"reset `simulatingActionId` BEFORE awaiting the action-"
"variant-diagram fetch — otherwise the Simulate button "
"stays in spinner state for the whole 5-6 s diagram "
"round-trip even though the action CARD has already "
"landed (commit dbc05f8). We check for the reset call "
"appearing OUTSIDE a finally clause in handleAddManualAction."
),
react={
# React uses the `simulate-and-variant-diagram` stream
# endpoint + separate loading flags so the button is tied
# to the simulate call, not the diagram. We just sanity-
# check the stream endpoint is wired.
"file_hint": "frontend/src/api.ts",
"pattern": r"simulate-and-variant-diagram",
},
standalone={
"file_hint": "standalone_interface.html",
# setSimulatingActionId(null) must appear before the
# action-variant-diagram fetch inside handleAddManualAction.
"pattern": r"handleAddManualAction[\s\S]*?setSimulatingActionId\(null\)[\s\S]*?/api/action-variant-diagram",
},
),
Invariant(
name="overview_svg_clone_is_memoized",
description=(
"The Action Overview must memoise the 25 MB N-1 SVG clone "
"— re-cloning on every re-render costs ~200-500 ms on the "
"PyPSA-EUR France grid and makes pan/zoom feel frozen "
"(commit 967766a). A ref-scoped cache keyed on the "
"source <svg> identity is the cheapest form of the "
"React `MemoizedSvgContainer` optimisation."
),
react={
"file_hint": "frontend/src/components/MemoizedSvgContainer.tsx",
"pattern": r"React\.memo",
},
standalone={
"file_hint": "standalone_interface.html",
"pattern": r"lastClonedSourceRef",
},
),
Invariant(
name="network_diagram_fetch_uses_format_text",
description=(
"`/api/network-diagram` must be fetched via the "
"`format=text` variant (JSON header + raw SVG body) so "
"the client skips the ~500 ms JSON.parse on the multi-MB "
"SVG string — docs/performance/history/loading-parallel.md (commit dbc05f8)."
),
react={
"file_hint": "frontend/src/api.ts",
"pattern": r"network-diagram\?format=text",
},
standalone={
"file_hint": "standalone_interface.html",
"pattern": r"network-diagram\?format=text",
},
),
Invariant(
name="base_nad_parallel_with_metadata_boot",
description=(
"The base NAD fetch must overlap with the "
"/api/branches + /api/voltage-levels + /api/nominal-voltages "
"Promise.all — not run sequentially after it. /api/network-diagram "
"is the slowest XHR (~5-6 s pypowsybl regeneration), "
"serialising it adds 1-2 s to the critical path."
),
react={
"file_hint": "frontend/src/App.tsx",
# React parallelises all four in applySettingsImmediate.
"pattern": r"Promise\.all\(\s*\[[\s\S]*?getBranches[\s\S]*?getNetworkDiagram",
},
standalone={
"file_hint": "standalone_interface.html",
# Standalone's applySettings-equivalent now includes the
# NAD fetch in the Promise.all.
"pattern": r"Promise\.all\(\s*\[[\s\S]*?/api/branches[\s\S]*?_fetchNetworkDiagramTextFormat",
},
),
Invariant(
name="action_overview_backdrop_not_over_dimmed",
description=(
"Action Overview backdrop opacity must stay high enough "
"that voltage-coloured edges remain readable — "
"aggressively dimming (0.35 or 0.55) collapses 5-SVG-unit "
"edges into near-invisibility on large grids (commit d3c3b59). "
"Spec says 0.65 rect overlay, standalone uses 0.85 on the "
"SVG itself; both pass — anything < 0.65 fails this guard."
),
react={
# The React version uses a <rect> overlay with 0.65
# flood. Check the className is used.
"file_hint": "frontend/src/components/ActionOverviewDiagram.tsx",
"pattern": r"nad-overview-dim",
},
standalone={
"file_hint": "standalone_interface.html",
# Extract the opacity value set on the cloned backdrop
# and reject anything under 0.65. The regex is structured
# so the numeric capture is easy to validate in the checker.
"pattern": r"clone\.style\.opacity\s*=\s*['\"](0\.(?:[7-9]\d?|6[5-9]))['\"]",
},
),
# ===== UX-design-critique invariants =====
# The five recommendations from
# `docs/proposals/ui-design-critique.md` landed on top of an
# already-shipped UI. Each invariant below guards the UX
# contract a recommendation introduced so a future refactor
# can't silently revert it. The standalone side carries an
# empty spec (file-existence-only) because the contracts are
# React-source-level — the auto-generated bundle inherits them
# on its next build, and the legacy hand-maintained file
# predates the recommendations.
Invariant(
name="notices_panel_in_sidebar",
description=(
"Recommendation #4 (tier the warning system): the app "
"must surface a single `<NoticesPanel>` pill as the "
"entry point for persistent warnings instead of stacking "
"up to five concurrent yellow banners. The pill lives "
"in the Header — tucked under the app title alongside "
"the network-path block — so it stays visible across "
"every sidebar / panel state without crowding the sticky "
"contingency strip."
),
react={
"file_hint": "frontend/src/components/Header.tsx",
"pattern": r"<NoticesPanel\s",
},
standalone={"file_hint": "standalone_interface.html"},
),
Invariant(
name="action_feed_no_dismissable_warning_state",
description=(
"Recommendation #4: ActionFeed.tsx must not own "
"`showActionDictWarning` / `showRecommenderWarning` "
"local state — both warnings moved into NoticesPanel. "
"Re-introducing a `setShow*Warning` setter inside the "
"feed would silently bring back the yellow banners."
),
react={
"file_hint": "frontend/src/components/ActionFeed.tsx",
# Sanity anchor — the file must exist and parse.
"pattern": r"const\s+ActionFeed\b",
# Forbid the dismissable-banner state from sneaking back.
"must_not": r"setShow(?:ActionDict|Recommender)Warning",
},
standalone={"file_hint": "standalone_interface.html"},
),
Invariant(
name="overload_panel_uses_monitoring_hint",
description=(
"Recommendation #4: OverloadPanel.tsx renders the "
"monitoring-coverage warning as a one-line grey hint "
"(`monitoringHint` prop) — the full notice lives in "
"NoticesPanel. `showMonitoringWarning` / "
"`onDismissWarning` props on this component would mean "
"the inline yellow banner has been re-introduced."
),
react={
"file_hint": "frontend/src/components/OverloadPanel.tsx",
"pattern": r"monitoringHint\?\:\s*string\s*\|\s*null",
"must_not": r"showMonitoringWarning|onDismissWarning",
},
standalone={"file_hint": "standalone_interface.html"},
),
Invariant(
name="diagram_legend_on_each_diagram_tab",
description=(
"Recommendation #5 (add a diagram legend): "
"VisualizationPanel.tsx must render a `<DiagramLegend>` "
"for the N, contingency and Action tabs so the operator "
"can read halo / disconnection / voltage-level "
"conventions on-screen. Removing any of the three would "
"re-open the onboarding gap the recommendation closed."
),
react={
"file_hint": "frontend/src/components/VisualizationPanel.tsx",
# Each tabId must be wired explicitly. `[\s\S]*?` chains
# the three matches in source order. The contingency tab
# is keyed ``contingency`` since the N-K refactor (was
# ``n-1`` before).
"pattern": (
r"<DiagramLegend\b[^>]*?tabId=\"n\""
r"[\s\S]*?<DiagramLegend\b[^>]*?tabId=\"contingency\""
r"[\s\S]*?<DiagramLegend\b[^>]*?tabId=\"action\""
),
},
standalone={"file_hint": "standalone_interface.html"},
),
Invariant(
name="nad_overload_halo_zoom_adaptive",
description=(
"NAD overload halo sizing: the halo is screen-space "
"(`vector-effect: non-scaling-stroke`) and its width is driven "
"CONTINUOUSLY by usePanZoom through the `--nad-halo-w` CSS var — "
"thin (~24px, a clean trace) when zoomed in, growing toward a "
"prominent marker when zoomed out, with no abrupt `data-zoom-tier` "
"snap. App.css binds `stroke-width` to the var (thin default + "
"non-scaling-stroke guard) and usePanZoom writes the var from the "
"zoom ratio (`computeHaloWidthPx`). Guard against re-introducing a "
"fixed per-tier width snap (the old 24px-vs-120px tier step)."
),
react={
"file_hint": "frontend/src/App.css",
"pattern": (
r"\.nad-overloaded[^{}]*\{[^{}]*"
r"stroke-width:\s*var\(\s*--nad-halo-w,\s*24px\s*\)"
r"[^{}]*vector-effect:\s*non-scaling-stroke"
),
"must_not": (
r"\[data-zoom-tier=\"(?:region|detail)\"\]\s+\.nad-overloaded"
r"[^{}]*\{[^{}]*stroke-width:"
),
},
standalone={"file_hint": "standalone_interface.html"},
),
Invariant(
name="design_token_gate_blocks_inline_hex",
description=(
"Recommendation #1 (design-token layer): the code-"
"quality gate must keep the inline-hex ceiling at zero "
"outside `tokens.{css,ts}`. Loosening the gate would "
"let the three competing palettes (Flat UI / Bootstrap /"
" Tailwind) creep back in component-by-component."
),
react={
"file_hint": "scripts/check_code_quality.py",
# The named ceiling constant must stay at 0. We assert
# both the constant declaration and the comparison that
# consumes it so a renamed-but-unbound copy can't slip
# through.
"pattern": r"FRONTEND_HEX_LITERAL_MAX\s*=\s*0\b",
},
standalone={"file_hint": "standalone_interface.html"},
),
Invariant(
name="action_card_progressive_disclosure_gated_by_isViewing",
description=(
"Recommendation #2 (progressive disclosure): the "
"ActionCard component must gate its editable detail "
"rows (`isViewing && …`) so at-rest cards stay terse. "
"Without the gate the load-shedding / curtailment / "
"PST-tap rows render on every card and the feed becomes "
"unscan­nable on a 25 %-width sidebar."
),
react={
"file_hint": "frontend/src/components/ActionCard.tsx",
# The component receives an `isViewing` prop AND uses
# it to gate at least one subtree (PR #121 disclosure).
"pattern": r"isViewing:\s*boolean[\s\S]*?\{isViewing\s*&&",
},
standalone={"file_hint": "standalone_interface.html"},
),
]
def check_invariant(inv: Invariant) -> dict:
"""Run one invariant against both codebases."""
result = {
"name": inv.name,
"description": inv.description,
"severity": inv.severity,
"sides": {},
"ok": True,
}
for side, spec in (("react", inv.react), ("standalone", inv.standalone)):
paths = _paths_for_hint(spec["file_hint"])
if not paths:
result["sides"][side] = {
"status": "FAIL",
"reason": f"file not found: {spec['file_hint']}",
}
result["ok"] = False
continue
pattern = spec.get("pattern")
must_not = spec.get("must_not")
pattern_hit = must_not_hit = None
if pattern:
p, m = _search_in_paths(paths, pattern, re.DOTALL)
pattern_hit = (str(p.relative_to(REPO_ROOT)), m.start()) if m else None
if must_not:
p, m = _search_in_paths(paths, must_not, re.DOTALL)
must_not_hit = (str(p.relative_to(REPO_ROOT)), m.start()) if m else None
side_ok = (pattern is None or pattern_hit is not None) and (must_not is None or must_not_hit is None)
result["sides"][side] = {
"status": "OK" if side_ok else "FAIL",
"file_hint": spec["file_hint"],
"pattern_hit": pattern_hit,
"must_not_hit": must_not_hit,
"reason": None if side_ok else (
"regex not found in any candidate path" if (pattern and pattern_hit is None)
else f"forbidden pattern found at {must_not_hit}" if must_not_hit
else "unknown"
),
}
if not side_ok:
result["ok"] = False
return result
def run_checks() -> dict:
findings = [check_invariant(inv) for inv in INVARIANTS]
return {"invariants": findings}
def render_human(report: dict) -> str:
out = ["USER-OBSERVABLE INVARIANTS REPORT (Layer 4 static)", "=" * 72]
failed = [r for r in report["invariants"] if not r["ok"]]
for r in report["invariants"]:
status_icon = "✅" if r["ok"] else "❌"
out.append(f" {status_icon} {r['name']:50s} [{r['severity']}]")
if not r["ok"]:
out.append(f" description: {r['description']}")
for side, info in r["sides"].items():
if info["status"] != "OK":
hint = info.get("file_hint", "<no hint>")
out.append(f" {side:10s}{info.get('reason', '')} ({hint})")
out.append("")
out.append(f"Summary: {len(report['invariants']) - len(failed)}/{len(report['invariants'])} invariants satisfied.")
return "\n".join(out)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--json", action="store_true")
args = parser.parse_args()
report = run_checks()
if args.json:
print(json.dumps(report, indent=2, sort_keys=True))
else:
print(render_human(report))
hard_fail = any(
not r["ok"] and r["severity"] == "FAIL" for r in report["invariants"]
)
return 1 if hard_fail else 0
if __name__ == "__main__":
sys.exit(main())