feat(state): validation + friendly errors on both write and eval paths (item 11)
All checks were successful
Check / eval (push) Successful in 2m56s

'Never has to master Nix' includes the error messages. The tool grows
validate_state (appearance schema = hard errors; settings.* and
unknown keys = warnings so menu writers and newer schemas keep
working), a read-only `validate` subcommand, and validation BEFORE
every write — an invalid set/apply is refused with the on-disk file
untouched. JSON syntax errors now say line, column, and fix. theme.nix
enforces the same schema at eval time (on the post-defaults state, so
sparse/older files still evaluate) and throws a message naming each
field, its got-value, and the fix paths — the hand-edit escape hatch
no longer ends in a Nix stack trace. Doctor's JSON fix-text points at
validate.

V1: new checks.theme-sync-validate fixture corpus green (good state,
trailing comma, bad hex, bad type, unknown key, refused set with
byte-identical file); negative mkFlake eval of a broken scratch
downstream shows the friendly throw; good-path template-home builds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Bernardo Magri
2026-07-04 20:44:08 +01:00
parent 352c681f48
commit bfb80cb60d
6 changed files with 291 additions and 14 deletions

View File

@@ -72,7 +72,7 @@ if [ -f "$flake/theme-state.json" ]; then
ok "theme-state.json parses"
else
bad "theme-state.json is not valid JSON (rebuilds will fail)" \
"jq . $flake/theme-state.json (shows the bad spot; fix it, or re-apply a theme)"
"nomarchy-theme-sync validate (names the spot; fix it, or re-apply a theme)"
fi
if git -C "$flake" ls-files --error-unmatch theme-state.json >/dev/null 2>&1; then
ok "theme-state.json is git-tracked"

View File

