#!/usr/bin/env python3
"""Check the frontend's CSS against the tokens its stylesheet declares.
The gap this closes: `services/design_stylesheet.check_code_against_tokens` has
always been able to answer "does this code use the sheet correctly?", but the
only thing ever fed to it was recorded SNIPPETS. The app's own components — where
sixteen unresolvable references were found living quietly (#2319) — were checked
by nothing at all.
That was structural rather than an oversight. The drift panel runs in the browser
and cannot read source files, and the server has no repo access. CI is the only
place holding both the component sources and the ability to run the check, and it
only became cheap once `theme.css` became a generated artifact — so the source of
truth is a local file, with no network and no credentials.
INSTANCE-AGNOSTIC ON PURPOSE (rule #115). Nothing here knows what a token should
be called or which literals are discouraged. Both come from the stylesheet: the
declarations, and the `SUPERSEDES` block the generator emits. Point it at a
different install's sheet and it checks that install's rules.
Two severities, and the split is deliberate:
FAIL an unresolvable `var()` reference. Currently zero, so this is a ratchet
that holds a line already reached rather than a backlog that keeps CI
red. It also cannot false-positive: either the name is declared or it
is not.
REPORT superseded literals and raw colour literals. Hundreds today, so gating
on them would mean a permanently failing job that everyone learns to
ignore — which is worse than no check.
"""
from __future__ import annotations
import argparse
import pathlib
import re
import sys
# A declaration is `--name:`; a reference is `var(--name)` or `var(--name, …)`.
DECLARATION = re.compile(r"(?\s*(--[A-Za-z0-9_-]+)\s*$")
HEX_LITERAL = re.compile(r"#[0-9a-fA-F]{3,8}\b")
STYLE_BLOCK = re.compile(r"", re.S)
CSS_COMMENT = re.compile(r"/\*.*?\*/", re.S)
def declared_tokens(sheet: str) -> set[str]:
"""Every custom property the stylesheet declares.
Anchored on the colon alone. Anchoring on `{` or `;` instead silently drops
every declaration that follows a comment — a mistake made once already, which
lost `--color-bg` and 2 others without erroring.
"""
return set(DECLARATION.findall(sheet))
def superseded_literals(sheet: str) -> dict[str, str]:
"""`{literal: token}` from the generator's SUPERSEDES block, lowercased."""
out: dict[str, str] = {}
for line in sheet.splitlines():
match = SUPERSEDES_LINE.match(line)
if match:
out[match.group(1).lower()] = match.group(2)
return out
def _literal_pattern(literal: str) -> re.Pattern:
"""Match a literal without matching a longer one containing it.
`#fff` must not fire inside `#ffffff`: different colours, and a finding on
the wrong one sends someone to change correct code.
"""
return re.compile(
r"(? str:
"""The CSS in a file — `