From 401057fb2ff0ec2c073aa2cad139c56fc99262e8 Mon Sep 17 00:00:00 2001 From: parit singla Date: Fri, 28 Aug 2026 18:00:47 +0530 Subject: [PATCH] schema: add diff-impact CLI (Phase 2+3 of schema-diff) Cross-references what changed between two schemas against what a specific config actually uses (hyprvalidate/schema/impact.py), rather than reporting Hyprland's whole API surface diff - the difference between a usable feature and noise, given how large that surface is. New `hyprvalidate diff-impact --from A.json --to B.json` command, informational by default (exit 0 even when it finds something) since this reports what changed in the schema, not confirmed breakage; --fail-on-impact opts a CI job into gating on it. Verified end-to-end against the real hl.permission{} allow -> mode regression (Hyprland#14400): a config constructing hl.permission({ allow = ... }) is flagged with the rename guess and nothing else; a config that never touches hl.permission at all, diffed against the exact same two schema versions, reports nothing. Documented gap, not silently overclaimed: this only sees fields *written* into a spec-table constructor (hl.monitor({ mode = ... })), not fields *read* off a runtime object handed to a callback (e.g. win.class inside an hl.on(...) handler) - which is the actual shape of the real HL.Window over_fullscreen regression this feature was scoped around. Tracing that needs real type-flow inference (which event name a handler is registered for, what payload type that event carries) this doesn't attempt. --- CHANGELOG.md | 23 ++- README.md | 25 ++++ hyprvalidate/cli.py | 104 ++++++++++++++ hyprvalidate/schema/impact.py | 260 ++++++++++++++++++++++++++++++++++ tests/test_cli.py | 47 +++++- tests/test_schema_impact.py | 144 +++++++++++++++++++ 6 files changed, 600 insertions(+), 3 deletions(-) create mode 100644 hyprvalidate/schema/impact.py create mode 100644 tests/test_schema_impact.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a32ade5..c67e7cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 55146b1..b5e101e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/hyprvalidate/cli.py b/hyprvalidate/cli.py index dbefdc2..092a4d7 100644 --- a/hyprvalidate/cli.py +++ b/hyprvalidate/cli.py @@ -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" @@ -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]]: @@ -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": @@ -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 diff --git a/hyprvalidate/schema/impact.py b/hyprvalidate/schema/impact.py new file mode 100644 index 0000000..1d32ff7 --- /dev/null +++ b/hyprvalidate/schema/impact.py @@ -0,0 +1,260 @@ +"""Phase 2 of schema-diff: given a `diff.SchemaDiff` and a real parsed +config, report only the subset of what changed that the config actually +uses - not "353 things exist now," but "you use 2 of the 11 things that +changed." + +This is what makes a schema diff a usable product rather than noise: +Hyprland's API is large and the diff between any two versions touches a lot +of surface most configs never go near. Cross-referencing against what a +specific file actually references is the whole point. + +Three kinds of usage are collected, each schema-independent except the +third: + - config keys used in `hl.config({...})` blocks (dotted paths, same + key-path logic `checker._walk_config_table` uses for validation - this + module has its own copy because collecting "what's used" and checking + "is it valid" are different shapes, even though they walk the same + tree) + - every dotted `hl.*` symbol a Call/Invoke actually targets + - spec-table field names used inside a call whose parameter resolves (via + the CURRENT schema - the one the config is actually written against + today, not the target of the diff) to a known schema class, e.g. + `hl.monitor({ mode = ... })` -> fields used on `HL.MonitorSpec`. + Reuses `checker.resolve_symbol`/`checker.parse_fun_signature` rather + than re-deriving signature resolution. + +Every reported item is phrased as "this changed in the schema, and your +config uses it" - never "this will break". See `diff.py`'s module +docstring for why: the schema doesn't always accurately reflect real +runtime behavior at every point in time, in both directions. + +Known, deliberate gap: this only sees fields *written* into a literal +table constructor (a spec table you build and pass in, e.g. +`hl.monitor({ mode = ... })`) - it does NOT see fields *read* off a +runtime object handed to a callback, e.g. `win.class` inside +`hl.on("window.opened", function(win) ... end)`. That second pattern is +exactly the shape of the real HL.Window `over_fullscreen` regression this +whole feature was scoped around - and it's invisible here by construction, +because knowing `win`'s type requires tracing which event name the +callback is registered for and what payload type that event carries, +which is real type-flow inference this module doesn't attempt. Config-key +usage and constructed-spec-table usage (HL.MonitorSpec, HL.PermissionSpec, +HL.BindOptions, etc.) are what's actually covered; anything read off a +callback parameter is not. Documented here rather than silently +overclaiming coverage. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from luaparser.astnodes import Call, Invoke, Table + +from hyprvalidate.luaast import reader +from hyprvalidate.schema.extractor import Schema +from hyprvalidate.schema.diff import SchemaDiff, PossibleRename, ConfigKeyTypeChange, FieldTypeChange +from hyprvalidate.checker import resolve_symbol, parse_fun_signature + + +@dataclass +class UsedSymbols: + config_keys: set[str] = field(default_factory=set) + dotted_calls: set[str] = field(default_factory=set) + # class name -> set of field names used in a spec-table argument of that class + spec_table_fields: dict[str, set[str]] = field(default_factory=dict) + + +def _field_key_name(key_expr) -> str | None: + from luaparser.astnodes import Name, String + + if hasattr(key_expr, "id"): + return key_expr.id + if hasattr(key_expr, "s"): + s = key_expr.s + return s.decode() if isinstance(s, bytes) else s + return None + + +def _collect_config_keys(table: Table, prefix: str, out: set[str]) -> None: + for f in table.fields: + if f.key is None: + continue + key_name = _field_key_name(f.key) + if key_name is None: + continue + key_path = f"{prefix}.{key_name}" if prefix else key_name + out.add(key_path) + if isinstance(f.value, Table): + _collect_config_keys(f.value, key_path, out) + + +def _collect_spec_table_fields(table: Table, out: set[str]) -> None: + for f in table.fields: + if f.key is None: + continue + key_name = _field_key_name(f.key) + if key_name is not None: + out.add(key_name) + + +def find_used_symbols(schema: Schema, tree) -> UsedSymbols: + """Walk a parsed config once and collect everything it references that + a schema diff could possibly be relevant to.""" + used = UsedSymbols() + + for node in reader.walk(tree): + if not isinstance(node, (Call, Invoke)): + continue + dotted = reader.resolve_dotted_name(node.func) + if dotted is None: + continue + used.dotted_calls.add(dotted) + + if dotted == "hl.config" and node.args and isinstance(node.args[0], Table): + _collect_config_keys(node.args[0], "", used.config_keys) + continue + + is_valid, _reason, sig_type_expr = resolve_symbol(schema, dotted) + if not is_valid or sig_type_expr is None: + continue + sig = parse_fun_signature(sig_type_expr) + if sig is None: + continue + for idx, param in enumerate(sig.params): + if param.type_expr not in schema.classes or idx >= len(node.args): + continue + arg = node.args[idx] + if isinstance(arg, Table): + fields_used = used.spec_table_fields.setdefault(param.type_expr, set()) + _collect_spec_table_fields(arg, fields_used) + + return used + + +@dataclass +class AffectedConfigKey: + key: str + kind: str # "removed" | "type_changed" | "possible_rename" + detail: str + + +@dataclass +class AffectedClassField: + class_name: str + field: str + kind: str # "removed" | "type_changed" | "possible_rename" + detail: str + + +@dataclass +class AffectedClass: + class_name: str + used_via: set[str] # dotted call(s) that resolve into this now-removed class + + +@dataclass +class ImpactReport: + affected_config_keys: list[AffectedConfigKey] = field(default_factory=list) + affected_class_fields: list[AffectedClassField] = field(default_factory=list) + affected_classes: list[AffectedClass] = field(default_factory=list) + + def is_empty(self) -> bool: + return not (self.affected_config_keys or self.affected_class_fields or self.affected_classes) + + +def _resolve_containing_class(schema: Schema, dotted_name: str) -> tuple[str, str] | None: + """Same traversal `checker.resolve_symbol` does, but returns + (containing_class_name, leaf_field_name) instead of validity - e.g. + "hl.dsp.window.close" -> ("HL.DspWindowNamespace", "close"). Returns + None for anything that doesn't resolve to a class field at all (not an + hl.* symbol, or doesn't fully resolve).""" + segments = dotted_name.split(".") + if segments[0] != "hl" or len(segments) < 2: + return None + + current_class = "HL.API" + for seg in segments[1:-1]: + cls = schema.classes.get(current_class) + if cls is None: + return None + type_expr = cls.fields.get(seg) + if type_expr is None or type_expr not in schema.classes: + return None + current_class = type_expr + + return current_class, segments[-1] + + +def compute_impact(schema: Schema, diff: SchemaDiff, used: UsedSymbols) -> ImpactReport: + """Cross-reference a SchemaDiff against what a config actually uses. + `schema` is the CURRENT schema (the one `used` was collected against) - + needed to resolve which class a dotted call's field belongs to.""" + report = ImpactReport() + + for key in sorted(used.config_keys): + rename = next((r for r in diff.config_key_possible_renames if r.old_name == key), None) + if rename is not None: + # A rename guess already states the "no longer exists" fact - + # reporting the plain removal too would just say the same thing + # twice, not add honesty. + report.affected_config_keys.append(AffectedConfigKey( + key=key, kind="possible_rename", + detail=f"'{key}' no longer exists - possibly renamed to " + f"'{rename.new_name}' (heuristic guess, not confirmed - " + f"check the Hyprland changelog for the target version)", + )) + elif key in diff.config_keys_removed: + report.affected_config_keys.append(AffectedConfigKey( + key=key, kind="removed", + detail=f"'{key}' no longer exists in the target schema " + f"(was: {diff.config_keys_removed[key]})", + )) + for change in diff.config_keys_type_changed: + if change.key == key: + report.affected_config_keys.append(AffectedConfigKey( + key=key, kind="type_changed", + detail=f"'{key}' changes from {change.old_type} to " + f"{change.new_type} in the target schema", + )) + + for class_name, fields_used in used.spec_table_fields.items(): + class_diff = diff.class_diffs.get(class_name) + if class_diff is None: + continue + for used_field in sorted(fields_used): + rename = next((r for r in class_diff.possible_renames if r.old_name == used_field), None) + if rename is not None: + report.affected_class_fields.append(AffectedClassField( + class_name=class_name, field=used_field, kind="possible_rename", + detail=f"'{used_field}' on {class_name} no longer exists - " + f"possibly renamed to '{rename.new_name}' (heuristic " + f"guess, not confirmed)", + )) + elif used_field in class_diff.fields_removed: + report.affected_class_fields.append(AffectedClassField( + class_name=class_name, field=used_field, kind="removed", + detail=f"'{used_field}' on {class_name} no longer exists in " + f"the target schema (was: {class_diff.fields_removed[used_field]})", + )) + for change in class_diff.fields_type_changed: + if change.name == used_field: + report.affected_class_fields.append(AffectedClassField( + class_name=class_name, field=used_field, kind="type_changed", + detail=f"'{used_field}' on {class_name} changes from " + f"{change.old_type} to {change.new_type} in the target schema", + )) + + classes_removed = set(diff.classes_removed) + if classes_removed: + by_class: dict[str, set[str]] = {} + for dotted in sorted(used.dotted_calls): + resolved = _resolve_containing_class(schema, dotted) + if resolved is None: + continue + containing_class, _leaf = resolved + if containing_class in classes_removed: + by_class.setdefault(containing_class, set()).add(dotted) + for class_name, via in sorted(by_class.items()): + report.affected_classes.append(AffectedClass(class_name=class_name, used_via=via)) + + return report diff --git a/tests/test_cli.py b/tests/test_cli.py index ce00102..2f0f3e0 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -18,7 +18,7 @@ _LIVE_STUB = Path("/usr/share/hypr/stubs/hl.meta.lua") SCHEMA_PATH = str(_LIVE_STUB if _LIVE_STUB.is_file() else Path(__file__).parent.parent / "schema.json") -from hyprvalidate.cli import check_files, convert_file +from hyprvalidate.cli import check_files, convert_file, diff_impact_files REPO_ROOT = Path(__file__).parent.parent REAL_KEYBINDS = FIXTURES / "hyprland-lua" / "keybinds.lua" @@ -329,3 +329,48 @@ def _boom(*args, **kwargs): lua_file = tmp_path / "x.lua" lua_file.write_text(VALID_SNIPPET) assert cli.main(["check", str(lua_file), "--stub", SCHEMA_PATH]) == 2 + + +SCHEMAS_DIR = REPO_ROOT / "schemas" +PERMISSION_SNIPPET = 'hl.permission({ allow = "yes", binary = "steam", type = "screencopy" })\n' + + +def test_diff_impact_reports_a_real_regression_and_exits_zero_by_default(tmp_path): + """The real allow -> mode regression (hyprwm/Hyprland#14400) - + informational by default: exit 0 even though it found something, since + this reports what changed, not confirmed breakage.""" + f = tmp_path / "perm.lua" + f.write_text(PERMISSION_SNIPPET) + code, lines = diff_impact_files( + [f], str(SCHEMAS_DIR / "v0.55.0.json"), str(SCHEMAS_DIR / "v0.55.1.json") + ) + assert code == 0 + assert any("possible_rename" in line and "mode" in line for line in lines) + + +def test_diff_impact_fail_on_impact_exits_one(tmp_path): + f = tmp_path / "perm.lua" + f.write_text(PERMISSION_SNIPPET) + code, lines = diff_impact_files( + [f], str(SCHEMAS_DIR / "v0.55.0.json"), str(SCHEMAS_DIR / "v0.55.1.json"), + fail_on_impact=True, + ) + assert code == 1 + + +def test_diff_impact_unrelated_config_reports_nothing(tmp_path): + f = tmp_path / "ok.lua" + f.write_text(VALID_SNIPPET) + code, lines = diff_impact_files( + [f], str(SCHEMAS_DIR / "v0.55.0.json"), str(SCHEMAS_DIR / "v0.55.1.json") + ) + assert code == 0 + assert "nothing they use changed" in lines[-1] + + +def test_diff_impact_missing_schema_exits_two(tmp_path): + f = tmp_path / "ok.lua" + f.write_text(VALID_SNIPPET) + code, lines = diff_impact_files([f], "/nonexistent/a.json", str(SCHEMAS_DIR / "v0.55.1.json")) + assert code == 2 + assert "--from" in lines[0] diff --git a/tests/test_schema_impact.py b/tests/test_schema_impact.py new file mode 100644 index 0000000..4638dbb --- /dev/null +++ b/tests/test_schema_impact.py @@ -0,0 +1,144 @@ +"""Tests for hyprvalidate.schema.impact - cross-referencing a SchemaDiff +against what a real config actually uses. The rename/removal cases are +tested against the real corpus (same policy as test_schema_diff.py); the +config-key-removed and class-removed paths use small hand-built schemas +since no real config key or class was ever removed across the 8 real +Hyprland versions this project has - those code paths still need covering, +just not from history that doesn't exist yet. +""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +SCHEMAS_DIR = Path(__file__).parent.parent / "schemas" + +from hyprvalidate.schema.extractor import Schema, ClassInfo +from hyprvalidate.schema.diff import diff_schemas +from hyprvalidate.schema.impact import find_used_symbols, compute_impact +from hyprvalidate.luaast import reader + + +def _load(version: str) -> Schema: + path = SCHEMAS_DIR / f"{version}.json" + assert path.is_file(), f"expected the real schema corpus at {path}" + return Schema.from_json(path.read_text()) + + +def test_real_permission_rename_is_reported_only_once_when_used(): + """The real allow -> mode regression, from a config that actually + constructs a permission spec table using the old field name. Must + report the rename guess, and must NOT also separately report a plain + "removed" for the same field - that would just say the same fact twice.""" + old, new = _load("v0.55.0"), _load("v0.55.1") + diff = diff_schemas(old, new) + tree = reader.parse('hl.permission({ allow = "yes", binary = "foo", type = "bar" })') + used = find_used_symbols(old, tree) + report = compute_impact(old, diff, used) + + assert len(report.affected_class_fields) == 1 + hit = report.affected_class_fields[0] + assert hit.class_name == "HL.PermissionSpec" + assert hit.field == "allow" + assert hit.kind == "possible_rename" + assert "mode" in hit.detail + + +def test_real_permission_rename_is_not_reported_when_config_does_not_use_it(): + """A config that never touches hl.permission at all - the diff between + these two versions is real, but irrelevant to this specific file.""" + old, new = _load("v0.55.0"), _load("v0.55.1") + diff = diff_schemas(old, new) + tree = reader.parse('hl.bind("SUPER + Q", hl.dsp.window.close())') + used = find_used_symbols(old, tree) + report = compute_impact(old, diff, used) + assert report.is_empty() + + +def test_real_monitor_scale_widening_is_reported_as_type_changed(): + """HL.MonitorSpec.scale: string -> string|number (v0.55.0 -> v0.55.1) - + a real, backward-compatible widening. Still reported (this module + reports facts, not severity judgments).""" + old, new = _load("v0.55.0"), _load("v0.55.1") + diff = diff_schemas(old, new) + tree = reader.parse('hl.monitor({ output = "eDP-1", scale = "1.5" })') + used = find_used_symbols(old, tree) + report = compute_impact(old, diff, used) + + scale_hits = [f for f in report.affected_class_fields if f.field == "scale"] + assert len(scale_hits) == 1 + assert scale_hits[0].kind == "type_changed" + assert scale_hits[0].class_name == "HL.MonitorSpec" + + +def test_config_key_removal_is_detected_when_used(): + old = Schema(config_value_types={"input.accel_profile": "string", "general.gaps_in": "integer"}) + new = Schema(config_value_types={"general.gaps_in": "integer"}) + diff = diff_schemas(old, new) + + tree = reader.parse('hl.config({ input = { accel_profile = "flat" }, general = { gaps_in = 5 } })') + used = find_used_symbols(old, tree) + assert used.config_keys == {"input", "input.accel_profile", "general", "general.gaps_in"} + + report = compute_impact(old, diff, used) + assert len(report.affected_config_keys) == 1 + hit = report.affected_config_keys[0] + assert hit.key == "input.accel_profile" + assert hit.kind == "removed" + + +def test_config_key_rename_guess_is_reported_and_not_duplicated(): + old = Schema(config_value_types={"input.old_name": "string"}) + new = Schema(config_value_types={"input.new_name": "string"}) + diff = diff_schemas(old, new) + + tree = reader.parse('hl.config({ input = { old_name = "x" } })') + used = find_used_symbols(old, tree) + report = compute_impact(old, diff, used) + + assert len(report.affected_config_keys) == 1 + hit = report.affected_config_keys[0] + assert hit.kind == "possible_rename" + assert "new_name" in hit.detail + + +def test_removed_class_used_via_a_call_is_detected(): + old = Schema(classes={ + "HL.API": ClassInfo(name="HL.API", fields={"legacy": "HL.LegacyNamespace"}), + "HL.LegacyNamespace": ClassInfo(name="HL.LegacyNamespace", fields={"do_thing": "fun(): nil"}), + }) + new = Schema(classes={ + "HL.API": ClassInfo(name="HL.API", fields={}), + }) + diff = diff_schemas(old, new) + assert "HL.LegacyNamespace" in diff.classes_removed + + tree = reader.parse("hl.legacy.do_thing()") + used = find_used_symbols(old, tree) + report = compute_impact(old, diff, used) + + assert len(report.affected_classes) == 1 + assert report.affected_classes[0].class_name == "HL.LegacyNamespace" + assert "hl.legacy.do_thing" in report.affected_classes[0].used_via + + +def test_unrelated_removed_class_is_not_reported_when_unused(): + old = Schema(classes={ + "HL.API": ClassInfo(name="HL.API", fields={ + "legacy": "HL.LegacyNamespace", "dsp": "HL.DspNamespace", + }), + "HL.LegacyNamespace": ClassInfo(name="HL.LegacyNamespace", fields={"do_thing": "fun(): nil"}), + "HL.DspNamespace": ClassInfo(name="HL.DspNamespace", fields={"exec_cmd": "fun(...): HL.Dispatcher"}), + }) + new = Schema(classes={ + "HL.API": ClassInfo(name="HL.API", fields={"dsp": "HL.DspNamespace"}), + "HL.DspNamespace": ClassInfo(name="HL.DspNamespace", fields={"exec_cmd": "fun(...): HL.Dispatcher"}), + }) + diff = diff_schemas(old, new) + assert "HL.LegacyNamespace" in diff.classes_removed + + tree = reader.parse('hl.bind("SUPER + Q", hl.dsp.exec_cmd("foo"))') + used = find_used_symbols(old, tree) + report = compute_impact(old, diff, used) + assert report.affected_classes == []