From e200c1eaa18cf22bbb0b78f6fa9b1b53bf26ee61 Mon Sep 17 00:00:00 2001 From: DreamCoder08 Date: Tue, 29 Sep 2026 12:00:04 -0500 Subject: [PATCH 01/10] fix(ml4w): give the profile super+shift+arrows and move keyboard resize to super+ctrl+arrows The variant resized and the profile moved windows on the same combos, so both fired. The profile documents that it owns move-window, so the variant's resize moves to the free SUPER+CTRL+arrows. The collision test now allows no exceptions and pins the resize binds. --- docs/configuration/ml4w.md | 5 +++-- .../hypr/conf/keybindings/dreamcoder.lua | 12 +++++------ tests/ml4w/keybindings_variant.bats | 21 +++++++++---------- 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/docs/configuration/ml4w.md b/docs/configuration/ml4w.md index e5d30f36..cf27a2e3 100644 --- a/docs/configuration/ml4w.md +++ b/docs/configuration/ml4w.md @@ -89,8 +89,9 @@ The ML4W 2.16 delta is ported as follows: | Rewritten AZERTY detection (`fr`, `be`) | Ported; binds the AZERTY keysyms only on AZERTY layouts, since the profile owns the digit workspace binds | `tests/ml4w/keybindings_variant.bats` fails on any new collision between the -variant and the profile. The `SUPER + SHIFT + arrows` overlap (variant resize, -profile move) predates 2.16 and is listed there as a known exception. +variant and the profile; there are no exceptions. The profile owns +`SUPER + SHIFT + arrows` (move window), so the variant's keyboard resize sits on +`SUPER + CTRL + arrows` instead of ML4W's upstream `SUPER + SHIFT + arrows`. ## hyprctl dispatch is broken on Hyprland 0.55+ — native dispatchers used diff --git a/ml4w_assets/hypr/conf/keybindings/dreamcoder.lua b/ml4w_assets/hypr/conf/keybindings/dreamcoder.lua index 3b270434..671f439d 100644 --- a/ml4w_assets/hypr/conf/keybindings/dreamcoder.lua +++ b/ml4w_assets/hypr/conf/keybindings/dreamcoder.lua @@ -17,17 +17,17 @@ local mainMod = "SUPER" -- Sets "Windows" key as main modifier -- Only the emoji picker stays here. hl.bind(mainMod .. " + CTRL + E", hl.dsp.exec_cmd("~/.config/ml4w/settings/emojipicker.sh"), { description = "Open the emoji picker" }) --- Windows -- Profile owns: Q (kill), F (fullscreen), T (float), H/L/K/J + arrows (focus), --- SHIFT+H/L/K/J + arrows (move window), Y (toggle split). +-- SHIFT+H/L/K/J + arrows (move window), Y (toggle split). Keyboard resize sits on +-- CTRL + arrows here (upstream ML4W uses SHIFT + arrows, which the profile owns). hl.bind(mainMod .. " + SHIFT + Q", hl.dsp.exec_cmd("hyprctl activewindow | grep pid | tr -d 'pid:' | xargs kill"), { description = "Quit active window and all open instances" }) hl.bind(mainMod .. " + M", hl.dsp.window.fullscreen({ mode = "maximized", action = "toggle" }), { description = "Toggle Maximize Window" }) hl.bind(mainMod .. " + SHIFT + T", hl.dsp.exec_cmd("~/.config/ml4w/scripts/ml4w-toggle-allfloat"), { description = "Toggle floating for all windows of workspace" }) hl.bind(mainMod .. " + ALT + T", function() hl.dispatch(hl.dsp.window.float({ action = "toggle" })); hl.dispatch(hl.dsp.window.pin()) end, { description = "Toggle floating + pinned" }) -hl.bind(mainMod .. " + SHIFT + right", hl.dsp.window.resize({ x = 100, y = 0, relative = true }), { repeating = true }, { description = "Increase window width with keyboard" }) -hl.bind(mainMod .. " + SHIFT + left", hl.dsp.window.resize({ x = -100, y = 0, relative = true }), { repeating = true }, { description = "Reduce window width with keyboard" }) -hl.bind(mainMod .. " + SHIFT + down", hl.dsp.window.resize({ x = 0, y = 100, relative = true }), { repeating = true }, { description = "Increase window height with keyboard" }) -hl.bind(mainMod .. " + SHIFT + up", hl.dsp.window.resize({ x = 0, y = -100, relative = true }), { repeating = true }, { description = "Reduce window height with keyboard" }) +hl.bind(mainMod .. " + CTRL + right", hl.dsp.window.resize({ x = 100, y = 0, relative = true }), { repeating = true }, { description = "Increase window width with keyboard" }) +hl.bind(mainMod .. " + CTRL + left", hl.dsp.window.resize({ x = -100, y = 0, relative = true }), { repeating = true }, { description = "Reduce window width with keyboard" }) +hl.bind(mainMod .. " + CTRL + down", hl.dsp.window.resize({ x = 0, y = 100, relative = true }), { repeating = true }, { description = "Increase window height with keyboard" }) +hl.bind(mainMod .. " + CTRL + up", hl.dsp.window.resize({ x = 0, y = -100, relative = true }), { repeating = true }, { description = "Reduce window height with keyboard" }) hl.bind(mainMod .. " + G", hl.dsp.group.toggle(), { description = "Toggle window group" }) -- hl.bind(mainMod .. " + SHIFT + G", hl.dsp.group.active("f"), { description = "Switch to next group window" }) hl.bind(mainMod .. " + ALT + left", hl.dsp.window.swap({ direction = "l" }), { description = "Swap tiled window left" }) diff --git a/tests/ml4w/keybindings_variant.bats b/tests/ml4w/keybindings_variant.bats index e8f5feee..466e241d 100644 --- a/tests/ml4w/keybindings_variant.bats +++ b/tests/ml4w/keybindings_variant.bats @@ -30,14 +30,6 @@ profile() { printf '%s' "${DREAMCODER_DOTS_DIR}/DreamcoderProfiles/dreamcoder/as [ "$status" -eq 0 ] } -# Pre-existing overlap, not introduced by the 2.16 port: the variant resizes -# with SUPER + SHIFT + arrows (upstream default.lua) while the profile moves -# windows on the same combos. Resolving it is a product decision; listed here -# so any NEW collision still fails. -KNOWN_OVERLAPS='SUPER+SHIFT+DOWN -SUPER+SHIFT+LEFT -SUPER+SHIFT+RIGHT -SUPER+SHIFT+UP' @test "keybindings variant: no mainMod bind collides with the profile" { command -v jq >/dev/null || skip "jq not installed" @@ -49,9 +41,16 @@ SUPER+SHIFT+UP' grep -oE 'hl\.bind\(mainMod \.\. " \+ [^"]+"' "$(variant)" \ | sed -E 's/.*" \+ ([^"]+)"/SUPER + \1/; s/ \+ /+/g' | tr '[:lower:]' '[:upper:]' \ | sort -u >"${BATS_TEST_TMPDIR}/variant" - printf '%s\n' "${KNOWN_OVERLAPS}" | sort -u >"${BATS_TEST_TMPDIR}/known" - comm -12 "${BATS_TEST_TMPDIR}/profile" "${BATS_TEST_TMPDIR}/variant" >"${BATS_TEST_TMPDIR}/overlap" - run comm -23 "${BATS_TEST_TMPDIR}/overlap" "${BATS_TEST_TMPDIR}/known" + run comm -12 "${BATS_TEST_TMPDIR}/profile" "${BATS_TEST_TMPDIR}/variant" [ "$status" -eq 0 ] [ -z "$output" ] } + +@test "keybindings variant: keyboard resize uses SUPER + CTRL + arrows, leaving SUPER + SHIFT + arrows to the profile's move-window" { + for dir in right left down up; do + run grep -F "mainMod .. \" + CTRL + ${dir}\", hl.dsp.window.resize(" "$(variant)" + [ "$status" -eq 0 ] + run grep -F "mainMod .. \" + SHIFT + ${dir}\"" "$(variant)" + [ "$status" -ne 0 ] + done +} From 9636168563dc77baf3a584c0a9ca30f16048d11c Mon Sep 17 00:00:00 2001 From: DreamCoder08 Date: Tue, 29 Sep 2026 12:01:02 -0500 Subject: [PATCH 02/10] ci: ratchet the coverage gate from 40% to 80% Measured coverage is 83%; a floor of 40% let a 40-point regression through. Update pyproject, the CI job and ADR-0003 together. --- .github/workflows/theme-validation.yml | 2 +- docs/adr/0003-python-quality-strategy.md | 6 +++--- pyproject.toml | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/theme-validation.yml b/.github/workflows/theme-validation.yml index 7220e250..d04271da 100644 --- a/.github/workflows/theme-validation.yml +++ b/.github/workflows/theme-validation.yml @@ -40,7 +40,7 @@ jobs: run: python -m pytest tests/ -v --tb=short - name: Coverage - run: python -m pytest tests/ --cov=dreamcoder_theme --cov-fail-under=40 + run: python -m pytest tests/ --cov=dreamcoder_theme --cov-fail-under=80 - name: Run theme health check env: diff --git a/docs/adr/0003-python-quality-strategy.md b/docs/adr/0003-python-quality-strategy.md index 034b7d48..894ed44f 100644 --- a/docs/adr/0003-python-quality-strategy.md +++ b/docs/adr/0003-python-quality-strategy.md @@ -33,7 +33,7 @@ We adopt a three-layer quality strategy for all Python code: ### Layer 3 — Coverage (pytest-cov) - **Metric**: Branch coverage via `pytest --cov=dreamcoder_theme` -- **Threshold**: `fail_under = 40` (baseline; expected to increase over time) +- **Threshold**: `fail_under = 80` (ratcheted from the original 40 baseline on 2026-09-29, when measured coverage was 83%; raise it again only when coverage rises — never lower it) - **Reporting**: `--cov-report=term-missing` shows uncovered lines All three layers run in CI on every push and pull request. @@ -52,7 +52,7 @@ Positive: Negative: - Mypy strict mode requires explicit annotations throughout the codebase -- Coverage fail-under of 40 is low — new code should aim for 80%+ +- The 80% floor sits just under the measured level (83%), so a large untested addition fails CI; raising it is a deliberate follow-up - Initial migration required fixing pre-existing issues ## Compliance @@ -60,7 +60,7 @@ Negative: - `ruff check src/ tests/` must pass - `ruff format --check src/ tests/` must pass - `mypy src/` must pass (strict mode) -- `pytest --cov=dreamcoder_theme --cov-fail-under=40` must pass +- `pytest --cov=dreamcoder_theme --cov-fail-under=80` must pass ## Alternatives Considered diff --git a/pyproject.toml b/pyproject.toml index 93abca65..dabd12e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -111,7 +111,7 @@ relative_files = true [tool.coverage.report] show_missing = true -fail_under = 40 +fail_under = 80 [dependency-groups] dev = ["mypy>=2.1.0"] From cbd077123d271549214df9ce413f17765f9fda4a Mon Sep 17 00:00:00 2001 From: DreamCoder08 Date: Tue, 29 Sep 2026 12:02:41 -0500 Subject: [PATCH 03/10] fix(shell): make extract() default its output directory in bash A single local statement expands dir before file is assigned in bash (SC2318), so extract archive.zip used an empty directory. Split the assignments and cover the default, explicit and unknown-type cases. --- .../shell/aliases/dreamcoder-modern.sh | 5 +- .../shell/test_dreamcoder_modern_extract.bats | 43 +++++++ tests/test_design_system_contract.py | 57 ++++++++++ tests/test_palette_dual_gate.py | 107 ++++++++++++++++++ 4 files changed, 211 insertions(+), 1 deletion(-) create mode 100644 tests/shell/test_dreamcoder_modern_extract.bats diff --git a/DreamcoderShell/.config/shell/aliases/dreamcoder-modern.sh b/DreamcoderShell/.config/shell/aliases/dreamcoder-modern.sh index f83547fc..e1e38927 100644 --- a/DreamcoderShell/.config/shell/aliases/dreamcoder-modern.sh +++ b/DreamcoderShell/.config/shell/aliases/dreamcoder-modern.sh @@ -64,7 +64,10 @@ extract() { echo "Usage: extract [output_dir]" return 1 fi - local file="$1" dir="${2:-${file%.*}}" + # Two statements: in bash a single `local` expands every value before any is + # assigned, so dir would not see file (ShellCheck SC2318). + local file="$1" + local dir="${2:-${file%.*}}" case "$file" in *.tar.gz | *.tgz) tar -xzf "$file" -C "$(dirname "$file")" 2>/dev/null || tar -xzf "$file" ;; *.tar.bz2 | *.tbz2) tar -xjf "$file" ;; diff --git a/tests/shell/test_dreamcoder_modern_extract.bats b/tests/shell/test_dreamcoder_modern_extract.bats new file mode 100644 index 00000000..55e3cb5b --- /dev/null +++ b/tests/shell/test_dreamcoder_modern_extract.bats @@ -0,0 +1,43 @@ +#!/usr/bin/env bats +# ============================================================================ +# extract() in dreamcoder-modern.sh is sourced by bash and zsh. In bash, a +# single `local a=... b=${a...}` expands b BEFORE a is assigned (ShellCheck +# SC2318), which left the default output directory empty. +# ============================================================================ + +setup() { + WORK="$(mktemp -d)" + cd "${WORK}" + python3 - <<'PY' +import zipfile +with zipfile.ZipFile("sample.zip", "w") as z: + z.writestr("hello.txt", "hi") +PY +} + +teardown() { + cd / + rm -rf "${WORK}" +} + +extract_in_bash() { + bash -c "source '${BATS_TEST_DIRNAME}/../../DreamcoderShell/.config/shell/aliases/dreamcoder-modern.sh' >/dev/null 2>&1; extract $*" +} + +@test "extract defaults the output directory to the archive name without extension" { + run extract_in_bash sample.zip + [ "$status" -eq 0 ] + [ -f "${WORK}/sample/hello.txt" ] +} + +@test "extract honours an explicit output directory for zip archives" { + run extract_in_bash sample.zip custom + [ "$status" -eq 0 ] + [ -f "${WORK}/custom/hello.txt" ] +} + +@test "extract rejects an unknown archive type" { + : > notes.unknown + run extract_in_bash notes.unknown + [ "$status" -eq 1 ] +} diff --git a/tests/test_design_system_contract.py b/tests/test_design_system_contract.py index 20d8e463..3567bfdd 100644 --- a/tests/test_design_system_contract.py +++ b/tests/test_design_system_contract.py @@ -6,6 +6,7 @@ import pytest from dreamcoder_theme.design_system import ( + _parse_renderer_output, evaluate_contract, load_contract, load_tokens, @@ -184,3 +185,59 @@ def test_matrix_requires_declared_target_roles_and_orders_findings(contract, tok finding.artifact or "", ), ) + + +def _summaries(findings): + return [(f.code, f.target, f.mode, f.role, f.message) for f in findings] + + +def test_missing_canonical_mode_is_reported_once_and_skips_rendering(contract, tokens): + broken = copy.deepcopy(tokens) + del broken["modes"]["dusk"] + + assert _summaries(evaluate_contract(contract, broken)) == [ + ("MISSING_MODE", None, "dusk", None, "canonical mode 'dusk' is missing"), + ] + + +def test_unresolvable_role_source_is_reported_per_mode(contract, tokens): + broken = copy.deepcopy(contract) + broken["roles"]["panel"]["source"] = "modes.{mode}.nope" + + assert _summaries(evaluate_contract(broken, tokens)) == [ + ( + "ROLE_RESOLUTION", + None, + mode, + "panel", + f"missing canonical role source: modes.{mode}.nope", + ) + for mode in ("dark", "dusk", "light") + ] + + +def test_unknown_renderer_is_a_render_failure_per_mode(contract, tokens): + broken = copy.deepcopy(contract) + broken["targets"]["kitty"]["renderer"] = "nope" + + assert _summaries(evaluate_contract(broken, tokens)) == [ + ("RENDER_FAILURE", "kitty", mode, None, "unknown renderer callable: nope") + for mode in ("dark", "dusk", "light") + ] + + +def test_mapping_to_unknown_role_is_invalid_provenance(contract, tokens): + broken = copy.deepcopy(contract) + broken["targets"]["kitty"]["mappings"]["text"] = "missing-role" + + assert _summaries(evaluate_contract(broken, tokens)) == [ + ("SEMANTIC_PROVENANCE_INVALID", "kitty", mode, "text", "unknown role: missing-role") + for mode in ("dark", "dusk", "light") + ] + + +def test_renderer_output_parser_rejects_invalid_json_and_unknown_targets(): + with pytest.raises(ValueError, match="renderer 'opencode' produced invalid JSON"): + _parse_renderer_output("opencode", "{not json") + with pytest.raises(ValueError, match="no adapter for target: nope"): + _parse_renderer_output("nope", "") diff --git a/tests/test_palette_dual_gate.py b/tests/test_palette_dual_gate.py index 30624388..4e937dce 100644 --- a/tests/test_palette_dual_gate.py +++ b/tests/test_palette_dual_gate.py @@ -164,3 +164,110 @@ def test_missing_declared_pair_token_is_reported(): pal.pop("text_heading") errors = validate_palette(pal, _guardrails(), mode="dark") assert any("missing token: text_heading (declared heading pair)" in e for e in errors) + + +# -- Characterization: exact error list and order --------------------------- +# Frozen snapshot of the canonical dark palette so the golden list below stays +# independent of future token edits; it pins every validate_palette branch and +# the exact order in which errors are emitted. +_FROZEN_DARK = { + "bg": "#000000", + "bg_soft": "#0B0B0B", + "surface0": "#0B0B0B", + "surface1": "#0D0D0F", + "surface2": "#1F1F1F", + "surface3": "#2E2E2E", + "text": "#E6E6E6", + "text_heading": "#F5F5F5", + "muted": "#C7C7C7", + "subtle": "#A7A7A7", + "comment": "#D0D0D0", + "border": "#6B6B6B", + "border_ui": "#767676", + "border_hi": "#A7A7A7", + "focus": "#3B82F6", + "accent": "#A5B4FC", + "accent_2": "#D4B5FD", + "diagnostic": "#7DD3FC", + "selection": "#3A3A3A", + "details": "darker", + "prompt_bg": "#000000", + "prompt_surface0": "#0B0B0B", + "prompt_surface1": "#0D0D0F", + "prompt_surface2": "#1F1F1F", + "prompt_text": "#E6E6E6", + "prompt_muted": "#C7C7C7", + "prompt_accent": "#A5B4FC", + "prompt_accent_2": "#D4B5FD", + "sage": "#34D399", + "lavender": "#D4B5FD", + "mauve": "#D8B4FE", + "error": "#FB8585", + "warning": "#FBBF24", + "success": "#34D399", + "info": "#7DD3FC", + "selection_bg": "#3A3A3A", + "selection_fg": "#E6E6E6", + "on_surface": "#E6E6E6", + "on_accent": "#000000", + "on_error": "#000000", + "on_focus": "#000000", + "link": "#A5B4FC", + "link_hover": "#D4B5FD", + "disabled": "#A7A7A7", + "hover": "#3A3A3A", + "pressed": "#1F1F1F", +} + + +def test_error_list_and_order_are_stable_across_every_rule_family(): + pal = dict(_FROZEN_DARK) + pal.pop("diagnostic") + pal["text"] = "#8a8a8a" + pal["selection_fg"] = pal["selection_bg"] + pal["on_accent"] = pal["accent"] + pal["surface1"] = pal["bg"] + pal["subtle"] = pal["comment"] + pal["accent_2"] = pal["accent"] + guardrails = dict(_guardrails(), minimum_terminal_ansi_contrast=12.0) + + errors = validate_palette(pal, guardrails, mode="dark") + + ansi = "WCAG fail: mode=dark pair=ansi{}/bg measured={} guardrail=minimum_terminal_ansi_contrast=12.0" + assert errors == [ + "missing token: diagnostic", + "WCAG fail: mode=dark pair=text/bg measured=6.08 guardrail=preferred_main_text_contrast=7.0", + "WCAG fail: mode=dark pair=selection_fg/selection_bg measured=1.00 " + "guardrail=minimum_terminal_selection_contrast=7.0", + "WCAG fail: mode=dark pair=on_accent/accent measured=1.00 guardrail=minimum_text_contrast=4.5", + ansi.format(0, "4.82"), + ansi.format(1, "8.80"), + ansi.format(2, "10.92"), + ansi.format(5, "11.88"), + ansi.format(6, "11.83"), + ansi.format(9, "8.15"), + ansi.format(10, "9.76"), + ansi.format(11, "11.06"), + ansi.format(12, "11.13"), + ansi.format(13, "10.59"), + ansi.format(14, "5.64"), + ansi.format(15, "6.08"), + "APCA fail: mode=dark pair=text/bg class=body measured=39.6 " + "guardrail=minimum_apca_body_dark=50", + "missing token: diagnostic (declared body pair)", + "APCA fail: mode=dark pair=on_accent/accent class=on-accent measured=16.0 " + "guardrail=minimum_apca_on_accent=60", + "WCAG fail: mode=dark pair=on_accent/accent measured=1.00 guardrail=minimum_text_contrast=4.5", + "surface1 too close to bg", + "comment and subtle must differ", + "accent and accent_2 must differ", + ] + + +def test_light_mode_without_surface3_is_reported_last(): + pal = _clean_palette("light") + pal.pop("surface3") + + errors = validate_palette(pal, _guardrails()) + + assert errors[-1] == "light mode missing surface3" From 32b7f44532ee8c3186f309feb49292f4e7ee91d6 Mon Sep 17 00:00:00 2001 From: DreamCoder08 Date: Tue, 29 Sep 2026 12:03:51 -0500 Subject: [PATCH 04/10] ci: shellcheck every repo-owned shell file, not only scripts/ CI and pre-commit skipped lib/ and DreamcoderShell/, which is how the extract() SC2318 bug shipped. Third-party skill scripts stay out of scope. --- .github/workflows/theme-validation.yml | 2 +- .pre-commit-config.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/theme-validation.yml b/.github/workflows/theme-validation.yml index d04271da..1da5e15f 100644 --- a/.github/workflows/theme-validation.yml +++ b/.github/workflows/theme-validation.yml @@ -34,7 +34,7 @@ jobs: run: mypy src/ - name: Shell check - run: shellcheck --shell=bash --severity=warning scripts/*.sh + run: git ls-files 'scripts/*.sh' 'lib/*.sh' 'DreamcoderShell/*.sh' 'DreamcoderShell/**/*.sh' | xargs shellcheck --shell=bash --severity=warning - name: Run tests run: python -m pytest tests/ -v --tb=short diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1d049aef..68ac0d9f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -28,7 +28,7 @@ repos: hooks: - id: shellcheck args: ["--shell=bash", "--severity=warning"] - files: ^(scripts|lib)/ + files: ^(scripts|lib|DreamcoderShell)/.*\.sh$ - repo: local hooks: From 8d48f49767fa4f5d8b8bce6d04a744d6e6a83416 Mon Sep 17 00:00:00 2001 From: DreamCoder08 Date: Tue, 29 Sep 2026 12:05:53 -0500 Subject: [PATCH 05/10] refactor(palette): split validate_palette into per-rule-family helpers Compose WCAG text tokens, main text, foreground pairs, ANSI, APCA classes and structural checks as small pure helpers in the original order. Every error string and its position is unchanged (pinned by the characterization test); radon cc drops from E (34) to B (6). --- src/dreamcoder_theme/palette.py | 173 +++++++++++++++++++++----------- 1 file changed, 116 insertions(+), 57 deletions(-) diff --git a/src/dreamcoder_theme/palette.py b/src/dreamcoder_theme/palette.py index 7c304376..1635e875 100644 --- a/src/dreamcoder_theme/palette.py +++ b/src/dreamcoder_theme/palette.py @@ -278,96 +278,155 @@ def validate_palette( measured={value} guardrail={key}={threshold} """ g = guardrails or {} - errors: list[str] = [] - bg = palette["bg"] + if "bg" not in palette: # fail fast, exactly like the former eager lookup + raise KeyError("bg") effective_mode = mode if mode is not None else detect_mode(palette) text_min = g.get("minimum_text_contrast", 4.5) - main_min = g.get("preferred_main_text_contrast", 7.0) - sel_min = g.get("minimum_terminal_selection_contrast", 7.0) - - def wcag_diag(fg_key: str, bg_key: str, measured: float, key: str, threshold: float) -> str: - return ( - f"WCAG fail: mode={effective_mode} " - f"pair={fg_key}/{bg_key} measured={measured:.2f} " - f"guardrail={key}={threshold}" - ) - - def apca_diag(cls: str, fg_key: str, bg_key: str, lc: float, key: str, threshold: float) -> str: - return ( - f"APCA fail: mode={effective_mode} " - f"pair={fg_key}/{bg_key} class={cls} measured={abs(lc):.1f} " - f"guardrail={key}={threshold}" - ) # -- WCAG 2.2 gate -------------------------------------------------- + errors = _wcag_text_tokens(palette, effective_mode, text_min) + errors += _wcag_main_text(palette, effective_mode, g.get("preferred_main_text_contrast", 7.0)) + errors += _wcag_foreground_pairs( + palette, effective_mode, text_min, g.get("minimum_terminal_selection_contrast", 7.0) + ) + errors += _wcag_ansi(palette, effective_mode, g.get("minimum_terminal_ansi_contrast", 4.5)) + + # -- APCA gate (independent; never short-circuits WCAG) ------------- + if mode is not None and mode not in ("light", "dark", "dusk"): + errors.append(f"invalid mode: {mode}") + errors += _apca_classes(palette, g, effective_mode, text_min) + + # -- Structural checks ---------------------------------------------- + errors += _structural_errors(palette, effective_mode) + return errors + + +def _wcag_diag( + mode: str, fg_key: str, bg_key: str, measured: float, key: str, threshold: float +) -> str: + return ( + f"WCAG fail: mode={mode} " + f"pair={fg_key}/{bg_key} measured={measured:.2f} " + f"guardrail={key}={threshold}" + ) + + +def _apca_diag( + mode: str, cls: str, fg_key: str, bg_key: str, lc: float, key: str, threshold: float +) -> str: + return ( + f"APCA fail: mode={mode} " + f"pair={fg_key}/{bg_key} class={cls} measured={abs(lc):.1f} " + f"guardrail={key}={threshold}" + ) + + +def _wcag_text_tokens(palette: dict[str, str], mode: str, text_min: float) -> list[str]: + errors: list[str] = [] for key in ("text", "muted", "comment", "accent", "error", "warning", "diagnostic"): if key not in palette: errors.append(f"missing token: {key}") continue - ratio = contrast(bg, palette[key]) + ratio = contrast(palette["bg"], palette[key]) if ratio < text_min: - errors.append(wcag_diag(key, "bg", ratio, "minimum_text_contrast", text_min)) + errors.append(_wcag_diag(mode, key, "bg", ratio, "minimum_text_contrast", text_min)) + return errors + - if "text" in palette: - ratio = contrast(bg, palette["text"]) - if ratio < main_min: - errors.append(wcag_diag("text", "bg", ratio, "preferred_main_text_contrast", main_min)) +def _wcag_main_text(palette: dict[str, str], mode: str, main_min: float) -> list[str]: + if "text" not in palette: + return [] + ratio = contrast(palette["bg"], palette["text"]) + if ratio >= main_min: + return [] + return [_wcag_diag(mode, "text", "bg", ratio, "preferred_main_text_contrast", main_min)] + +def _wcag_foreground_pairs( + palette: dict[str, str], mode: str, text_min: float, sel_min: float +) -> list[str]: + errors: list[str] = [] for fg_key, bg_key in ( ("selection_fg", "selection_bg"), ("on_accent", "accent"), ("on_error", "error"), ): - if fg_key in palette and bg_key in palette: - ratio = contrast(palette[fg_key], palette[bg_key]) - if fg_key == "selection_fg" and ratio < sel_min: - errors.append( - wcag_diag(fg_key, bg_key, ratio, "minimum_terminal_selection_contrast", sel_min) - ) - elif fg_key.startswith("on_") and ratio < text_min: - errors.append(wcag_diag(fg_key, bg_key, ratio, "minimum_text_contrast", text_min)) + if fg_key not in palette or bg_key not in palette: + continue + ratio = contrast(palette[fg_key], palette[bg_key]) + if fg_key == "selection_fg": + key, threshold = "minimum_terminal_selection_contrast", sel_min + else: + key, threshold = "minimum_text_contrast", text_min + if ratio < threshold: + errors.append(_wcag_diag(mode, fg_key, bg_key, ratio, key, threshold)) + return errors + - ansi_min = g.get("minimum_terminal_ansi_contrast", 4.5) +def _wcag_ansi(palette: dict[str, str], mode: str, ansi_min: float) -> list[str]: + errors: list[str] = [] for index, color in enumerate(ansi(palette)): - ratio = contrast(color, bg) + ratio = contrast(color, palette["bg"]) if ratio < ansi_min: errors.append( - wcag_diag(f"ansi{index}", "bg", ratio, "minimum_terminal_ansi_contrast", ansi_min) + _wcag_diag( + mode, f"ansi{index}", "bg", ratio, "minimum_terminal_ansi_contrast", ansi_min + ) ) + return errors - # -- APCA gate (independent; never short-circuits WCAG) ------------- - if mode is not None and mode not in ("light", "dark", "dusk"): - errors.append(f"invalid mode: {mode}") + +def _apca_classes( + palette: dict[str, str], guardrails: dict[str, float], mode: str, text_min: float +) -> list[str]: + errors: list[str] = [] for cls, pairs, light_key, dark_key in _APCA_PAIR_CLASSES: - key = light_key if effective_mode in ("light", "dusk") else dark_key - threshold = g.get(key) + key = light_key if mode in ("light", "dusk") else dark_key + threshold = guardrails.get(key) if threshold is None: errors.append(f"missing guardrail key: {key}") continue for fg_key, bg_key in pairs: - if fg_key not in palette or bg_key not in palette: - errors.append(f"missing token: {fg_key} (declared {cls} pair)") - continue - lc = apca_lc(palette[fg_key], palette[bg_key]) - if abs(lc) < threshold: - errors.append(apca_diag(cls, fg_key, bg_key, lc, key, threshold)) - # Independent WCAG floor on the SAME declared pair (ADR-002 dual - # gate): both metrics are required for every declared class, so - # an APCA-boosted near-invisible pair cannot pass unremarked. - ratio = contrast(palette[fg_key], palette[bg_key]) - if ratio < text_min: - errors.append(wcag_diag(fg_key, bg_key, ratio, "minimum_text_contrast", text_min)) + errors += _apca_pair(palette, mode, cls, (fg_key, bg_key), (key, threshold), text_min) + return errors + + +def _apca_pair( + palette: dict[str, str], + mode: str, + cls: str, + pair: tuple[str, str], + guardrail: tuple[str, float], + text_min: float, +) -> list[str]: + fg_key, bg_key = pair + if fg_key not in palette or bg_key not in palette: + return [f"missing token: {fg_key} (declared {cls} pair)"] + errors: list[str] = [] + key, threshold = guardrail + lc = apca_lc(palette[fg_key], palette[bg_key]) + if abs(lc) < threshold: + errors.append(_apca_diag(mode, cls, fg_key, bg_key, lc, key, threshold)) + # Independent WCAG floor on the SAME declared pair (ADR-002 dual + # gate): both metrics are required for every declared class, so + # an APCA-boosted near-invisible pair cannot pass unremarked. + ratio = contrast(palette[fg_key], palette[bg_key]) + if ratio < text_min: + errors.append(_wcag_diag(mode, fg_key, bg_key, ratio, "minimum_text_contrast", text_min)) + return errors - # -- Structural checks ---------------------------------------------- - for step in ("bg_soft", "surface0", "surface1", "surface2", "surface3"): - if step in palette and contrast(palette[step], bg) < 1.02: - errors.append(f"{step} too close to bg") +def _structural_errors(palette: dict[str, str], mode: str) -> list[str]: + errors = [ + f"{step} too close to bg" + for step in ("bg_soft", "surface0", "surface1", "surface2", "surface3") + if step in palette and contrast(palette[step], palette["bg"]) < 1.02 + ] if palette.get("comment") == palette.get("subtle"): errors.append("comment and subtle must differ") if palette.get("accent") == palette.get("accent_2"): errors.append("accent and accent_2 must differ") - if effective_mode == "light" and "surface3" not in palette: + if mode == "light" and "surface3" not in palette: errors.append("light mode missing surface3") return errors From 60d0e4a37c93460aba5b1711c127478b796cfb2f Mon Sep 17 00:00:00 2001 From: DreamCoder08 Date: Tue, 29 Sep 2026 12:08:28 -0500 Subject: [PATCH 06/10] refactor(design-system): extract contract evaluation and output parsers Split evaluate_contract into mode/role, render, parity (provenance and required roles) and matrix helpers composed in the original order, and replace the _parse_renderer_output if-chain with a per-target parser table. Findings, messages and sort order are unchanged; radon cc goes from D (26) to A (1) and from D (21) to A (2). --- src/dreamcoder_theme/design_system.py | 292 ++++++++++++++++---------- 1 file changed, 182 insertions(+), 110 deletions(-) diff --git a/src/dreamcoder_theme/design_system.py b/src/dreamcoder_theme/design_system.py index 04e37252..feb3b220 100644 --- a/src/dreamcoder_theme/design_system.py +++ b/src/dreamcoder_theme/design_system.py @@ -4,7 +4,7 @@ import json import re -from collections.abc import Mapping +from collections.abc import Callable, Mapping from dataclasses import dataclass from pathlib import Path from typing import Any, Literal, cast @@ -166,13 +166,24 @@ def render_target( return RenderedTarget(target=target, mode=mode, fields=fields, content=content) -def evaluate_contract( # noqa: PLR0912 - contract: Mapping[str, Any], tokens: Mapping[str, Any] -) -> list[Finding]: +def evaluate_contract(contract: Mapping[str, Any], tokens: Mapping[str, Any]) -> list[Finding]: """Evaluate role provenance, three-mode parity, and matrix coverage in memory.""" - findings: list[Finding] = [] expected_modes = tuple(contract["modes"]) canonical_modes = tokens.get("modes", {}) + findings = _mode_role_findings(contract, tokens, expected_modes, canonical_modes) + rendered = _render_declared_targets(contract, expected_modes, canonical_modes, findings) + findings += _parity_findings(contract, tokens, expected_modes, rendered) + findings += _matrix_findings(contract) + return sorted(findings, key=_finding_sort_key) + + +def _mode_role_findings( + contract: Mapping[str, Any], + tokens: Mapping[str, Any], + expected_modes: tuple[str, ...], + canonical_modes: Mapping[str, Any], +) -> list[Finding]: + findings: list[Finding] = [] for mode in expected_modes: if mode not in canonical_modes: findings.append( @@ -186,7 +197,16 @@ def evaluate_contract( # noqa: PLR0912 findings.append( _finding("ROLE_RESOLUTION", mode=mode, role=role, message=str(error)) ) + return findings + +def _render_declared_targets( + contract: Mapping[str, Any], + expected_modes: tuple[str, ...], + canonical_modes: Mapping[str, Any], + findings: list[Finding], +) -> dict[tuple[str, str], RenderedTarget]: + """Render every declared target/mode pair, appending failures to ``findings``.""" rendered: dict[tuple[str, str], RenderedTarget] = {} for target, target_contract in contract["targets"].items(): declared_modes = tuple(target_contract.get("modes", ())) @@ -210,64 +230,99 @@ def evaluate_contract( # noqa: PLR0912 findings.append( _finding("RENDER_FAILURE", mode=mode, target=target, message=str(error)) ) + return rendered + +def _parity_findings( + contract: Mapping[str, Any], + tokens: Mapping[str, Any], + expected_modes: tuple[str, ...], + rendered: Mapping[tuple[str, str], RenderedTarget], +) -> list[Finding]: + findings: list[Finding] = [] for target, target_contract in contract["targets"].items(): for mode in expected_modes: output = rendered.get((target, mode)) if output is None: continue - mappings = target_contract.get("mappings", {}) - for role, rendered_value in output.fields.items(): - expected_role = mappings.get(role, role) - if expected_role.startswith("renderer:"): - continue - try: - expected = resolve_role(contract, tokens, mode, expected_role) - except ValueError as error: - findings.append( - _finding( - "SEMANTIC_PROVENANCE_INVALID", - mode=mode, - target=target, - role=role, - message=str(error), - ) - ) - continue - if rendered_value != expected.value: - findings.append( - _finding( - "SEMANTIC_PROVENANCE_MISMATCH", - mode=mode, - target=target, - role=role, - measured=rendered_value, - required=expected.value, - message=( - f"target '{target}' field for role '{role}' does not match " - f"canonical role '{expected_role}'" - ), - ) - ) - for role in target_contract["required_roles"]: - if role in output.fields: - continue - mapped_to = mappings.get(role) - if mapped_to and mapped_to in output.fields: - continue - findings.append( - _finding( - "PARITY_MISSING_FIELD", - mode=mode, - target=target, - role=role, - message=( - f"target '{target}' omits required role '{role}'; " - "declare a field or explicit semantic mapping" - ), - ) + findings += _provenance_findings(contract, tokens, target_contract, output) + findings += _required_role_findings(target_contract, output) + return findings + + +def _provenance_findings( + contract: Mapping[str, Any], + tokens: Mapping[str, Any], + target_contract: Mapping[str, Any], + output: RenderedTarget, +) -> list[Finding]: + findings: list[Finding] = [] + target, mode = output.target, output.mode + mappings = target_contract.get("mappings", {}) + for role, rendered_value in output.fields.items(): + expected_role = mappings.get(role, role) + if expected_role.startswith("renderer:"): + continue + try: + expected = resolve_role(contract, tokens, mode, expected_role) + except ValueError as error: + findings.append( + _finding( + "SEMANTIC_PROVENANCE_INVALID", + mode=mode, + target=target, + role=role, + message=str(error), + ) + ) + continue + if rendered_value != expected.value: + findings.append( + _finding( + "SEMANTIC_PROVENANCE_MISMATCH", + mode=mode, + target=target, + role=role, + measured=rendered_value, + required=expected.value, + message=( + f"target '{target}' field for role '{role}' does not match " + f"canonical role '{expected_role}'" + ), ) + ) + return findings + +def _required_role_findings( + target_contract: Mapping[str, Any], output: RenderedTarget +) -> list[Finding]: + mappings = target_contract.get("mappings", {}) + return [ + _finding( + "PARITY_MISSING_FIELD", + mode=output.mode, + target=output.target, + role=role, + message=( + f"target '{output.target}' omits required role '{role}'; " + "declare a field or explicit semantic mapping" + ), + ) + for role in target_contract["required_roles"] + if not _role_is_covered(role, mappings, output.fields) + ] + + +def _role_is_covered(role: str, mappings: Mapping[str, str], fields: Mapping[str, str]) -> bool: + if role in fields: + return True + mapped_to = mappings.get(role) + return bool(mapped_to and mapped_to in fields) + + +def _matrix_findings(contract: Mapping[str, Any]) -> list[Finding]: + findings: list[Finding] = [] for row in contract["matrix"]: for target in row["targets"]: target_contract = contract["targets"].get(target) @@ -281,18 +336,18 @@ def evaluate_contract( # noqa: PLR0912 supported = set(target_contract["required_roles"]) | set( target_contract.get("mappings", {}) ) - for role in (row["foreground"], row["background"]): - if role not in supported: - findings.append( - _finding( - "MATRIX_MISSING_ROLE", - target=target, - role=role, - required=row["id"], - message=f"matrix row '{row['id']}' requires role '{role}' for target '{target}'", - ) - ) - return sorted(findings, key=_finding_sort_key) + findings += [ + _finding( + "MATRIX_MISSING_ROLE", + target=target, + role=role, + required=row["id"], + message=f"matrix row '{row['id']}' requires role '{role}' for target '{target}'", + ) + for role in (row["foreground"], row["background"]) + if role not in supported + ] + return findings def _finding( @@ -319,49 +374,56 @@ def _finding_sort_key(finding: Finding) -> tuple[str, str, str, str, str]: ) -def _parse_renderer_output(target: str, content: str) -> dict[str, str]: # noqa: PLR0912 - if target == "opencode": - try: - parsed = json.loads(content) - except json.JSONDecodeError as exc: - raise ValueError(f"renderer {target!r} produced invalid JSON: {exc}") from exc - return {f"theme.{key}": value for key, value in parsed["theme"].items()} - if target == "kitty": - return _space_assignments(content) - if target == "ghostty": - fields = _equals_assignments(content) - for line in content.splitlines(): - if line.startswith("palette = "): - number, value = line.removeprefix("palette = ").split("=", 1) - fields[f"palette.{number}"] = value - return fields - if target == "warp": - fields = _colon_assignments(content) - section = "" - for line in content.splitlines(): - stripped = line.strip() - if stripped.endswith(":"): - section = stripped[:-1] - elif ":" in stripped and section in {"normal", "bright"}: - key, value = stripped.split(":", 1) - fields[f"terminal_colors.{section}.{key}"] = value.strip().strip("'") - return fields - if target == "starship": - star_fields: dict[str, str] = {} - in_palette = False - for line in content.splitlines(): - if line == "[palettes.dreamcoder]": - in_palette = True - continue - if in_palette and line.startswith("["): - break - if in_palette and " = " in line: - key, value = line.split(" = ", 1) - star_fields[f"palette.{key}"] = value.strip().strip('"') - return star_fields - if target == "tmux": - return _tmux_fields(content) - raise ValueError(f"no adapter for target: {target}") +def _parse_renderer_output(target: str, content: str) -> dict[str, str]: + parser = _OUTPUT_PARSERS.get(target) + if parser is None: + raise ValueError(f"no adapter for target: {target}") + return parser(content) + + +def _opencode_fields(content: str) -> dict[str, str]: + try: + parsed = json.loads(content) + except json.JSONDecodeError as exc: + raise ValueError(f"renderer 'opencode' produced invalid JSON: {exc}") from exc + return {f"theme.{key}": value for key, value in parsed["theme"].items()} + + +def _ghostty_fields(content: str) -> dict[str, str]: + fields = _equals_assignments(content) + for line in content.splitlines(): + if line.startswith("palette = "): + number, value = line.removeprefix("palette = ").split("=", 1) + fields[f"palette.{number}"] = value + return fields + + +def _warp_fields(content: str) -> dict[str, str]: + fields = _colon_assignments(content) + section = "" + for line in content.splitlines(): + stripped = line.strip() + if stripped.endswith(":"): + section = stripped[:-1] + elif ":" in stripped and section in {"normal", "bright"}: + key, value = stripped.split(":", 1) + fields[f"terminal_colors.{section}.{key}"] = value.strip().strip("'") + return fields + + +def _starship_fields(content: str) -> dict[str, str]: + star_fields: dict[str, str] = {} + in_palette = False + for line in content.splitlines(): + if line == "[palettes.dreamcoder]": + in_palette = True + continue + if in_palette and line.startswith("["): + break + if in_palette and " = " in line: + key, value = line.split(" = ", 1) + star_fields[f"palette.{key}"] = value.strip().strip('"') + return star_fields def _space_assignments(content: str) -> dict[str, str]: @@ -398,3 +460,13 @@ def _tmux_fields(content: str) -> dict[str, str]: if match: fields[name] = match.group(1) return fields + + +_OUTPUT_PARSERS: dict[str, Callable[[str], dict[str, str]]] = { + "opencode": _opencode_fields, + "kitty": _space_assignments, + "ghostty": _ghostty_fields, + "warp": _warp_fields, + "starship": _starship_fields, + "tmux": _tmux_fields, +} From ab16d68ba1f3697b3aa2c9cde66db68c0bcb7d15 Mon Sep 17 00:00:00 2001 From: DreamCoder08 Date: Tue, 29 Sep 2026 12:08:45 -0500 Subject: [PATCH 07/10] test(palette): pin validate_palette fail-fast and skipped-pair behaviour Cover the missing-bg KeyError, the ANSI derivation failing without text, and the skipped WCAG check for an absent foreground-pair token. --- tests/test_palette_dual_gate.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/test_palette_dual_gate.py b/tests/test_palette_dual_gate.py index 4e937dce..d32b57ab 100644 --- a/tests/test_palette_dual_gate.py +++ b/tests/test_palette_dual_gate.py @@ -9,6 +9,8 @@ import json from pathlib import Path +import pytest + from dreamcoder_theme._math import apca_lc, contrast from dreamcoder_theme.palette import validate_palette @@ -271,3 +273,29 @@ def test_light_mode_without_surface3_is_reported_last(): errors = validate_palette(pal, _guardrails()) assert errors[-1] == "light mode missing surface3" + + +def test_missing_bg_fails_fast_with_key_error(): + pal = dict(_FROZEN_DARK) + pal.pop("bg") + + with pytest.raises(KeyError, match="bg"): + validate_palette(pal, _guardrails(), mode="dark") + + +def test_absent_foreground_pair_token_skips_its_wcag_check(): + pal = dict(_FROZEN_DARK) + pal.pop("on_error") + + errors = validate_palette(pal, _guardrails(), mode="dark") + + assert not any("on_error/" in e for e in errors) + + +def test_missing_text_token_still_fails_through_the_ansi_derivation(): + """ANSI colors derive from ``text``, so a palette without it cannot be validated.""" + pal = dict(_FROZEN_DARK) + pal.pop("text") + + with pytest.raises(KeyError, match="text"): + validate_palette(pal, _guardrails(), mode="dark") From af187196272d6e91214590e8f7e6593094fc96a9 Mon Sep 17 00:00:00 2001 From: DreamCoder08 Date: Tue, 29 Sep 2026 12:13:06 -0500 Subject: [PATCH 08/10] docs(odd): record q1 and q2 complexity refactors and evidence --- odd/tasks/engineering-quality-pass.md | 84 +++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 odd/tasks/engineering-quality-pass.md diff --git a/odd/tasks/engineering-quality-pass.md b/odd/tasks/engineering-quality-pass.md new file mode 100644 index 00000000..015261fe --- /dev/null +++ b/odd/tasks/engineering-quality-pass.md @@ -0,0 +1,84 @@ +# Engineering quality pass + +## Objective + +Raise maintainability of the theme engine and the repo gates using measured +evidence, without changing behaviour: reduce the worst cyclomatic-complexity +hotspots, ratchet the coverage gate to the real level, and document publication. + +## Problem (measured 2026-09-29) + +- `mypy --strict`: clean (56 files). Coverage: 83% (gate is 40%). ruff: clean. +- radon cc worst offenders: `palette.validate_palette` E(34), + `design_system.evaluate_contract` D(26), `design_system._parse_renderer_output` + D(21), then C(13–19) in doctor, targets, audit, herdr renderer, repair, backups. +- Largest modules: `sync.py` 1061 lines, `cli_handlers.py` 620, `herdr_switch.py` 552. +- Coverage gate at 40% would let a 40-point regression through. + +## Why + +Central gates (contrast validation, design-system contract) with cyclomatic +complexity above 20 are hard to review and easy to break; a coverage floor far below +reality is not a gate. + +## Scope + +- In: behaviour-preserving extraction refactors of the three D/E functions (small + pure helpers, no new abstractions beyond need), characterization tests written + first where existing coverage of a branch is missing, coverage ratchet in + `pyproject.toml` and CI, PR slicing plan for the stacked branches. +- Out: splitting `sync.py`/`cli_handlers.py` wholesale (separate design decision, + in-flight `hexagonal-architecture-v2` change exists); pushing or opening PRs + (user decision); changing any generated artifact byte. + +## Constraints + +- Public function signatures and outputs unchanged; every Light/Dark artifact + byte-identical (`./scripts/dreamcoder sync` repo-only must report 0 changes). +- ruff, ruff format, `mypy --strict`, shellcheck stay clean. +- Never stage the unrelated timer-rewritten / user working-tree files. +- TDD: off (no project configuration). Runners: `python -m pytest tests/`, + `bats tests/shell/ tests/ml4w/`. Complexity check: `uvx radon cc src -s -n C`. +- Delivery: work-unit commits on `refactor/remove-night-profile`. + +## Tasks + +- [x] Q0 — Resolve the SUPER+SHIFT+arrows overlap (variant resize vs profile move): + resize moved to SUPER+CTRL+arrows, collision test has no exceptions. Route: inline. +- [x] Q1 — Refactor `validate_palette` (E→ at most B) preserving every error message and order. Route: delegated writer. + `validate_palette` E(34) → B(6); helpers all ≤ B(8). Commits: 4c4214b (refactor), + c4db159 (fail-fast/skip tests); characterization tests landed in 684026f (see note). +- [x] Q2 — Refactor `evaluate_contract` and `_parse_renderer_output` (D→ at most B). Route: delegated writer. + `evaluate_contract` D(26) → A(1), `_parse_renderer_output` D(21) → A(2) via a per-target + parser table; helpers all ≤ B(7). Commit: d503d2e. +- [ ] Q3 — Ratchet coverage `fail_under` (pyproject + CI) from 40 to the measured floor. Route: inline. +- [ ] Q4 — PR slicing plan for the two stacked branches (chained-pr / work-unit-commits). Route: inline. + +## Acceptance criteria + +- radon reports no function above C(15) in the three refactored areas; `validate_palette` at most B. +- Full pytest + bats green; repo-only sync reports 0 changes; mypy/ruff clean. + +## Progress + +- Baseline metrics above. Q0 done. +- Q1/Q2 evidence: characterization tests written first and green on the old code + (golden ordered error list over a frozen dark palette; exact findings for + MISSING_MODE, ROLE_RESOLUTION, RENDER_FAILURE, SEMANTIC_PROVENANCE_INVALID; parser + invalid-JSON/unknown-target). The three functions are now 100% line-covered. + Coverage total 83% → 84%. pytest exit 0; bats 147/147 ok; ruff, ruff format, mypy + (56 files) clean. Byte identity: sync run in an isolated worktree with a throwaway + HOME/XDG_* (`DREAMCODER_THEME_MODE=dark|light`, `DREAMCODER_WRITE_REPO=1`), sha256 + of all 900 tracked non-src files plus 20 HOME outputs identical HEAD vs refactor in + both modes. +- Note: a concurrent commit (684026f `fix(shell): ...`) swept the staged + characterization tests in with it; left as-is (no history rewrite). Later commits + use `git commit --only `. +- Incident: `./scripts/dreamcoder sync --help` does not parse `--help` and ran a real + sync once (active light identity re-applied to live paths; it also regenerated the + timer-modified `DreamcoderPi/.../dreamcoder-dark.json` and + `DreamcoderWarp/.../Dreamcoder-Dark.yaml` back to their committed bytes). + +## Next step + +Q3, Q4. From 81e860aeecbf75eef3410ff62fa877afe633c22e Mon Sep 17 00:00:00 2001 From: DreamCoder08 Date: Tue, 29 Sep 2026 12:18:05 -0500 Subject: [PATCH 09/10] fix(sync): parse arguments before any side effect so --help cannot sync main() ignored sys.argv, so dreamcoder sync --help ran a full sync against the live configuration. Parse first: --help prints usage and exits 0, unknown options exit 2, and tests inject argv explicitly. --- src/dreamcoder_theme/sync.py | 22 ++++++++++++++- tests/test_dreamcoder_sync.py | 12 ++++----- tests/test_sync_cli_args.py | 51 +++++++++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 7 deletions(-) create mode 100644 tests/test_sync_cli_args.py diff --git a/src/dreamcoder_theme/sync.py b/src/dreamcoder_theme/sync.py index 170d9958..94b89ef6 100644 --- a/src/dreamcoder_theme/sync.py +++ b/src/dreamcoder_theme/sync.py @@ -2,6 +2,7 @@ from __future__ import annotations +import argparse import subprocess import sys from collections.abc import Callable, Mapping, Sequence @@ -1020,7 +1021,26 @@ def prepare(base: str) -> PreparedSync: ) -def main() -> None: +def _parse_args(argv: Sequence[str] | None) -> None: + """Reject anything unknown before a single side effect runs. + + sync takes no options: the mode and write behaviour come from the + environment. Parsing first is what keeps ``--help`` from syncing. + """ + parser = argparse.ArgumentParser( + prog="dreamcoder sync", + description="Regenerate the Dreamcoder theme from tokens.json and apply the active mode.", + epilog=( + "environment: DREAMCODER_THEME_MODE=light|dark selects the mode (default: the " + "persisted live mode, then dark); DREAMCODER_WRITE_REPO=0 skips repo-tracked " + "outputs and writes live targets only." + ), + ) + parser.parse_args(argv) + + +def main(argv: Sequence[str] | None = None) -> None: + _parse_args(argv) gen = ROOT / "scripts" / "generate-palette-tokens.py" if gen.is_file(): subprocess.run([sys.executable, str(gen)], check=True) diff --git a/tests/test_dreamcoder_sync.py b/tests/test_dreamcoder_sync.py index a1516505..67e90e8c 100644 --- a/tests/test_dreamcoder_sync.py +++ b/tests/test_dreamcoder_sync.py @@ -239,7 +239,7 @@ def test_main_happy_path(mock_paths, active, variants): for p in patches: p.start() try: - sync.main() + sync.main([]) finally: for p in patches: p.stop() @@ -251,7 +251,7 @@ def test_main_fails_on_invalid_starship(mock_paths, active, variants): p.start() try: with pytest.raises(SystemExit) as exc: - sync.main() + sync.main([]) finally: for p in patches: p.stop() @@ -303,7 +303,7 @@ def test_main_gate_failure_blocks_all_writes(mock_paths, active, variants): p.start() try: with pytest.raises(SystemExit) as exc: - sync.main() + sync.main([]) finally: for p in patches: p.stop() @@ -323,7 +323,7 @@ def test_main_skips_sync_repo_when_disabled(mock_paths, active, variants): for p in patches: p.start() try: - sync.main() + sync.main([]) finally: for p in patches: p.stop() @@ -335,7 +335,7 @@ def test_main_calls_sync_repo_when_enabled(mock_paths, active, variants): for p in patches: p.start() try: - sync.main() + sync.main([]) finally: for p in patches: p.stop() @@ -348,7 +348,7 @@ def test_main_light_mode(mock_paths): for p in patches: p.start() try: - sync.main() + sync.main([]) finally: for p in patches: p.stop() diff --git a/tests/test_sync_cli_args.py b/tests/test_sync_cli_args.py new file mode 100644 index 00000000..ff6093a1 --- /dev/null +++ b/tests/test_sync_cli_args.py @@ -0,0 +1,51 @@ +"""``dreamcoder sync`` must parse its arguments before touching anything. + +``main()`` used to ignore ``sys.argv``: ``dreamcoder sync --help`` ran a full sync +against the live configuration instead of printing usage. +""" + +from __future__ import annotations + +import pytest + +from dreamcoder_theme import sync + + +@pytest.fixture +def no_side_effects(monkeypatch): + # Python 3.14 colours argparse output when FORCE_COLOR is set (the Dreamcoder + # shell exports it); keep the assertions independent of the ambient terminal. + monkeypatch.delenv("FORCE_COLOR", raising=False) + monkeypatch.delenv("CLICOLOR_FORCE", raising=False) + monkeypatch.setenv("NO_COLOR", "1") + calls: list[str] = [] + + def boom(name: str): + def _fail(*_a, **_k): + calls.append(name) + raise AssertionError(f"{name} ran before argument parsing finished") + + return _fail + + monkeypatch.setattr(sync.subprocess, "run", boom("subprocess.run")) + monkeypatch.setattr(sync, "theme_paths", boom("theme_paths")) + monkeypatch.setattr(sync, "prepare", boom("prepare")) + return calls + + +def test_help_prints_usage_and_exits_zero_without_syncing(no_side_effects, capsys): + with pytest.raises(SystemExit) as exc: + sync.main(["--help"]) + assert exc.value.code == 0 + out = capsys.readouterr().out + assert "usage: dreamcoder sync" in out + assert "DREAMCODER_THEME_MODE" in out + assert no_side_effects == [] + + +def test_unknown_argument_is_rejected_without_syncing(no_side_effects, capsys): + with pytest.raises(SystemExit) as exc: + sync.main(["--frobnicate"]) + assert exc.value.code == 2 + assert "unrecognized arguments" in capsys.readouterr().err + assert no_side_effects == [] From 3d7c826124a9a2a47f17c79c22929f4b96679cee Mon Sep 17 00:00:00 2001 From: DreamCoder08 Date: Tue, 29 Sep 2026 12:18:19 -0500 Subject: [PATCH 10/10] docs(odd): close the engineering quality pass with evidence --- odd/tasks/engineering-quality-pass.md | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/odd/tasks/engineering-quality-pass.md b/odd/tasks/engineering-quality-pass.md index 015261fe..62d35f61 100644 --- a/odd/tasks/engineering-quality-pass.md +++ b/odd/tasks/engineering-quality-pass.md @@ -51,8 +51,17 @@ reality is not a gate. - [x] Q2 — Refactor `evaluate_contract` and `_parse_renderer_output` (D→ at most B). Route: delegated writer. `evaluate_contract` D(26) → A(1), `_parse_renderer_output` D(21) → A(2) via a per-target parser table; helpers all ≤ B(7). Commit: d503d2e. -- [ ] Q3 — Ratchet coverage `fail_under` (pyproject + CI) from 40 to the measured floor. Route: inline. -- [ ] Q4 — PR slicing plan for the two stacked branches (chained-pr / work-unit-commits). Route: inline. +- [x] Q3 — Ratchet coverage `fail_under` (pyproject + CI + ADR-0003) from 40 to 80 (measured 83–84%). Commit: 089f513. Route: inline. +- [x] Q4 — PR slicing plan (nothing pushed; user decides): keep commit order, cut at + commit boundaries into chained PRs: (1) ML4W 2.16 compat f7c2b9b..339ab7e, + (2) stow layer + Herdr 0.9.1 3398856..a7c925a, (3) theme-mode correctness + listener + hook 702751f..8127674, (4) shell safety c9cf723..754d352, (5) Night removal + 319ffdd..f7597b1, (6) quality pass 089f513 onwards. PRs 1, 2, 3 and 5 exceed the + ~400-line heuristic (tests, fixtures and generated deletions dominate); 684026f mixes + characterization tests into the extract() fix, to be re-cut when slicing. Route: inline. +- [x] Q5 — Found while verifying Q1/Q2: `dreamcoder sync --help` ran a real sync. + `main(argv)` now parses arguments before any side effect (--help exits 0, unknown + options exit 2); tests inject argv. Route: inline. ## Acceptance criteria @@ -81,4 +90,4 @@ reality is not a gate. ## Next step -Q3, Q4. +Feature complete; publication is the user's decision.