Add structural_check: spacing/radius/type/motion drift

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-11-02 08:18:27 +01:00
parent 82119bdd47
commit cbcee98d94
8 changed files with 523 additions and 317 deletions

View File

@ -31,3 +31,8 @@ jobs:
- name: Categorical ramp still passes its constraints
run: python3 unified-design-system/scripts/ramp_check.py
# Spacing, radius, type, shadow and motion: palette_check.py parses
# hex only, so these scales were unguarded until structural_check.py.
- name: Structural scales agree across all token sources
run: python3 unified-design-system/scripts/structural_check.py

View File

@ -1,139 +0,0 @@
Add a structural-token drift checker to CI
## findings (2, ranked by value over effort)
1. **Spacing, radius and type have never been drift-checked** — `palette_check.py`
parses hex only, so three whole token categories can diverge across stacks
while CI stays green. Motion inherits the same hole.
2. **`palette_map.py` points at a "scale check" that does not exist** — a
comment describing a guarantee nobody wrote.
## what
`palette_check.py` guarantees the colour palette agrees across all stack copies.
It does **not** check anything else — verified 2026-08-16 by reading its four
parsers, every one of which requires a `#RRGGBB` value:
```python
parse_css : r"(--[a-z0-9-]+)\s*:\s*(#[0-9A-Fa-f]{6})\s*;"
parse_dart: r"static const Color (\w+) = Color\(0x[fF]{2}([0-9A-Fa-f]{6})\)"
parse_tk : r'^\s{4}(\w+):\s*str\s*=\s*"(#[0-9A-Fa-f]{6})"'
parse_md : r"^\|\s*`([a-z0-9-]+)`\s*\|\s*`(#[0-9A-Fa-f]{6})`"
```
So **spacing, radius and type have been silently unprotected all along**, and the
motion tokens added by prompt 01 inherit the same hole. A `--duration-fast: 120ms`
is invisible to every parser: CI passes while the stacks drift apart.
Worse, `scripts/palette_map.py:159` claims structural values are "checked by the
scale check" — **no such script exists anywhere in `~/src/utils`**. That comment
describes a check that was never written. This prompt writes it.
## where
Repo: `~/src/utils`.
**Create** `~/src/utils/unified-design-system/scripts/structural_check.py`.
**Edit** `~/src/utils/.github/workflows/palette-drift.yml` — add a step invoking it.
The workflow deliberately has no `paths:` filter; keep it that way.
**Fix** the stale comment at `~/src/utils/unified-design-system/scripts/palette_map.py`
(~:159) to name the real script.
Sources to compare (three code stacks + the prose freeze):
| Role | Path |
|---|---|
| prose freeze | `~/src/utils/unified-design-system/tokens.md` (spacing/radius/type) |
| motion freeze | `~/src/utils/unified-design-system/motion.md` (**created by prompt 01** — run it first) |
| web/React | `~/src/utils/web_ui/src/tokens.css` |
| Flutter/Dart | `~/src/utils/design_system/lib/src/tokens.dart` |
| Python/Tkinter | `~/src/utils/gatelock/gatelock/_window.py` (`class LockConfig`) |
## must
- Cover **motion** (durations, easing) **and** the already-unprotected
**spacing, radius and type** scales, in one checker.
- Mirror `palette_check.py`'s two-halves design, because it is the part that
actually works:
1. **Agreement** — for each mapped token, resolve it in every non-`n/a` stack
and fail if values disagree.
2. **Completeness, bidirectional** — every structural value parsed out of any
source must be mapped, or explicitly exempted. This is what catches
"added it to one stack, forgot the others".
- Support an explicit **`n/a` per stack**, and require a reason string. Tkinter
has no animation framework; a duration legitimately does not exist there. An
honest `n/a` with a reason is correct — a fabricated constant is not.
- **Adjudicate with exit codes.** Exit non-zero on drift with a message naming
the token and the disagreeing files. No warnings-only mode.
- Keep every file ≤ **250 lines** (`~/src/utils/file_length/config.py`). Note
`palette_check.py` is 231 and `palette_map.py` is 162 — that pair was split
precisely to stay under the cap. **Split yours the same way from the start**
(`structural_check.py` + `structural_map.py`) rather than discovering the
problem at 251 lines.
- Ruff-clean under the repo's config. No `# noqa`, no `type: ignore`.
- **Expect it to fail on first run.** Spacing/radius/type have never been
compared; there may be real pre-existing drift. That is the point of writing
it. Fix whatever it surfaces, in the stack copy that is wrong, and say in the
session summary what drifted.
- must not: weaken the checker, add an exemption, or narrow its scope to make it
green. If a token genuinely differs per stack for a good reason, encode it as
an `n/a`/variant **with the reason recorded**, not as a silent skip.
- must not: modify `palette_check.py`. It works; it is simply colour-only. The
two scripts run side by side.
- must not: change any token *value* to resolve a disagreement without saying so
explicitly — that is a design change, and `tokens.md` is a freeze. Surface it
and let kuhy decide which value is canonical.
- optional: teach it the shadow tier too, if that falls out cheaply.
## done
1. `python3 ~/src/utils/unified-design-system/scripts/structural_check.py` exits 0,
and exits non-zero when you deliberately break one stack copy (**test both
directions** — a checker that never fails is not a checker).
2. It is wired into `~/src/utils/.github/workflows/palette-drift.yml` and the
workflow passes.
3. `palette_map.py`'s "scale check" comment names the real script.
4. `~/src/utils/scripts/check_file_length.sh --all` reports no new violations.
5. The session summary states what drift, if any, the first run found.
## verify
Desktop. Run the script, paste the output. Then prove it fails: change one
spacing or duration value in **one** stack copy, re-run, paste the non-zero exit
and the message, and revert. Both halves of that demonstration are required —
the passing run alone proves nothing.
## read first
- `~/src/utils/unified-design-system/scripts/palette_check.py` — the design to
mirror. Read `NON_COLOUR_CSS`: it is the regex that currently *excludes*
spacing/radius/type/shadow from the colour check, and therefore the precise
list of what you are now covering.
- `~/src/utils/unified-design-system/scripts/palette_map.py` — the `PALETTE` tuple of
`Token(canonical, md, css, dart, tk, why)` rows. Your map wants the same shape,
including the `why` field.
- `~/src/utils/.github/workflows/palette-drift.yml` — how `palette_check.py` and
`ramp_check.py` are invoked; add your step alongside.
- `~/src/utils/unified-design-system/motion.md` — what prompt 01 created.
- `~/src/utils/unified-design-system/tokens.md` — the spacing/radius/type tables.
**Do not edit it**; it is 260 lines and already over the cap.
## context you would otherwise rediscover
- The four "stack copies" are really **three code stacks plus a prose freeze**.
There is no fourth code stack; do not hunt for one.
- `~/src/dufs-cloud/app/lib/ui/theme.dart` is a **fifth, uncompared copy** of the
palette (the Flutter app has no `design_system` dependency and hand-restates
every hex). It is out of scope here — prompt 07 wires it up properly. Do not
add it as a source to this checker; wait until 07 has removed it.
- Three Flutter apps (diet-guard, workout_app, wake_alarm) similarly
hand-transcribe tokens into local `lib/ui/theme.dart` files. Those are
deliberately outside the checker's scope for now — bringing them in is a much
larger job than this prompt.
- `~/src/utils/gatelock/` is a package inside this monorepo, not a separate repo.
REMOVE ME AFTER FINISH

View File

@ -1,177 +0,0 @@
> **This file is a ready-to-use prompt.** Open this repo and say
> "do dopamine-ux-02-structural-check". It is self-contained -- it needs no context from
> any other session.
>
> Canonical copy: `~/src/utils/dopamine-ux/02-structural-check.md` (source of truth; this is a
> distributed copy). Running order and cross-prompt rules: `~/src/utils/dopamine-ux/00-INDEX.md`.
> Generated 2026-08-16 from a survey of this repo -- line numbers are accurate
> as of that date; **anchor on the symbol names, not the line numbers.**
## ⛔ WHEN YOU FINISH THIS PROMPT
Do **both**, in the same session that completes the work:
1. **Delete this file** and commit that deletion together with the
implementation:
```bash
git rm prompts/dopamine-ux-02-structural-check.md
```
Do not leave a finished prompt lying in the repo -- a stale prompt is
indistinguishable from a pending one, and the next session will re-run it.
2. **Log completion in the canonical index**, in `~/src/utils` (a *different* repo,
so it needs its own commit):
```bash
cd ~/src/utils
# append to the "Completion log" table in dopamine-ux/00-INDEX.md:
# | 02-structural-check.md | DONE <YYYY-MM-DD> | <impl commit sha> | <one-line note> |
git add dopamine-ux/00-INDEX.md
git commit -m "dopamine-ux: mark 02-structural-check.md done"
git push
```
If you finish only part of the prompt, do **neither** -- instead edit this file
to strike out what is done and state what remains, so the next session starts
from the true state. Partial completion must never be recorded as DONE.
---
Add a structural-token drift checker to CI
## findings (2, ranked by value over effort)
1. **Spacing, radius and type have never been drift-checked** — `palette_check.py`
parses hex only, so three whole token categories can diverge across stacks
while CI stays green. Motion inherits the same hole.
2. **`palette_map.py` points at a "scale check" that does not exist** — a
comment describing a guarantee nobody wrote.
## what
`palette_check.py` guarantees the colour palette agrees across all stack copies.
It does **not** check anything else — verified 2026-08-16 by reading its four
parsers, every one of which requires a `#RRGGBB` value:
```python
parse_css : r"(--[a-z0-9-]+)\s*:\s*(#[0-9A-Fa-f]{6})\s*;"
parse_dart: r"static const Color (\w+) = Color\(0x[fF]{2}([0-9A-Fa-f]{6})\)"
parse_tk : r'^\s{4}(\w+):\s*str\s*=\s*"(#[0-9A-Fa-f]{6})"'
parse_md : r"^\|\s*`([a-z0-9-]+)`\s*\|\s*`(#[0-9A-Fa-f]{6})`"
```
So **spacing, radius and type have been silently unprotected all along**, and the
motion tokens added by prompt 01 inherit the same hole. A `--duration-fast: 120ms`
is invisible to every parser: CI passes while the stacks drift apart.
Worse, `scripts/palette_map.py:159` claims structural values are "checked by the
scale check" — **no such script exists anywhere in `~/src/utils`**. That comment
describes a check that was never written. This prompt writes it.
## where
Repo: `~/src/utils`.
**Create** `~/src/utils/unified-design-system/scripts/structural_check.py`.
**Edit** `~/src/utils/.github/workflows/palette-drift.yml` — add a step invoking it.
The workflow deliberately has no `paths:` filter; keep it that way.
**Fix** the stale comment at `~/src/utils/unified-design-system/scripts/palette_map.py`
(~:159) to name the real script.
Sources to compare (three code stacks + the prose freeze):
| Role | Path |
|---|---|
| prose freeze | `~/src/utils/unified-design-system/tokens.md` (spacing/radius/type) |
| motion freeze | `~/src/utils/unified-design-system/motion.md` (**created by prompt 01** — run it first) |
| web/React | `~/src/utils/web_ui/src/tokens.css` |
| Flutter/Dart | `~/src/utils/design_system/lib/src/tokens.dart` |
| Python/Tkinter | `~/src/utils/gatelock/gatelock/_window.py` (`class LockConfig`) |
## must
- Cover **motion** (durations, easing) **and** the already-unprotected
**spacing, radius and type** scales, in one checker.
- Mirror `palette_check.py`'s two-halves design, because it is the part that
actually works:
1. **Agreement** — for each mapped token, resolve it in every non-`n/a` stack
and fail if values disagree.
2. **Completeness, bidirectional** — every structural value parsed out of any
source must be mapped, or explicitly exempted. This is what catches
"added it to one stack, forgot the others".
- Support an explicit **`n/a` per stack**, and require a reason string. Tkinter
has no animation framework; a duration legitimately does not exist there. An
honest `n/a` with a reason is correct — a fabricated constant is not.
- **Adjudicate with exit codes.** Exit non-zero on drift with a message naming
the token and the disagreeing files. No warnings-only mode.
- Keep every file ≤ **250 lines** (`~/src/utils/file_length/config.py`). Note
`palette_check.py` is 231 and `palette_map.py` is 162 — that pair was split
precisely to stay under the cap. **Split yours the same way from the start**
(`structural_check.py` + `structural_map.py`) rather than discovering the
problem at 251 lines.
- Ruff-clean under the repo's config. No `# noqa`, no `type: ignore`.
- **Expect it to fail on first run.** Spacing/radius/type have never been
compared; there may be real pre-existing drift. That is the point of writing
it. Fix whatever it surfaces, in the stack copy that is wrong, and say in the
session summary what drifted.
- must not: weaken the checker, add an exemption, or narrow its scope to make it
green. If a token genuinely differs per stack for a good reason, encode it as
an `n/a`/variant **with the reason recorded**, not as a silent skip.
- must not: modify `palette_check.py`. It works; it is simply colour-only. The
two scripts run side by side.
- must not: change any token *value* to resolve a disagreement without saying so
explicitly — that is a design change, and `tokens.md` is a freeze. Surface it
and let kuhy decide which value is canonical.
- optional: teach it the shadow tier too, if that falls out cheaply.
## done
1. `python3 ~/src/utils/unified-design-system/scripts/structural_check.py` exits 0,
and exits non-zero when you deliberately break one stack copy (**test both
directions** — a checker that never fails is not a checker).
2. It is wired into `~/src/utils/.github/workflows/palette-drift.yml` and the
workflow passes.
3. `palette_map.py`'s "scale check" comment names the real script.
4. `~/src/utils/scripts/check_file_length.sh --all` reports no new violations.
5. The session summary states what drift, if any, the first run found.
## verify
Desktop. Run the script, paste the output. Then prove it fails: change one
spacing or duration value in **one** stack copy, re-run, paste the non-zero exit
and the message, and revert. Both halves of that demonstration are required —
the passing run alone proves nothing.
## read first
- `~/src/utils/unified-design-system/scripts/palette_check.py` — the design to
mirror. Read `NON_COLOUR_CSS`: it is the regex that currently *excludes*
spacing/radius/type/shadow from the colour check, and therefore the precise
list of what you are now covering.
- `~/src/utils/unified-design-system/scripts/palette_map.py` — the `PALETTE` tuple of
`Token(canonical, md, css, dart, tk, why)` rows. Your map wants the same shape,
including the `why` field.
- `~/src/utils/.github/workflows/palette-drift.yml` — how `palette_check.py` and
`ramp_check.py` are invoked; add your step alongside.
- `~/src/utils/unified-design-system/motion.md` — what prompt 01 created.
- `~/src/utils/unified-design-system/tokens.md` — the spacing/radius/type tables.
**Do not edit it**; it is 260 lines and already over the cap.
## context you would otherwise rediscover
- The four "stack copies" are really **three code stacks plus a prose freeze**.
There is no fourth code stack; do not hunt for one.
- `~/src/dufs-cloud/app/lib/ui/theme.dart` is a **fifth, uncompared copy** of the
palette (the Flutter app has no `design_system` dependency and hand-restates
every hex). It is out of scope here — prompt 07 wires it up properly. Do not
add it as a source to this checker; wait until 07 has removed it.
- Three Flutter apps (diet-guard, workout_app, wake_alarm) similarly
hand-transcribe tokens into local `lib/ui/theme.dart` files. Those are
deliberately outside the checker's scope for now — bringing them in is a much
larger job than this prompt.
- `~/src/utils/gatelock/` is a package inside this monorepo, not a separate repo.
REMOVE ME AFTER FINISH

View File

@ -156,7 +156,7 @@ PALETTE: tuple[Token, ...] = (
)
# Tokens a source legitimately defines that are not colours in the palette --
# structural/typographic values checked by the scale check, not the hex check.
# structural/typographic values checked by structural_check.py, not the hex check.
NON_COLOUR_CSS = re.compile(
r"^--(radius|space|text|tracking|font|measure|shadow)-|^--(font-sans|font-mono|measure)$"
)

View File

@ -0,0 +1,138 @@
"""Verify spacing, radius, type, shadow and motion agree across all stacks.
`palette_check.py` guards colour only (every parser wants `#RRGGBB`), so these
scales were unprotected until now. Same two halves:
* agreement -- each mapped token resolves in every stack that is not `n/a`
for it, and all stacks carry the same value
* completeness -- every structural name any source defines is mapped or
exempted with a reason, in both directions (a stale exemption also fails)
Sources: DOCS-tokens.md + DOCS-motion.md (prose freeze), web_ui tokens.css,
design_system tokens.dart, gatelock _theme.py.
Run: python3 unified-design-system/scripts/structural_check.py
Exit 0 = agree and complete; 1 = drift, a missing value or an unmapped name.
"""
from __future__ import annotations
import re
import sys
from dataclasses import dataclass
from pathlib import Path
import structural_parse as sp
from structural_map import EXEMPT, STACKS, TOKENS
ROOT = Path(__file__).resolve().parents[2]
SOURCE_FILES: dict[str, str] = {
"md": "unified-design-system/DOCS-tokens.md",
"motion": "unified-design-system/DOCS-motion.md",
"css": "web_ui/src/tokens.css",
"dart": "design_system/lib/src/tokens.dart",
"tk": "gatelock/gatelock/_theme.py",
}
@dataclass
class Result:
"""One check outcome."""
ok: bool
detail: str
def norm(kind: str, raw: str) -> str:
"""Reduce a raw source value to a comparable string for its kind."""
if kind == "shadow":
return " ".join(raw.split())
nums = re.findall(r"-?\d*\.?\d+", raw)
if kind == "ease":
return ",".join(f"{float(n):g}" for n in nums)
if not nums:
return "0" if "zero" in raw else raw.strip()
return f"{float(nums[0]):g}"
def load(root: Path) -> dict[str, sp.Table]:
"""Parse every source under `root` into `{stack: {key: raw}}`."""
def read(key: str) -> str:
return (root / SOURCE_FILES[key]).read_text(encoding="utf-8")
return {
"md": {**sp.parse_tokens_md(read("md")), **sp.parse_motion_md(read("motion"))},
"css": sp.parse_css(read("css")),
"dart": sp.parse_dart(read("dart")),
"tk": sp.parse_tk(read("tk")),
}
def check_agreement(src: dict[str, sp.Table]) -> list[Result]:
"""Every mapped token must carry one value in every non-`n/a` stack."""
results: list[Result] = []
for token in TOKENS:
kind = token.canonical.split(".")[0]
names = {"md": token.canonical, **{s: getattr(token, s) for s in STACKS}}
seen: dict[str, str] = {}
missing: list[str] = []
for stack, name in names.items():
if name is None:
continue
if name in src[stack]:
seen[stack] = norm(kind, src[stack][name])
else:
missing.append(f"{stack}:{name} ({SOURCE_FILES[stack]})")
if missing:
results.append(Result(False, f"{token.canonical:<20} MISSING {', '.join(missing)}"))
elif len(set(seen.values())) > 1:
detail = ", ".join(f"{s}={v}" for s, v in seen.items())
files = ", ".join(SOURCE_FILES[s] for s in seen)
results.append(Result(False, f"{token.canonical:<20} DRIFT {detail} [{files}]"))
else:
results.append(
Result(True, f"{token.canonical:<20} {next(iter(seen.values()))} x{len(seen)}")
)
return results
def check_completeness(src: dict[str, sp.Table]) -> list[Result]:
"""No unmapped name in any source, and no exemption for a vanished name."""
results: list[Result] = []
for stack in ("md", *STACKS):
if stack == "md":
mapped = {t.canonical for t in TOKENS}
else:
mapped = {getattr(t, stack) for t in TOKENS if getattr(t, stack)}
exempt = EXEMPT.get(stack, {})
extras = sorted(n for n in src[stack] if n not in mapped and n not in exempt)
stale = sorted(n for n in exempt if n not in src[stack])
if extras:
msg = f"{stack:<4} defines unmapped name(s): {', '.join(extras)} -- add to TOKENS or EXEMPT"
results.append(Result(False, msg))
if stale:
results.append(Result(False, f"{stack:<4} stale exemption(s): {', '.join(stale)}"))
if not extras and not stale:
results.append(Result(True, f"{stack:<4} all {len(src[stack])} parsed name(s) accounted for"))
return results
def run(root: Path) -> list[Result]:
"""Both halves of the check against the sources under `root`."""
src = load(root)
return [*check_agreement(src), *check_completeness(src)]
def main(root: Path = ROOT) -> int:
"""Print the report; non-zero on any failing check."""
results = run(root)
for r in results:
print(("PASS " if r.ok else "FAIL ") + r.detail)
failures = sum(not r.ok for r in results)
print(f"\n{failures} failing check(s)")
return 1 if failures else 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,87 @@
"""The canonical structural-token table: spacing, radius, type, shadow, motion.
Colour is `palette_map.py`; this is its twin for everything that is not a
colour. Pure data -- parsing and comparison live in `structural_check.py`.
A canonical key is `<kind>.<name>` (`space.sm`, `duration.fast`) and is also
the key the prose freezes (`DOCS-tokens.md`, `DOCS-motion.md`) are parsed
into, so the md stack needs no column of its own. A `None` name means the
token is intentionally absent from that stack and `why` must say so: an
honest `n/a` with a reason, never a fabricated constant.
"""
from __future__ import annotations
from dataclasses import dataclass
STACKS = ("css", "dart", "tk")
@dataclass(frozen=True)
class Token:
"""One canonical structural token and its name in each code stack."""
canonical: str
css: str | None
dart: str | None
tk: str | None
why: str = ""
def __post_init__(self) -> None:
"""Refuse an absence that carries no recorded reason."""
if None in (self.css, self.dart, self.tk) and not self.why:
msg = f"{self.canonical}: a stack is n/a but `why` is empty"
raise ValueError(msg)
_TK_NO_RADIUS = "Tk has no corner-radius primitive: widgets are square."
_TK_NO_MOTION = "Tkinter has no animation framework; a duration would be dead."
_NO_SHADOW = (
"Dark-first Flutter/Tk surfaces take no shadow (elevation is a fill step); "
"only web light surfaces opt in."
)
_STEPS = ("xs", "sm", "md", "lg", "xl", "xxl")
_TYPE = ("display", "title", "subtitle", "body", "label", "caption")
TOKENS: tuple[Token, ...] = (
*(
Token(f"space.{s}", f"--space-{i}", f"AppSpacing.{s}", f"LockSpacing.{s}")
for i, s in enumerate(_STEPS, 1)
),
*(
Token(f"radius.{s}", f"--radius-{s}", f"AppRadius.{s}", None, _TK_NO_RADIUS)
for s in ("sm", "md", "lg")
),
*(
Token(f"text.{t}", f"--text-{t}", f"AppTextSize.{t}", f"LockTypography.{t}")
for t in _TYPE
),
*(Token(f"shadow.{n}", f"--shadow-{n}", None, None, _NO_SHADOW) for n in (1, 2)),
*(
Token(f"duration.{d}", f"--duration-{d}", f"AppDuration.{d}", None, _TK_NO_MOTION)
for d in ("instant", "fast", "base", "slow")
),
*(
Token(f"ease.{e}", f"--ease-{e}", f"AppCurve.{e}", None, _TK_NO_MOTION)
for e in ("standard", "decelerate", "accelerate")
),
)
# Names a source defines that are deliberately outside the compared scales.
# Per stack, name -> reason. A silent skip is the fail-open path this closes.
_TRACKING = "Tracking is an approximate range in the freeze ('tight, e.g. -0.2px'), not a value."
EXEMPT: dict[str, dict[str, str]] = {
"css": {
"--tracking-display": _TRACKING,
"--tracking-title": _TRACKING,
"--tracking-label": _TRACKING,
"--tracking-caption": _TRACKING,
"--font-sans": "Font stacks are per-platform by design (DOCS-tokens.md, Typography).",
"--font-mono": "Font stacks are per-platform by design (DOCS-tokens.md, Typography).",
"--measure": "Web-only prose line-length cap (rule 21); no Dart/Tk text measure.",
},
"dart": {},
"tk": {
"LockSpacing.focus_thickness": "Focus-ring width (operability doc), not a scale step.",
},
}

View File

@ -0,0 +1,110 @@
"""Parsers for `structural_check.py`: one per source, all fail closed.
Every parser returns `{key: raw_value}` and raises `SystemExit` when the
structure it expects is gone. Returning `{}` would make a moved file read as
"0 parsed, 0 drifted" -- the exact vacuous pass `palette_check.py` was bitten
by -- so a vanished section is an error, never a skip.
"""
from __future__ import annotations
import re
Table = dict[str, str]
def _die(msg: str) -> None:
raise SystemExit(f"structural_check: {msg}")
def section(text: str, header: str) -> str:
"""Body of the `## <header>` section, up to the next `## ` heading."""
m = re.search(rf"^## {re.escape(header)}.*?$(.*?)(?=^## |\Z)", text, re.M | re.S)
if not m:
_die(f"section '## {header}' not found; the freeze was renamed or moved")
return m.group(1)
def parse_tokens_md(text: str) -> Table:
"""Spacing, radius, type and shadow rows from `DOCS-tokens.md`."""
found: Table = {}
steps = re.search(r"`([^`\n]*·[^`\n]*)`", section(text, "Spacing scale"))
if steps:
for name, val in re.findall(r"(\w+) (\d+)", steps.group(1)):
found[f"space.{name}"] = val
for name, val in re.findall(r"`(\w+) (\d+)`", section(text, "Corner radius scale")):
found[f"radius.{name}"] = val
for name, val in re.findall(
r"^\|\s*`(\w+)`\s*\|\s*(\d+)\s*\|", section(text, "Typography scale"), re.M
):
found[f"text.{name}"] = val
for level, val in re.findall(
r"Level (\d)[^`\n]*`([^`\n]+)`", section(text, "Shadow policy")
):
found[f"shadow.{level}"] = val
return found
def parse_motion_md(text: str) -> Table:
"""Duration and easing rows from `DOCS-motion.md`."""
found: Table = {}
row = r"^\|\s*`(\w+)`\s*\|\s*`([^`]+)`"
for name, val in re.findall(row, section(text, "Duration"), re.M):
found[f"duration.{name}"] = val
for name, val in re.findall(row, section(text, "Easing"), re.M):
found[f"ease.{name}"] = val
return found
def parse_css(text: str) -> Table:
"""Root-scope structural custom properties from `tokens.css`.
Everything from the first `@media` on is overrides (reduced motion, dark
shadows); the freeze is the root block above it.
"""
text = re.sub(r"/\*.*?\*/", "", text, flags=re.S) # prose may name tokens
cut = text.find("@media")
if cut == -1:
_die("tokens.css has no @media block; cannot find the end of :root")
decl = re.compile(
r"(--(?:space|radius|text|shadow|duration|ease|tracking|font|measure)[a-z0-9-]*)"
r"\s*:\s*([^;]+);"
)
return {
name: value.strip()
for name, value in decl.findall(text[:cut])
if not value.strip().startswith("#") # `--text`, `--text-muted`: colours
}
def _classes(text: str, opener: str) -> list[tuple[str, str]]:
"""(name, body) of each class; `opener` is the regex that starts one."""
parts = list(re.finditer(opener, text, re.M))
if not parts:
return []
ends = [m.start() for m in parts[1:]] + [len(text)]
return [(m.group(1), text[m.end() : end]) for m, end in zip(parts, ends, strict=True)]
def parse_dart(text: str) -> Table:
"""`static const double|Duration|Curve` members, keyed `Class.member`."""
found: Table = {}
member = re.compile(r"static const (?:double|Duration|Curve) (\w+) = ([^;]+);")
for cls, body in _classes(text, r"^abstract final class (\w+)"):
for name, value in member.findall(body):
found[f"{cls}.{name}"] = value.strip()
if not found:
_die("tokens.dart has no double/Duration/Curve constants")
return found
def parse_tk(text: str) -> Table:
"""Integer dataclass fields, keyed `Class.field`."""
found: Table = {}
field = re.compile(r"^ {4}(\w+):\s*int\s*=\s*(\d+)", re.M)
for cls, body in _classes(text, r"^class (\w+)"):
for name, value in field.findall(body):
found[f"{cls}.{name}"] = value
if not found:
_die("_theme.py has no int fields; the scales moved out of it")
return found

View File

@ -0,0 +1,182 @@
"""Tests for scripts/structural_check.py: both directions of the checker."""
from __future__ import annotations
import shutil
import subprocess
import sys
from pathlib import Path
import pytest
import structural_check
import structural_map
REAL_ROOT = Path(__file__).resolve().parents[2]
@pytest.fixture
def root(tmp_path: Path) -> Path:
"""A scratch copy of the five source files, mutable per test."""
for rel in structural_check.SOURCE_FILES.values():
dest = tmp_path / rel
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy(REAL_ROOT / rel, dest)
return tmp_path
def edit(root: Path, stack: str, old: str, new: str) -> None:
path = root / structural_check.SOURCE_FILES[stack]
text = path.read_text(encoding="utf-8")
assert old in text, f"{old!r} not in {path}"
path.write_text(text.replace(old, new, 1), encoding="utf-8")
def failures(root: Path) -> list[str]:
return [r.detail for r in structural_check.run(root) if not r.ok]
def test_real_tree_is_green() -> None:
assert failures(REAL_ROOT) == []
assert structural_check.main(REAL_ROOT) == 0
@pytest.mark.parametrize(
("stack", "old", "new", "token"),
[
("css", "--space-2: 8px", "--space-2: 9px", "space.sm"),
("dart", "double sm = 8;", "double sm = 9;", "space.sm"),
("tk", "sm: int = 8", "sm: int = 9", "space.sm"),
("css", "--duration-fast: 120ms;", "--duration-fast: 130ms;", "duration.fast"),
("dart", "milliseconds: 200", "milliseconds: 210", "duration.base"),
("css", "cubic-bezier(0.3, 0, 1, 1)", "cubic-bezier(0.4, 0, 1, 1)", "ease.accelerate"),
("dart", "Cubic(0, 0, 0, 1)", "Cubic(0, 0, 0, 2)", "ease.decelerate"),
("css", "--radius-md: 12px", "--radius-md: 13px", "radius.md"),
("tk", "title: int = 24", "title: int = 25", "text.title"),
("css", "0 4px 8px rgba", "0 5px 8px rgba", "shadow.2"),
("md", "`sm 8` (buttons", "`sm 7` (buttons", "radius.sm"),
("motion", "`200ms`", "`210ms`", "duration.base"),
],
)
def test_value_drift_fails_naming_the_token(
root: Path, stack: str, old: str, new: str, token: str
) -> None:
edit(root, stack, old, new)
found = failures(root)
assert any(token in line and "DRIFT" in line for line in found), found
assert structural_check.main(root) == 1
def test_missing_in_a_stack_fails(root: Path) -> None:
edit(root, "css", "--space-2: 8px;", "")
assert any("space.sm" in f and "MISSING" in f for f in failures(root))
def test_unmapped_css_token_fails_completeness(root: Path) -> None:
edit(root, "css", "--space-6: 48px;", "--space-6: 48px; --space-7: 64px;")
assert any("css" in f and "--space-7" in f for f in failures(root))
def test_unmapped_dart_token_fails_completeness(root: Path) -> None:
edit(root, "dart", "static const double xxl = 48;", "static const double xxl = 48;\n static const double huge = 64;")
assert any("dart" in f and "AppSpacing.huge" in f for f in failures(root))
def test_unmapped_tk_token_fails_completeness(root: Path) -> None:
edit(root, "tk", "xxl: int = 48", "xxl: int = 48\n huge: int = 64")
assert any("tk" in f and "LockSpacing.huge" in f for f in failures(root))
def test_unmapped_md_token_fails_completeness(root: Path) -> None:
edit(root, "motion", "| `slow` | `320ms` |", "| `glacial` | `900ms` |\n| `slow` | `320ms` |")
assert any("md" in f and "duration.glacial" in f for f in failures(root))
def test_unmapped_shadow_in_md_fails(root: Path) -> None:
edit(root, "md", "- Level 1 (e.g. a dropdown)", "- Level 3 (e.g. a toast): `0 8px 16px rgba(0, 0, 0, 0.2)`\n - Level 1 (e.g. a dropdown)")
assert any("shadow.3" in f for f in failures(root))
def test_stale_exemption_fails(root: Path) -> None:
edit(root, "css", "--measure: 40rem;", "")
assert any("--measure" in f and "stale" in f for f in failures(root))
def test_missing_section_fails_closed(root: Path) -> None:
edit(root, "md", "## Spacing scale (4px base)", "## Renamed")
with pytest.raises(SystemExit):
structural_check.run(root)
def test_missing_class_fails_closed(root: Path) -> None:
edit(root, "tk", "class LockSpacing", "class Renamed")
# Renamed class still has int fields, so the tokens map to nothing.
assert any("LockSpacing" in f for f in failures(root))
def test_no_dart_class_fails_closed(root: Path) -> None:
path = root / structural_check.SOURCE_FILES["dart"]
path.write_text("// empty\n", encoding="utf-8")
with pytest.raises(SystemExit):
structural_check.run(root)
def test_no_tk_ints_fails_closed(root: Path) -> None:
path = root / structural_check.SOURCE_FILES["tk"]
path.write_text("x = 1\n", encoding="utf-8")
with pytest.raises(SystemExit):
structural_check.run(root)
def test_css_without_media_fails_closed(root: Path) -> None:
path = root / structural_check.SOURCE_FILES["css"]
text = path.read_text(encoding="utf-8")
path.write_text(text.replace("@media", "@supports"), encoding="utf-8")
with pytest.raises(SystemExit):
structural_check.run(root)
def test_absent_stack_requires_a_reason() -> None:
with pytest.raises(ValueError, match="why"):
structural_map.Token("space.zz", "--space-zz", None, "x")
@pytest.mark.parametrize(
("kind", "raw", "expected"),
[
("space", "8px", "8"),
("duration", "Duration.zero", "0"),
("duration", "0ms", "0"),
("duration", "Duration(milliseconds: 120)", "120"),
("ease", "cubic-bezier(0.2, 0, 0, 1)", "0.2,0,0,1"),
("ease", "Cubic(0.2, 0, 0, 1)", "0.2,0,0,1"),
("shadow", "0 2px 4px", "0 2px 4px"),
("space", "none", "none"),
],
)
def test_norm(kind: str, raw: str, expected: str) -> None:
assert structural_check.norm(kind, raw) == expected
def test_cli_exit_codes(root: Path) -> None:
script = REAL_ROOT / "unified-design-system/scripts/structural_check.py"
ok = subprocess.run([sys.executable, str(script)], check=False, capture_output=True)
assert ok.returncode == 0
edit(root, "css", "--space-2: 8px", "--space-2: 9px")
scratch = root / "unified-design-system/scripts"
scratch.mkdir(parents=True)
for name in ("structural_check.py", "structural_map.py", "structural_parse.py"):
shutil.copy(script.parent / name, scratch / name)
bad = subprocess.run(
[sys.executable, str(scratch / "structural_check.py")],
check=False,
capture_output=True,
text=True,
)
assert bad.returncode == 1
assert "space.sm" in bad.stdout
assert "DRIFT" in bad.stdout
def test_spacing_line_absent_is_not_invented(root: Path) -> None:
edit(root, "md", "`xs 4 · sm 8", "xs 4 · sm 8")
assert any("MISSING" in f and "space." in f for f in failures(root))