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

@@ -26,15 +26,6 @@ next, in what order*.
## NEXT
### 11. State-file validation & friendly errors
New; "the user never has to master Nix" must include *error messages*.
A hand-edited theme-state.json (trailing comma, wrong type, unknown
theme slug) today surfaces as a raw Nix eval stack. Add (a) `nomarchy-
theme-sync validate` + validate-before-write on every set/apply; (b) an
eval-time schema assertion in `theme.nix` whose message says the field,
the problem, and the fix in plain language. **Verify:** V1 + a corpus of
broken-state fixtures round-tripped through validate; V2 if cheap.
### 12. Screen recording in the Capture submenu
New; screenshots ship, recording doesn't — a standard workstation need.
Extend `nomarchy-menu capture`: record region/full → `wl-screenrec`

View File

@@ -17,6 +17,28 @@ Template:
---
## 2026-07-04 — state-file validation & friendly errors (iteration #21, item 11)
- **Task:** BACKLOG NEXT#11 — hand-edited state must fail with field +
problem + fix, never a Nix stack.
- **Did:** (a) tool: validate_state() (appearance schema = hard errors;
settings.*/unknown keys = warnings), `validate` subcommand,
validate-BEFORE-write in write_state (invalid set/apply never touches
disk), JSON syntax errors now say line/column/fix. (b) theme.nix:
same schema at eval time on `parsed` (post-defaults, so sparse files
stay fine); throws a multi-line message naming each field, the got-
value, and the three fix paths. Doctor's JSON fix-text now points at
`validate`.
- **Verified:** V0; V1 — new checks.theme-sync-validate GREEN: good
template passes, trailing comma → line/col message, bad hex/type →
field named, unknown key → warning only, invalid `set` refused with
the file byte-identical (cmp). Negative eval of a broken scratch
downstream through mkFlake shows the friendly throw (both fields);
good-path template-home still builds.
- **Pending:** nothing — no hardware dependency.
- **Next suggestion:** NEXT#12 (screen recording) or #13 niceties
slice; #12 first (it reshapes the Capture submenu the OCR LATER item
waits on).
## 2026-07-04 — nomarchy-doctor (iteration #20, item 10)
- **Task:** BACKLOG NEXT#10 — one-shot read-only health check.
- **Did:** pkgs/nomarchy-doctor (writeShellApplication → shellcheck-

View File

@@ -184,6 +184,53 @@
touch $out
'';
# The state-file friendly-errors contract (item 11): validate
# reports broken fixtures by field with a fix, good state
# passes, and an invalid `set` is refused BEFORE the write —
# the on-disk file must be byte-identical after the attempt.
theme-sync-validate = pkgs.runCommand "nomarchy-theme-sync-validate"
{ nativeBuildInputs = [ pkgs.python3 pkgs.jq ]; }
''
set -eu
tool=${./pkgs/nomarchy-theme-sync/nomarchy-theme-sync.py}
export NOMARCHY_DEFAULT_THEMES=${./themes}
mkdir good && cp ${./templates/downstream/theme-state.json} good/theme-state.json
chmod +w good/theme-state.json
NOMARCHY_PATH=$PWD/good python3 "$tool" validate
mkdir syntax && printf '{ "slug": "x", }' > syntax/theme-state.json
if NOMARCHY_PATH=$PWD/syntax python3 "$tool" validate 2>err; then
echo "FAIL: syntax fixture accepted"; exit 1; fi
grep -q "syntax error at line" err
mkdir badhex && jq '.colors.accent = "7aa2f7"' \
good/theme-state.json > badhex/theme-state.json
if NOMARCHY_PATH=$PWD/badhex python3 "$tool" validate 2>err; then
echo "FAIL: bad hex accepted"; exit 1; fi
grep -q "colors.accent" err && grep -q "fix:" err
mkdir badtype && jq '.ui.gapsOut = "12"' \
good/theme-state.json > badtype/theme-state.json
if NOMARCHY_PATH=$PWD/badtype python3 "$tool" validate 2>err; then
echo "FAIL: bad type accepted"; exit 1; fi
grep -q "ui.gapsOut" err
mkdir unknown && jq '.extraKey = 1' \
good/theme-state.json > unknown/theme-state.json
NOMARCHY_PATH=$PWD/unknown python3 "$tool" validate \
| grep -q "unknown top-level key"
cp good/theme-state.json before.json
if NOMARCHY_PATH=$PWD/good python3 "$tool" --quiet \
set ui.gapsOut '"12"' --no-switch 2>err; then
echo "FAIL: invalid set was written"; exit 1; fi
grep -q "refusing to write" err
cmp good/theme-state.json before.json
touch $out
'';
# nomarchy-doctor's contract: an induced failed unit flips the
# sheet to ✖/exit-1 and names the unit; with the failure
# cleared it reports healthy/exit-0. Minimal node (just the

View File

@@ -79,6 +79,61 @@ let
parsed = lib.recursiveUpdate defaults themeState;
# ── Friendly eval-time validation ───────────────────────────────────
# The same schema nomarchy-theme-sync enforces before every write. A
# HAND-edited state file (the one path that bypasses the tool) must
# fail with the field, the problem, and the fix — not a Nix stack
# trace deep in some consumer. Checks run on `parsed` (after the
# defaults), so missing fields are fine; only wrong values throw.
isHex = v: builtins.isString v && builtins.match "#[0-9a-fA-F]{6}" v != null;
isNum = v: builtins.isInt v || builtins.isFloat v;
colorRoles = [ "base" "mantle" "surface" "overlay" "text" "subtext"
"muted" "accent" "accentAlt" "good" "warn" "bad" ];
got = v: "got: ${builtins.toJSON v}";
problems = lib.concatLists [
(map (k: "colors.${k} must be \"#RRGGBB\" (${got (parsed.colors.${k} or null)})")
(builtins.filter (k: !isHex (parsed.colors.${k} or null)) colorRoles))
(lib.optional (!(parsed.mode == "dark" || parsed.mode == "light"))
"mode must be \"dark\" or \"light\" (${got parsed.mode})")
(map (k: "ui.${k} must be a non-negative whole number (${got (parsed.ui.${k} or null)})")
(builtins.filter
(k: let v = parsed.ui.${k} or null; in !(builtins.isInt v && v >= 0))
[ "gapsIn" "gapsOut" "borderSize" "rounding" "iconSize" ]))
(map (k: "ui.${k} must be a number between 0 and 1 (${got (parsed.ui.${k} or null)})")
(builtins.filter
(k: let v = parsed.ui.${k} or null; in !(isNum v && v >= 0 && v <= 1))
[ "activeOpacity" "inactiveOpacity" "terminalOpacity" ]))
(map (k: "ui.${k} must be true or false (${got (parsed.ui.${k} or null)})")
(builtins.filter (k: !builtins.isBool (parsed.ui.${k} or null))
[ "blur" "shadow" ]))
(map (k: "fonts.${k} must be a font-family string (${got (parsed.fonts.${k} or null)})")
(builtins.filter (k: !builtins.isString (parsed.fonts.${k} or null))
[ "mono" "ui" ]))
(lib.optional (!(isNum (parsed.fonts.size or null) && parsed.fonts.size > 0))
"fonts.size must be a positive number (${got (parsed.fonts.size or null)})")
(lib.optional (!(builtins.isList parsed.ansi
&& builtins.length parsed.ansi == 16
&& lib.all isHex parsed.ansi))
"ansi must be a list of exactly 16 \"#RRGGBB\" strings")
(map (k: "border.${k} must be a palette role or \"#RRGGBB\" (${got (parsed.border.${k} or null)})")
(builtins.filter
(k: let v = parsed.border.${k} or null;
in !(builtins.isString v && (builtins.elem v colorRoles || isHex v)))
[ "active" "inactive" ]))
];
checked =
if problems == [ ] then parsed
else throw ''
Nomarchy: your theme-state.json is invalid:
${lib.concatMapStrings (p: " ${p}\n") problems}
Fix the named field(s) in the theme-state.json of your flake
checkout (usually ~/.nomarchy/theme-state.json the store copy at
${toString cfg.stateFile} is a snapshot of it). The tool prints
the same report with per-field fixes: `nomarchy-theme-sync
validate`. Re-applying any preset resets all appearance fields:
`nomarchy-theme-sync apply tokyo-night`.'';
# A border value is a palette key (look it up in colors) unless it's
# already a literal hex; unknown keys fall through to the raw string.
resolveColor = v: if lib.hasPrefix "#" v then v else parsed.colors.${v} or v;
@@ -97,12 +152,12 @@ let
in
{
config = {
nomarchy.theme = parsed // { inherit iconTheme border; };
nomarchy.theme = checked // { inherit iconTheme border; };
# Feature toggles the menu writes (settings.nightlight.enable, …), exposed
# alongside the appearance state. Feature modules mkDefault-read from here
# so a menu toggle lands in the flake instead of in ~/.local/state.
nomarchy.settings = parsed.settings;
nomarchy.settings = checked.settings;
nomarchy.lib = {
# "#7aa2f7" -> "rgb(7aa2f7)" (Hyprland color syntax)

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")