Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,27 @@ Notable changes. Format loosely follows [Keep a Changelog](https://keepachangelo

### Added

- **Foundation for schema-diff** (no CLI surface yet — this lays the
groundwork, doesn't expose it): `hyprvalidate/schema/diff.py` structurally
- **`hyprvalidate diff-impact`** — Phase 2/3 of schema-diff, building on the
foundation below: cross-references what changed between two schemas
against what a specific config *actually uses* (`hyprvalidate/schema/impact.py`),
instead of reporting Hyprland's whole API surface diff. Verified against
the real `hl.permission{}` `allow` → `mode` regression: a config that
constructs `hl.permission({ allow = ... })` gets flagged; a config that
never touches `hl.permission` at all doesn't, even diffed against the
exact same two schema versions. Exits `0` by default even when it finds
something — this reports what changed, it doesn't confirm breakage — with
`--fail-on-impact` to opt a CI job into gating on it.

Known, documented gap: only sees fields *written* into a spec-table
constructor you build and pass in (`hl.monitor({ mode = ... })`), not
fields *read* off a runtime object handed to a callback (e.g. `win.class`
inside an `hl.on(...)` handler) — that second pattern is the actual shape
of the real `HL.Window` `over_fullscreen` regression this feature was
scoped around, and it's invisible here by construction: knowing what type
a callback parameter carries needs real type-flow inference this doesn't
attempt. Not silently overclaimed.

- **Foundation for schema-diff**: `hyprvalidate/schema/diff.py` structurally
compares two `Schema` snapshots — classes/fields/aliases/config-keys
added, removed, or type-changed — plus a conservative "possible rename"
heuristic that only fires on a clean 1:1 match (exactly one removed name
Expand Down
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,31 @@ two jobs, split by trigger:
- run: hyprvalidate check hypr/ --stub schema.json
```

### Checking whether an upcoming Hyprland update will affect you

`check` validates against one schema — "is my config valid right now."
`diff-impact` answers a different question: "of everything that changed
between two Hyprland versions, which parts does *my* config actually use?"
Hyprland's Lua config API is still young and has already shipped silent
breaking renames (e.g. `hl.permission{}`'s `allow` field became `mode` one
day after Lua config first shipped) — `diff-impact` cross-references a
config against a real schema diff instead of waiting to find out the hard
way:

```console
$ hyprvalidate diff-impact hyprland.lua --from schemas/v0.55.0.json --to schemas/v0.55.1.json
hyprland.lua: [possible_rename] 'allow' on HL.PermissionSpec no longer exists - possibly renamed to 'mode' (heuristic guess, not confirmed)

1 change(s) in the target schema affect these file(s) - not confirmed breakage, just what's worth checking before updating.
```

`schemas/` in this repo has one real schema per tagged Hyprland release
(v0.55.0 onward) to diff between. Every line is phrased as "this changed in
the schema," never "this will break" — the generated stub has itself lagged
real Hyprland behavior before, so a diff is evidence worth checking, not
proof of breakage. Exits `0` even when it finds something, since this isn't
a confirmed problem — pass `--fail-on-impact` to gate a build on it instead.

## Why this exists

Four community converters already existed when this started. Testing all four
Expand Down
104 changes: 104 additions & 0 deletions hyprvalidate/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
from hyprvalidate.hyprlang import parser as hyprlang_parser
from hyprvalidate.converter import mapper
from hyprvalidate.schema.extractor import load_schema
from hyprvalidate.schema.diff import diff_schemas
from hyprvalidate.schema.impact import find_used_symbols, compute_impact
from hyprvalidate import checker, fixer

DEFAULT_STUB_PATH = "/usr/share/hypr/stubs/hl.meta.lua"
Expand Down Expand Up @@ -131,6 +133,71 @@ def check_files(paths: list[Path], stub_path: str, fix: bool = False) -> tuple[i
return 0, lines


def diff_impact_files(
paths: list[Path], from_stub: str, to_stub: str, fail_on_impact: bool = False,
) -> tuple[int, list[str]]:
"""Cross-reference what changed between two schemas (`from_stub` ->
`to_stub`) against what each file actually uses. Informational by
default (exit 0) even when it finds something - `fail_on_impact` opts
into treating a non-empty report as a failure, for a CI job that wants
this to gate rather than just warn. Every line is phrased as "this
changed in the schema", never "this will break" - see
hyprvalidate.schema.diff's module docstring for why that distinction
is deliberate, not just careful wording."""
lines: list[str] = []

for label, stub_path in (("--from", from_stub), ("--to", to_stub)):
if not Path(stub_path).is_file():
return 2, [f"error: {label} schema not found at {stub_path}"]

paths, path_errors = _expand_paths(paths)
if path_errors:
return 2, path_errors

old_schema = load_schema(from_stub)
new_schema = load_schema(to_stub)
diff = diff_schemas(old_schema, new_schema)

syntax_failed = False
total_hits = 0

for path in paths:
luac_result = luac_gate.check_file(path)
if not luac_result.ok:
syntax_failed = True
lines.append(f"{luac_result.message}")
continue

tree = reader.parse_file(path)
used = find_used_symbols(old_schema, tree)
report = compute_impact(old_schema, diff, used)

hits = report.affected_config_keys + report.affected_class_fields
total_hits += len(hits) + len(report.affected_classes)
for hit in hits:
lines.append(f"{path}: [{hit.kind}] {hit.detail}")
for ac in report.affected_classes:
via = ", ".join(sorted(ac.used_via))
lines.append(
f"{path}: [class_removed] {ac.class_name} no longer exists in "
f"the target schema, used via: {via}"
)

if syntax_failed:
return 2, lines

if total_hits:
lines.append(
f"\n{total_hits} change(s) in the target schema affect these "
f"file(s) - not confirmed breakage, just what's worth checking "
f"before updating."
)
return (1 if fail_on_impact else 0), lines

lines.append(f"{len(paths)} file(s) checked - nothing they use changed between these schemas.")
return 0, lines


def convert_file(
path: Path, stub_path: str, output: Path | None, split: Path | None = None
) -> tuple[int, list[str]]:
Expand Down Expand Up @@ -277,6 +344,31 @@ def main(argv: list[str] | None = None) -> int:
"hyprland.lua entry point that require()s them",
)

diff_parser = subparsers.add_parser(
"diff-impact",
help=(
"check which changes between two Hyprland schema versions "
"affect what a config actually uses"
),
)
diff_parser.add_argument("files", nargs="+", type=Path, help=".lua file(s) to check")
diff_parser.add_argument(
"--from", dest="from_stub", required=True, metavar="SCHEMA",
help="schema.json (or hl.meta.lua stub) the config is written against today",
)
diff_parser.add_argument(
"--to", dest="to_stub", required=True, metavar="SCHEMA",
help="schema.json (or hl.meta.lua stub) to check for upcoming changes",
)
diff_parser.add_argument(
"--fail-on-impact", action="store_true",
help=(
"exit 1 if anything relevant changed (default: exit 0 - this "
"reports what changed, it doesn't confirm your config will "
"actually break, so it doesn't gate a build unless you ask it to)"
),
)

args = parser.parse_args(argv)

if args.command == "check":
Expand All @@ -301,6 +393,18 @@ def main(argv: list[str] | None = None) -> int:
print(line)
return exit_code

if args.command == "diff-impact":
try:
exit_code, lines = diff_impact_files(
args.files, args.from_stub, args.to_stub, args.fail_on_impact
)
except LuacNotFound as exc:
print(f"error: {exc}", file=sys.stderr)
return 2
for line in lines:
print(line)
return exit_code

parser.error(f"unknown command {args.command!r}")
return 2

Expand Down
Loading
Loading