@@ -18,6 +18,7 @@ Commands:
apply <name|file.json> merge a preset into the state + rebuild
set <dotted.path> <value> tweak one key (e.g. `set ui.gapsOut 16`) + rebuild
get [dotted.path] print the current state (or one key)
validate check the state file against the schema (read-only)
wallpaper apply the current wallpaper via swww
bg [next|auto] cycle the theme's wallpapers (instant, no rebuild)
"""
@@ -25,6 +26,7 @@ Commands:
import argparse
import json
import os
import re
import shlex
import shutil
import subprocess
@@ -90,11 +92,23 @@ def load_state(path: Path = STATE_FILE) -> dict:
except FileNotFoundError:
die(f"state file not found: {path} (set $NOMARCHY_PATH to your flake checkout)")
except json.JSONDecodeError as e:
die(f"invalid JSON in {path}: {e}")
die(f"theme-state.json has a JSON syntax error at line {e.lineno}, "
f"column {e.colno}: {e.msg}\n"
f" file: {path}\n"
f" fix: correct the syntax by hand (a trailing comma is the "
f"usual culprit) — nothing was changed")
def write_state(state: dict) -> None:
"""Atomic write: render to a temp file in the same dir, then rename."""
"""Atomic write: render to a temp file in the same dir, then rename.
Validates first — an invalid state never reaches disk (the old file
stays untouched), so a bad `set` can't brick the next rebuild."""
errors, warnings = validate_state(state)
for w in warnings:
log(f"warning: {w}")
if errors:
die("refusing to write an invalid state (nothing changed):\n"
+ "\n".join(errors))
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=STATE_FILE.parent, prefix=".theme-state.", suffix=".json")
try:
@@ -159,6 +173,152 @@ def deep_merge(base: dict, override: dict) -> dict:
return out
# ─── Schema validation ─────────────────────────────────────────────────────
# The friendly-errors contract (with theme.nix, which enforces the same
# rules at eval time): a hand-edited state file fails HERE, before it is
# written, with the field, the problem, and the fix — never as a Nix
# stack trace at rebuild. Hard errors cover the appearance schema the
# modules consume; settings.* (menu-writer territory) and unknown keys
# only warn, so custom keys and newer/older schemas keep working.
HEX_RE = re.compile(r"^#[0-9a-fA-F]{6}$")
COLOR_ROLES = ("base", "mantle", "surface", "overlay", "text", "subtext",
"muted", "accent", "accentAlt", "good", "warn", "bad")
TOP_KEYS = {"version", "name", "slug", "mode", "wallpaper", "colors", "ansi",
"fonts", "ui", "icons", "border", "settings"}
UI_UINTS = ("gapsIn", "gapsOut", "borderSize", "rounding", "iconSize")
UI_OPACITIES = ("activeOpacity", "inactiveOpacity", "terminalOpacity")
UI_BOOLS = ("blur", "shadow")
def _is_hex(v) -> bool:
return isinstance(v, str) and bool(HEX_RE.match(v))
def _is_num(v) -> bool:
return isinstance(v, (int, float)) and not isinstance(v, bool)
def validate_state(state) -> tuple:
"""Return (errors, warnings) — friendly strings, one problem each."""
errors, warnings = [], []
def err(field, problem, fix):
errors.append(f"{field}: {problem}\n fix: {fix}")
if not isinstance(state, dict):
err("top level", "must be a JSON object",
"re-apply a preset: nomarchy-theme-sync apply tokyo-night")
return errors, warnings
for k in state:
if k not in TOP_KEYS:
warnings.append(f"unknown top-level key '{k}' — a typo? (it is ignored)")
for field, want in (("name", "the theme's display name"),
("slug", "the theme's directory name"),
("wallpaper", "a filename in the theme's backgrounds/ (or \"\")"),
("icons", "an icon theme name (or \"\" for pick-by-mode)")):
v = state.get(field)
if v is not None and not isinstance(v, str):
err(field, f"must be a string ({want}), got {v!r}", 'quote it, e.g. "tokyo-night"')
mode = state.get("mode")
if mode is not None and mode not in ("dark", "light"):
err("mode", f'must be "dark" or "light", got {mode!r}', 'set it to "dark" or "light"')
colors = state.get("colors", {})
if not isinstance(colors, dict):
err("colors", "must be an object of palette roles",
"re-apply a preset to restore the palette")
else:
for k, v in colors.items():
if k not in COLOR_ROLES:
warnings.append(f"colors.{k}: not a Nomarchy palette role (it is ignored)")
elif not _is_hex(v):
err(f"colors.{k}", f'must be "#RRGGBB", got {v!r}',
'use a 6-digit hex color like "#7aa2f7"')
ansi = state.get("ansi")
if ansi is not None and not (isinstance(ansi, list) and len(ansi) == 16
and all(_is_hex(c) for c in ansi)):
err("ansi", 'must be a list of exactly 16 "#RRGGBB" strings',
"re-apply a preset to restore the terminal palette")
fonts = state.get("fonts", {})
if isinstance(fonts, dict):
for k in ("mono", "ui"):
v = fonts.get(k)
if v is not None and not isinstance(v, str):
err(f"fonts.{k}", f"must be a font family name (string), got {v!r}",
'quote it, e.g. "JetBrainsMono Nerd Font"')
size = fonts.get("size")
if size is not None and not (_is_num(size) and size > 0):
err("fonts.size", f"must be a positive number (points), got {size!r}",
"use a plain number like 11 (no quotes)")
else:
err("fonts", "must be an object", 're-add: {"mono": "...", "ui": "...", "size": 11}')
ui = state.get("ui", {})
if isinstance(ui, dict):
for k in UI_UINTS:
v = ui.get(k)
if v is not None and not (isinstance(v, int) and not isinstance(v, bool) and v >= 0):
err(f"ui.{k}", f"must be a non-negative whole number (pixels), got {v!r}",
"use a plain number like 12 (no quotes)")
for k in UI_OPACITIES:
v = ui.get(k)
if v is not None and not (_is_num(v) and 0 <= v <= 1):
err(f"ui.{k}", f"must be a number between 0 and 1, got {v!r}",
"use e.g. 0.95 (no quotes)")
for k in UI_BOOLS:
v = ui.get(k)
if v is not None and not isinstance(v, bool):
err(f"ui.{k}", f"must be true or false, got {v!r}",
"use true or false (no quotes)")
else:
err("ui", "must be an object", "re-apply a preset to restore the UI block")
border = state.get("border")
if border is not None:
if not isinstance(border, dict):
err("border", "must be an object", 're-add: {"active": "accent", "inactive": "overlay"}')
else:
for k in ("active", "inactive"):
v = border.get(k)
if v is not None and not (isinstance(v, str)
and (v in COLOR_ROLES or _is_hex(v))):
err(f"border.{k}",
f'must be a palette role ({", ".join(COLOR_ROLES)}) or "#RRGGBB", got {v!r}',
'use e.g. "accent" or "#7aa2f7"')
settings = state.get("settings")
if settings is not None and not isinstance(settings, dict):
err("settings", "must be an object (the menu writes feature flags here)",
're-add: "settings": {}')
slug = state.get("slug")
if isinstance(slug, str) and slug and find_preset(slug) is None:
warnings.append(f"slug '{slug}' matches no shipped/custom preset — "
f"fine for a hand-rolled theme, but per-theme assets won't resolve")
return errors, warnings
def cmd_validate(_args) -> None:
state = load_state() # dies with the friendly syntax error on bad JSON
errors, warnings = validate_state(state)
for w in warnings:
print(f"{w}")
if errors:
print(f"nomarchy-theme-sync: {STATE_FILE} has "
f"{len(errors)} problem(s):", file=sys.stderr)
for e in errors:
print(f"{e}", file=sys.stderr)
sys.exit(1)
log(f"{STATE_FILE} is valid ({len(warnings)} warning(s))")
# ─── Font verification ────────────────────────────────────────────────────
def check_fonts(state: dict) -> None:
@@ -419,6 +579,8 @@ def main() -> None:
p.add_argument("path", nargs="?")
p.set_defaults(func=cmd_get)
sub.add_parser("validate", help="check theme-state.json against the schema (read-only)").set_defaults(func=cmd_validate)
sub.add_parser("wallpaper", help="apply the current wallpaper via swww").set_defaults(func=cmd_wallpaper)
p = sub.add_parser("bg", help="wallpaper control: `bg next` cycles, `bg auto` resets")