test(checks): pure-contract guards batch (#49)
All checks were successful
Check / eval (push) Successful in 3m6s
All checks were successful
Check / eval (push) Successful in 3m6s
Four new no-VM checks.* + CI py_compile expansion:
- hardware-db-modules: installer DB module names ∈ pinned
nixos-hardware.nixosModules (a lock bump can rename a module and break
profiled installs on just the matching DMI, invisible to any VM).
- installer-compose-lock: offline lock-composer contract on fixtures.
- installer-disko: pure Nix assert — swapSize "0"/"0G" → no @swap, "2G"
→ sized, withLuks wraps root (permanently guards the #46 install fix).
- windowrule-syntax: builds the generated hyprland.conf and fails on the
pre-0.53 grammar / windowrulev2 keyword (guards ed7fd93).
CI py_compile now covers all tracked *.py; docs/TESTING.md §1 synced.
Implemented by a worktree agent; diff reviewed. Verified V0 (flake check)
+ V1 (each checks.x86_64-linux.* built on main, exit 0); agent's negative
tests confirm each guard fails on a regression.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -61,12 +61,14 @@ jobs:
|
|||||||
# not run) and the downstream template through lib.mkFlake.
|
# not run) and the downstream template through lib.mkFlake.
|
||||||
run: nix flake check --no-build
|
run: nix flake check --no-build
|
||||||
|
|
||||||
- name: Python syntax (nomarchy-theme-sync)
|
- name: Python syntax (all tracked scripts)
|
||||||
# Via nix shell so the step doesn't depend on the runner image
|
# Every tracked *.py — theme-sync, the installer composers
|
||||||
# preinstalling python3.
|
# (compose-lock, patch-template) and the maintainer tools
|
||||||
|
# (tools/, tools/vm/). Via nix shell so the step doesn't depend on
|
||||||
|
# the runner image preinstalling python3.
|
||||||
run: |
|
run: |
|
||||||
nix shell nixpkgs#python3 --command \
|
nix shell nixpkgs#python3 --command \
|
||||||
python3 -m py_compile pkgs/nomarchy-theme-sync/nomarchy-theme-sync.py
|
bash -c 'git ls-files "*.py" | xargs -r python3 -m py_compile'
|
||||||
|
|
||||||
- name: Shell syntax (tracked scripts)
|
- name: Shell syntax (tracked scripts)
|
||||||
# The distro's user-facing scripts are generated by Nix (their
|
# The distro's user-facing scripts are generated by Nix (their
|
||||||
|
|||||||
@@ -114,19 +114,6 @@ Next slice: on hardware, run `hyprctl clients` while each is open, read
|
|||||||
the real `class`, then append rules. Also revisit whether blueman/s-c-p
|
the real `class`, then append rules. Also revisit whether blueman/s-c-p
|
||||||
actually float once seen (regex tolerance for the `.…-wrapped` form).
|
actually float once seen (regex tolerance for the `.…-wrapped` form).
|
||||||
|
|
||||||
### 49. Pure-contract `checks.*` batch — no VM (promoted 2026-07-09)
|
|
||||||
Add pure runCommand checks (all V0):
|
|
||||||
- Expand CI `py_compile` to **all** tracked `*.py` (compose-lock, tools/*,
|
|
||||||
tools/vm/*), not just theme-sync (`.gitea/workflows/check.yml`; sync
|
|
||||||
`docs/TESTING.md §1`).
|
|
||||||
- Hardware-db module names ∈ pinned `nixos-hardware.nixosModules` (a lock bump
|
|
||||||
can rename modules and break profiled installs only on matching DMI).
|
|
||||||
- Installer pure contracts: `compose-lock.py`, disko `swapSize`
|
|
||||||
("0"/"0G"/"NG") + `withLuks` arg contract.
|
|
||||||
- **windowrule dead-syntax guard:** build the generated `hyprland.conf` and
|
|
||||||
fail on the rule-first `windowrule = (float|center|size|tile|…) ,` pattern
|
|
||||||
or the `windowrulev2` keyword (would have caught `ed7fd93`).
|
|
||||||
|
|
||||||
### 50. Small code one-liners batch — V0/V1 (promoted 2026-07-09)
|
### 50. Small code one-liners batch — V0/V1 (promoted 2026-07-09)
|
||||||
- Bluetooth menu row self-gate on `command -v blueman-manager` (`rofi.nix`) —
|
- Bluetooth menu row self-gate on `command -v blueman-manager` (`rofi.nix`) —
|
||||||
no-op when `nomarchy.system.bluetooth.enable = false`, like Printers.
|
no-op when `nomarchy.system.bluetooth.enable = false`, like Printers.
|
||||||
|
|||||||
@@ -17,6 +17,24 @@ Template:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-07-09 — #49 pure-contract checks.* batch (delegated, worktree)
|
||||||
|
- **Task:** NEXT #49 — add pure (no-VM) `checks.*` guards. Delegated to a
|
||||||
|
worktree opus agent, parallel with #46 VM + the #47/#48 agent.
|
||||||
|
- **Did:** Four new checks wired into `flake.nix` + CI py_compile expansion:
|
||||||
|
`hardware-db-modules` (installer DB module names ∈ pinned
|
||||||
|
nixos-hardware.nixosModules), `installer-compose-lock` (offline composer
|
||||||
|
contract on fixtures), `installer-disko` (pure Nix assert: swapSize
|
||||||
|
"0"/"0G" → no @swap, "2G" → sized, withLuks wrapping — permanently guards
|
||||||
|
the #46 fix), `windowrule-syntax` (builds the generated hyprland.conf, fails
|
||||||
|
on pre-0.53 grammar / windowrulev2 — permanently guards `ed7fd93`). CI
|
||||||
|
py_compile now covers all tracked `*.py`; TESTING.md §1 synced.
|
||||||
|
- **Verified:** V0 flake check green; **V1** I built each of the four
|
||||||
|
`checks.x86_64-linux.*` on main after cherry-pick — all exit 0. Agent's
|
||||||
|
negative tests confirmed each guard FAILS on a deliberate regression.
|
||||||
|
- **Pending:** none — all pure/headless, no VM. (A KVM CI runner, item 20,
|
||||||
|
would later run these + the VM suite on push.)
|
||||||
|
- **Next suggestion:** #50 one-liners (mine); then close #46 (variant A boot).
|
||||||
|
|
||||||
## 2026-07-09 — #47+#48 docs/string factual-drift pass (delegated, worktree)
|
## 2026-07-09 — #47+#48 docs/string factual-drift pass (delegated, worktree)
|
||||||
- **Task:** NEXT #47 (docs + string drift) + #48 (i2c option-docs). Delegated
|
- **Task:** NEXT #47 (docs + string drift) + #48 (i2c option-docs). Delegated
|
||||||
to a worktree sonnet agent, parallel with #46 VM + the #49 agent, no shared
|
to a worktree sonnet agent, parallel with #46 VM + the #49 agent, no shared
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ than claiming success. "All Nix files parse" is not "the bar renders."
|
|||||||
```sh
|
```sh
|
||||||
nix flake check --no-build # full module-system evaluation, no builds
|
nix flake check --no-build # full module-system evaluation, no builds
|
||||||
nix-instantiate --parse <file> # syntax-only, works even on macOS
|
nix-instantiate --parse <file> # syntax-only, works even on macOS
|
||||||
python3 -m py_compile pkgs/nomarchy-theme-sync/nomarchy-theme-sync.py
|
git ls-files '*.py' | xargs python3 -m py_compile # all tracked Python
|
||||||
bash -n <script>.sh # shell syntax (tools/, installer bits)
|
bash -n <script>.sh # shell syntax (tools/, installer bits)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
86
flake.nix
86
flake.nix
@@ -233,6 +233,92 @@
|
|||||||
touch $out
|
touch $out
|
||||||
'';
|
'';
|
||||||
|
|
||||||
|
# Installer hardware DB ↔ nixos-hardware (item 49): every module
|
||||||
|
# name the DB table and the CPU/GPU/chassis autodetect reference
|
||||||
|
# must exist in the PINNED nixos-hardware.nixosModules — a lock
|
||||||
|
# bump can rename a module upstream (the X1 Carbon → x1-Nth-gen
|
||||||
|
# churn) and break profiled installs on just the matching DMI,
|
||||||
|
# invisible to any VM. The available-set is generated here from
|
||||||
|
# attrNames — the same value fed to nomarchy-install — so it
|
||||||
|
# tracks the lock automatically.
|
||||||
|
hardware-db-modules = pkgs.runCommand "nomarchy-hardware-db-modules"
|
||||||
|
{ nativeBuildInputs = [ pkgs.python3 ]; }
|
||||||
|
''
|
||||||
|
python3 ${./tools/check-hardware-db.py} \
|
||||||
|
${./pkgs/nomarchy-install/hardware-db.sh} \
|
||||||
|
${pkgs.writeText "nixos-hardware-modules.txt"
|
||||||
|
(nixpkgs.lib.concatStringsSep "\n"
|
||||||
|
(builtins.attrNames nixos-hardware.nixosModules))}
|
||||||
|
touch $out
|
||||||
|
'';
|
||||||
|
|
||||||
|
# compose-lock.py offline-composer contract (item 49): the lock
|
||||||
|
# the installer writes on the target with no network must root at
|
||||||
|
# nomarchy, carry every upstream node one level down with its
|
||||||
|
# `follows` paths rebased, and fail closed on a missing narHash or
|
||||||
|
# a pre-existing nomarchy node. Pure — runs the script on fixtures.
|
||||||
|
installer-compose-lock = pkgs.runCommand "nomarchy-installer-compose-lock"
|
||||||
|
{ nativeBuildInputs = [ pkgs.python3 ]; }
|
||||||
|
''
|
||||||
|
python3 ${./tools/check-compose-lock.py} \
|
||||||
|
${./pkgs/nomarchy-install/compose-lock.py}
|
||||||
|
touch $out
|
||||||
|
'';
|
||||||
|
|
||||||
|
# The installer's disko arg contract (item 49): swapSize "0"/"0G"
|
||||||
|
# (bare "0" is what the swap=0 install passes) must yield NO @swap
|
||||||
|
# subvolume, a sized value a correctly-sized one, and withLuks must
|
||||||
|
# gate the LUKS wrapper around the BTRFS root. Pure eval of
|
||||||
|
# disko-config.nix (a plain function) — no disko, no VM.
|
||||||
|
installer-disko =
|
||||||
|
let
|
||||||
|
diskoFor = args: import ./pkgs/nomarchy-install/disko-config.nix
|
||||||
|
({ mainDrive = "/dev/vda"; } // args);
|
||||||
|
rootContent = args:
|
||||||
|
(diskoFor args).disko.devices.disk.main.content.partitions.root.content;
|
||||||
|
btrfsOf = c: if c.type == "luks" then c.content else c;
|
||||||
|
subvols = args: (btrfsOf (rootContent args)).subvolumes;
|
||||||
|
hasSwap = args: builtins.hasAttr "@swap" (subvols args);
|
||||||
|
expect = cond: msg: nixpkgs.lib.assertMsg cond "installer-disko: ${msg}";
|
||||||
|
ok =
|
||||||
|
expect (! hasSwap { swapSize = "0"; })
|
||||||
|
''swapSize "0" produced an @swap subvol''
|
||||||
|
&& expect (! hasSwap { swapSize = "0G"; })
|
||||||
|
''swapSize "0G" produced an @swap subvol''
|
||||||
|
&& expect (hasSwap { swapSize = "2G"; })
|
||||||
|
''swapSize "2G" produced no @swap subvol''
|
||||||
|
&& expect ((subvols { swapSize = "2G"; })."@swap".swap.swapfile.size == "2G")
|
||||||
|
''the @swap swapfile size is not "2G"''
|
||||||
|
&& expect ((rootContent { withLuks = true; }).type == "luks")
|
||||||
|
"withLuks=true did not wrap root in luks"
|
||||||
|
&& expect ((rootContent { withLuks = true; }).name == "crypted")
|
||||||
|
"the luks device is not named 'crypted'"
|
||||||
|
&& expect ((rootContent { withLuks = false; }).type == "btrfs")
|
||||||
|
"withLuks=false did not put btrfs directly on root"
|
||||||
|
&& expect (builtins.hasAttr "@" (subvols { withLuks = false; }))
|
||||||
|
"the no-luks root lost its @ subvolume";
|
||||||
|
in
|
||||||
|
assert ok; pkgs.runCommand "nomarchy-installer-disko" { } "touch $out";
|
||||||
|
|
||||||
|
# Hyprland windowrule dead-syntax guard (item 49): build the
|
||||||
|
# GENERATED hyprland.conf (the ISO's HM output) and fail on a
|
||||||
|
# regression to the pre-0.53 grammar — the `windowrulev2` keyword,
|
||||||
|
# rule-first order (`float, class:^…$`), or a bare `<prop>:`
|
||||||
|
# matcher. That syntax shipped a red config-error banner on every
|
||||||
|
# boot (fixed in ed7fd93); this locks the correct form. Building
|
||||||
|
# one config file is a pure derivation, not a VM.
|
||||||
|
windowrule-syntax =
|
||||||
|
let
|
||||||
|
hyprconf = self.nixosConfigurations.nomarchy-live.config
|
||||||
|
.home-manager.users.${username}.xdg.configFile."hypr/hyprland.conf".source;
|
||||||
|
in
|
||||||
|
pkgs.runCommand "nomarchy-windowrule-syntax"
|
||||||
|
{ nativeBuildInputs = [ pkgs.python3 ]; }
|
||||||
|
''
|
||||||
|
python3 ${./tools/check-windowrule-syntax.py} ${hyprconf}
|
||||||
|
touch $out
|
||||||
|
'';
|
||||||
|
|
||||||
# nomarchy-doctor's contract: an induced failed unit flips the
|
# nomarchy-doctor's contract: an induced failed unit flips the
|
||||||
# sheet to ✖/exit-1 and names the unit; with the failure
|
# sheet to ✖/exit-1 and names the unit; with the failure
|
||||||
# cleared it reports healthy/exit-0. Minimal node (just the
|
# cleared it reports healthy/exit-0. Minimal node (just the
|
||||||
|
|||||||
98
tools/check-compose-lock.py
Executable file
98
tools/check-compose-lock.py
Executable file
@@ -0,0 +1,98 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""compose-lock.py contract check (flake check `installer-compose-lock`).
|
||||||
|
|
||||||
|
Usage: check-compose-lock.py <compose-lock.py>
|
||||||
|
|
||||||
|
Exercises the offline lock-composer the installer runs on the target with
|
||||||
|
no network: given nomarchy's own flake.lock and the baked locked/original
|
||||||
|
node metadata, it must emit a well-formed downstream lock where
|
||||||
|
|
||||||
|
root ─▶ nomarchy (locked to the baked rev+narHash)
|
||||||
|
└─▶ every upstream node, verbatim, one level down
|
||||||
|
|
||||||
|
and where `follows` paths (list-valued inputs, absolute from the old root)
|
||||||
|
are rebased under the new `nomarchy` node. Also pins the fail-closed
|
||||||
|
guards: a locked node without a narHash, and an upstream lock that already
|
||||||
|
carries a `nomarchy` node, must both abort.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
tool = sys.argv[1]
|
||||||
|
|
||||||
|
|
||||||
|
def run(src_lock, locked, original):
|
||||||
|
"""Run compose-lock on a fixture; return (returncode, stderr, out_json|None)."""
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
src = Path(d) / "upstream.lock"
|
||||||
|
out = Path(d) / "out.lock"
|
||||||
|
src.write_text(json.dumps(src_lock))
|
||||||
|
p = subprocess.run(
|
||||||
|
[sys.executable, tool, str(src), str(out),
|
||||||
|
json.dumps(locked), json.dumps(original)],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
got = json.loads(out.read_text()) if p.returncode == 0 and out.exists() else None
|
||||||
|
return p.returncode, p.stderr, got
|
||||||
|
|
||||||
|
|
||||||
|
def check(cond, msg):
|
||||||
|
if not cond:
|
||||||
|
sys.exit(f"compose-lock contract: {msg}")
|
||||||
|
|
||||||
|
|
||||||
|
# A representative upstream lock: a plain input, and one that `follows`
|
||||||
|
# it (list-valued input — the case the rebasing logic exists for).
|
||||||
|
upstream = {
|
||||||
|
"nodes": {
|
||||||
|
"root": {"inputs": {"nixpkgs": "nixpkgs", "home-manager": "home-manager"}},
|
||||||
|
"nixpkgs": {"locked": {"type": "github", "narHash": "sha256-NP"}},
|
||||||
|
"home-manager": {
|
||||||
|
"inputs": {"nixpkgs": ["nixpkgs"]},
|
||||||
|
"locked": {"type": "github", "narHash": "sha256-HM"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7,
|
||||||
|
}
|
||||||
|
locked = {"type": "path", "path": "/nix/store/x", "narHash": "sha256-SELF", "lastModified": 1}
|
||||||
|
original = {"type": "git", "url": "https://example.invalid/nomarchy.git", "ref": "v1"}
|
||||||
|
|
||||||
|
rc, err, got = run(upstream, locked, original)
|
||||||
|
check(rc == 0, f"valid inputs were rejected (rc={rc}): {err}")
|
||||||
|
check(got is not None, "no output lock written on the happy path")
|
||||||
|
|
||||||
|
check(got["version"] == 7 and got["root"] == "root", "output is not a v7 lock rooted at 'root'")
|
||||||
|
check(got["nodes"]["root"] == {"inputs": {"nomarchy": "nomarchy"}},
|
||||||
|
f"root node must point only at nomarchy, got {got['nodes']['root']}")
|
||||||
|
|
||||||
|
nomarchy = got["nodes"].get("nomarchy")
|
||||||
|
check(nomarchy is not None, "no 'nomarchy' node in output")
|
||||||
|
check(nomarchy["locked"] == locked, "nomarchy node lost its baked locked metadata")
|
||||||
|
check(nomarchy["original"] == original, "nomarchy node lost its baked original metadata")
|
||||||
|
check(nomarchy["inputs"] == upstream["nodes"]["root"]["inputs"],
|
||||||
|
"nomarchy node did not inherit the upstream root's input set")
|
||||||
|
|
||||||
|
check(got["nodes"]["nixpkgs"] == upstream["nodes"]["nixpkgs"],
|
||||||
|
"a plain upstream node was mutated")
|
||||||
|
check(got["nodes"]["home-manager"]["inputs"]["nixpkgs"] == ["nomarchy", "nixpkgs"],
|
||||||
|
"a follows path was not rebased under the nomarchy node "
|
||||||
|
f"(got {got['nodes']['home-manager']['inputs']['nixpkgs']})")
|
||||||
|
|
||||||
|
# Fail-closed: no narHash in the baked locked metadata.
|
||||||
|
rc, err, _ = run(upstream, {"type": "path", "path": "/x"}, original)
|
||||||
|
check(rc != 0, "a locked node without a narHash was accepted")
|
||||||
|
check("narHash" in err, f"missing-narHash abort did not mention narHash: {err}")
|
||||||
|
|
||||||
|
# Fail-closed: an upstream lock that already names a nomarchy input.
|
||||||
|
poisoned = json.loads(json.dumps(upstream))
|
||||||
|
poisoned["nodes"]["nomarchy"] = {"locked": {"narHash": "sha256-X"}}
|
||||||
|
rc, err, _ = run(poisoned, locked, original)
|
||||||
|
check(rc != 0, "an upstream lock already carrying a 'nomarchy' node was accepted")
|
||||||
|
|
||||||
|
print("compose-lock contract: happy-path shape, follows rebasing, and both "
|
||||||
|
"fail-closed guards hold")
|
||||||
53
tools/check-hardware-db.py
Executable file
53
tools/check-hardware-db.py
Executable file
@@ -0,0 +1,53 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Installer hardware-DB ↔ nixos-hardware drift check (flake check
|
||||||
|
`hardware-db-modules`).
|
||||||
|
|
||||||
|
Usage: check-hardware-db.py <hardware-db.sh> <nixos-hardware-modules.txt>
|
||||||
|
|
||||||
|
Every nixos-hardware module name the installer references — the third
|
||||||
|
`|`-field of each HARDWARE_DB table row, plus the literal `common-*`
|
||||||
|
modules the CPU/GPU/chassis autodetect emits — must exist in the pinned
|
||||||
|
`nixos-hardware.nixosModules`. A lock bump can rename a module upstream
|
||||||
|
(the X1 Carbon → `x1-Nth-gen` churn is the canonical example) and silently
|
||||||
|
break profiled installs on exactly the machines whose DMI matches — a
|
||||||
|
class of bug that never shows up in a VM. `nixos-hardware-modules.txt` is
|
||||||
|
generated at eval time in flake.nix from `attrNames
|
||||||
|
nixos-hardware.nixosModules`, so the "what actually exists" side can't go
|
||||||
|
stale by itself.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
db_file, modules_file = sys.argv[1], sys.argv[2]
|
||||||
|
|
||||||
|
available = {l.strip() for l in open(modules_file) if l.strip()}
|
||||||
|
db = open(db_file).read()
|
||||||
|
|
||||||
|
referenced = {} # module name -> short reason (first seen)
|
||||||
|
|
||||||
|
# HARDWARE_DB rows: "sys_vendor_regex | product_regex | module". The first
|
||||||
|
# two fields are bash regexes (may contain anything but a literal quote);
|
||||||
|
# the module name is a plain nixos-hardware attr (lower-case, dashes).
|
||||||
|
for vendor, product, module in re.findall(
|
||||||
|
r'"([^"|]*)\|([^"|]*)\|([a-z0-9][a-z0-9-]*)"', db
|
||||||
|
):
|
||||||
|
referenced.setdefault(module, f"HARDWARE_DB row ({vendor.strip()} {product.strip()})")
|
||||||
|
|
||||||
|
# Literal `common-*` (and any other bare-literal) modules emitted by the
|
||||||
|
# autodetect via `echo "MODULE <name>"`. The `$mod` variable form is the
|
||||||
|
# HARDWARE_DB table above and is skipped here.
|
||||||
|
for module in re.findall(r'echo "MODULE ([a-z0-9][a-z0-9-]*)"', db):
|
||||||
|
referenced.setdefault(module, "autodetect MODULE line")
|
||||||
|
|
||||||
|
if not referenced:
|
||||||
|
sys.exit("hardware-db: parsed no module references — parser or file changed")
|
||||||
|
|
||||||
|
missing = sorted(m for m in referenced if m not in available)
|
||||||
|
if missing:
|
||||||
|
print(f"hardware-db: {len(missing)} module(s) not in nixos-hardware.nixosModules")
|
||||||
|
for m in missing:
|
||||||
|
print(f" {m} — referenced by {referenced[m]}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"hardware-db: all {len(referenced)} referenced modules exist in nixos-hardware")
|
||||||
73
tools/check-windowrule-syntax.py
Executable file
73
tools/check-windowrule-syntax.py
Executable file
@@ -0,0 +1,73 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Hyprland windowrule dead-syntax guard (flake check `windowrule-syntax`).
|
||||||
|
|
||||||
|
Usage: check-windowrule-syntax.py <hyprland.conf>
|
||||||
|
|
||||||
|
Hyprland 0.53 rewrote window rules (hyprlang `handleWindowrule`): the
|
||||||
|
`windowrulev2` keyword is a hard error, and the old rule-first order
|
||||||
|
(`windowrule = float, class:^…$`) no longer parses. Both surface as a red
|
||||||
|
config-error banner on every session start and silently drop the rules
|
||||||
|
(fixed in ed7fd93). The current, correct grammar is
|
||||||
|
`windowrule = <effect> <value>, match:<prop> ^…$` — e.g.
|
||||||
|
`windowrule = float 1, match:class ^…$`.
|
||||||
|
|
||||||
|
This guards the GENERATED hyprland.conf against a regression to the dead
|
||||||
|
syntax, catching three markers:
|
||||||
|
|
||||||
|
1. the `windowrulev2` keyword anywhere;
|
||||||
|
2. rule-first order — an effect keyword directly followed by a comma
|
||||||
|
(`windowrule = float, …` / `center, …` / `size, …`), i.e. no value;
|
||||||
|
3. a bare `<prop>:` matcher (`class:`, `title:`, …) instead of the
|
||||||
|
`match:<prop>` form — this also catches an effect that DOES carry a
|
||||||
|
value but kept the old matcher (`size 60% 65%, class:^…$`).
|
||||||
|
|
||||||
|
The current correct config must PASS; any of the above must FAIL.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
conf = sys.argv[1]
|
||||||
|
lines = open(conf).read().splitlines()
|
||||||
|
|
||||||
|
# Effect keywords that in the dead grammar sat directly before the comma.
|
||||||
|
EFFECTS = (
|
||||||
|
"float", "center", "tile", "pin", "fullscreen", "maximize",
|
||||||
|
"size", "move", "opacity", "workspace", "monitor", "fullscreenstate",
|
||||||
|
"group", "stayfocused", "pseudo", "immediate",
|
||||||
|
)
|
||||||
|
rule_first = re.compile(
|
||||||
|
r"windowrule\s*=\s*(?:" + "|".join(EFFECTS) + r")\s*,"
|
||||||
|
)
|
||||||
|
# A window-property matcher written the old (colon-suffixed) way. The new
|
||||||
|
# grammar spells these `match:class …` — "class" is then followed by a
|
||||||
|
# space, never a colon — so a bare `class:`/`title:` only ever appears in
|
||||||
|
# the dead form.
|
||||||
|
bare_matcher = re.compile(
|
||||||
|
r"\b(?:initialclass|initialtitle|class|title|tag|xwayland|floating)\s*:",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
failures = []
|
||||||
|
for n, line in enumerate(lines, 1):
|
||||||
|
stripped = line.strip()
|
||||||
|
if stripped.startswith("#"):
|
||||||
|
continue
|
||||||
|
if "windowrulev2" in line:
|
||||||
|
failures.append((n, "dead `windowrulev2` keyword", stripped))
|
||||||
|
continue
|
||||||
|
if not re.match(r"\s*windowrule\s*=", line):
|
||||||
|
continue
|
||||||
|
if rule_first.search(line):
|
||||||
|
failures.append((n, "rule-first order (effect directly before comma)", stripped))
|
||||||
|
if bare_matcher.search(line):
|
||||||
|
failures.append((n, "bare `<prop>:` matcher — expected `match:<prop>`", stripped))
|
||||||
|
|
||||||
|
if failures:
|
||||||
|
print(f"windowrule-syntax: {len(failures)} dead-syntax rule(s) in {conf}")
|
||||||
|
for n, why, text in failures:
|
||||||
|
print(f" line {n}: {why}\n {text}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
rules = sum(1 for l in lines if re.match(r"\s*windowrule\s*=", l))
|
||||||
|
print(f"windowrule-syntax: {rules} windowrule line(s), all post-0.53 grammar")
|
||||||
Reference in New Issue
Block a user