mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 19:07:17 +01:00
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:
parent
82119bdd47
commit
cbcee98d94
5
.github/workflows/palette-drift.yml
vendored
5
.github/workflows/palette-drift.yml
vendored
@ -31,3 +31,8 @@ jobs:
|
|||||||
|
|
||||||
- name: Categorical ramp still passes its constraints
|
- name: Categorical ramp still passes its constraints
|
||||||
run: python3 unified-design-system/scripts/ramp_check.py
|
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
|
||||||
|
|||||||
@ -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
|
|
||||||
@ -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
|
|
||||||
@ -156,7 +156,7 @@ PALETTE: tuple[Token, ...] = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Tokens a source legitimately defines that are not colours in the palette --
|
# 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(
|
NON_COLOUR_CSS = re.compile(
|
||||||
r"^--(radius|space|text|tracking|font|measure|shadow)-|^--(font-sans|font-mono|measure)$"
|
r"^--(radius|space|text|tracking|font|measure|shadow)-|^--(font-sans|font-mono|measure)$"
|
||||||
)
|
)
|
||||||
|
|||||||
138
unified-design-system/scripts/structural_check.py
Normal file
138
unified-design-system/scripts/structural_check.py
Normal 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())
|
||||||
87
unified-design-system/scripts/structural_map.py
Normal file
87
unified-design-system/scripts/structural_map.py
Normal 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.",
|
||||||
|
},
|
||||||
|
}
|
||||||
110
unified-design-system/scripts/structural_parse.py
Normal file
110
unified-design-system/scripts/structural_parse.py
Normal 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
|
||||||
182
unified-design-system/tests/test_structural_check.py
Normal file
182
unified-design-system/tests/test_structural_check.py
Normal 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))
|
||||||
Loading…
Reference in New Issue
Block a user