feat(state): validation + friendly errors on both write and eval paths (item 11)
All checks were successful
Check / eval (push) Successful in 2m56s
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:
@@ -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`
|
||||
|
||||
@@ -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-
|
||||
|
||||
47
flake.nix
47
flake.nix
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user