From bfb80cb60d61424e026f291574e161db9c73bb82 Mon Sep 17 00:00:00 2001 From: Bernardo Magri Date: Sat, 4 Jul 2026 20:44:08 +0100 Subject: [PATCH] feat(state): validation + friendly errors on both write and eval paths (item 11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit '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 --- agent/BACKLOG.md | 9 - agent/JOURNAL.md | 22 +++ flake.nix | 47 +++++ modules/home/theme.nix | 59 ++++++- pkgs/nomarchy-doctor/nomarchy-doctor.sh | 2 +- .../nomarchy-theme-sync.py | 166 +++++++++++++++++- 6 files changed, 291 insertions(+), 14 deletions(-) diff --git a/agent/BACKLOG.md b/agent/BACKLOG.md index 00df14e..ea67fd4 100644 --- a/agent/BACKLOG.md +++ b/agent/BACKLOG.md @@ -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` diff --git a/agent/JOURNAL.md b/agent/JOURNAL.md index 911f43e..983473a 100644 --- a/agent/JOURNAL.md +++ b/agent/JOURNAL.md @@ -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- diff --git a/flake.nix b/flake.nix index 925674e..9d06ddb 100644 --- a/flake.nix +++ b/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 diff --git a/modules/home/theme.nix b/modules/home/theme.nix index 4798407..6e154cd 100644 --- a/modules/home/theme.nix +++ b/modules/home/theme.nix @@ -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) diff --git a/pkgs/nomarchy-doctor/nomarchy-doctor.sh b/pkgs/nomarchy-doctor/nomarchy-doctor.sh index c702ff2..78b03ff 100644 --- a/pkgs/nomarchy-doctor/nomarchy-doctor.sh +++ b/pkgs/nomarchy-doctor/nomarchy-doctor.sh @@ -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" diff --git a/pkgs/nomarchy-theme-sync/nomarchy-theme-sync.py b/pkgs/nomarchy-theme-sync/nomarchy-theme-sync.py index 5f048d9..bf8df35 100644 --- a/pkgs/nomarchy-theme-sync/nomarchy-theme-sync.py +++ b/pkgs/nomarchy-theme-sync/nomarchy-theme-sync.py @@ -18,6 +18,7 @@ Commands: apply merge a preset into the state + rebuild set 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")