From c2fb97b08ba126c5413610f2084ffd09e1bd6745 Mon Sep 17 00:00:00 2001 From: Conduction Release Bot Date: Sat, 8 Aug 2026 14:11:30 +0200 Subject: [PATCH] fix(gate-54): a relation nested in an array-of-objects is still a property MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Check (c) reported a CORRECTLY shaped relation as "placed off a property" whenever it sat inside an array of objects. Observed on larpingapp: character.skillOverrides.items.properties.skill { "type": "string", "format": "uuid", "$ref": "skill", "x-relation-filter": { "setting": "@object.setting" } } That is byte-for-byte the shape the gate accepts one level up on character.skills. The only difference is nesting depth. Cause: check (c) works by identity. _raw_walk() flags every x-relation-filter whose containing dict is not in property_ids, and property_ids was built from schema["properties"] in a single non-recursive pass. A nested property could therefore never be in the set, so the finding was unconditional — and unfixable in the app: moving the filter off the property IS the rule-6 violation, flattening the array is a schema redesign to satisfy a linter, and deleting the filter loses the setting-scoped picker. _collect_nested_property_ids() registers property dicts below a top-level property, through items.properties and inline properties, bounded at depth 8 (a register file is JSON so it cannot cycle; the hazard is pathological depth, and past the bound the walk over-reports rather than under-reports). test_check_relation_dialect.py is new — the helper had no suite, so run-helper-suites.sh will now execute one. Every accepted case is paired with an arm that must still be REPORTED, so the suite cannot pass against a checker that returns nothing: 1 top-level relation accepted (harness positive control) 2 relation in items.properties accepted (the regression) 3 filter on a non-property items node STILL REPORTED 4 filter in a schema-level x-* block STILL REPORTED 5 relation two array levels deep accepted 6 pathological depth terminates and over-reports Can-fail proof: removing the _collect_nested_property_ids() call turns cases 2 and 5 red with the exact 'placed off a property' text; restoring it returns the suite to 6 passed, 0 failed. Measured on real trees — larpingapp full-tree gate-54 drops from 4 findings to 3, and its own lib/Settings/larpingapp_register.json goes clean; the remaining findings are genuine ones in register.d/ fragments. Closes #231 --- .../scripts/lib/check_relation_dialect.py | 42 ++++ .../lib/test_check_relation_dialect.py | 217 ++++++++++++++++++ 2 files changed, 259 insertions(+) create mode 100644 hydra-gates/scripts/lib/test_check_relation_dialect.py diff --git a/hydra-gates/scripts/lib/check_relation_dialect.py b/hydra-gates/scripts/lib/check_relation_dialect.py index 21ff64a4..a00f86a6 100644 --- a/hydra-gates/scripts/lib/check_relation_dialect.py +++ b/hydra-gates/scripts/lib/check_relation_dialect.py @@ -337,6 +337,46 @@ def _has_uuid_format(prop): return False +def _collect_nested_property_ids(prop, out, _depth=0): + """Register property dicts nested BELOW a top-level schema property. + + A relation can legitimately live inside an array of objects — e.g. + `character.skillOverrides.items.properties.skill`, which carries + type/format/$ref and its `x-relation-filter` on the property itself, + byte-for-byte the same shape the gate accepts one level up. + + Check (c) works by identity: `_raw_walk()` flags every `x-relation-filter` + whose containing dict is not in `property_ids`. That set used to be built + from `schema["properties"]` only — a single, non-recursive pass — so a + correctly-shaped nested relation was structurally unrepresentable to the + check and reported as "placed off a property" unconditionally. The finding + was unfixable in the app: the three ways to clear it (move the filter off + the property, flatten the array-of-objects, or drop the filter) are all + worse than the code being flagged. + + Observed 2026-08-08 on larpingapp (ConductionNL/.github#231). + + `_depth` bounds the walk; schemas nest a couple of levels at most, and an + unbounded recursion here would be a denial-of-service on a malformed doc. + """ + if _depth > 8 or not isinstance(prop, dict): + return + + # Arrays of objects: items.properties.* + items = prop.get("items") + if isinstance(items, dict): + _collect_nested_property_ids(items, out, _depth + 1) + + # Inline object properties: properties.* + nested = prop.get("properties") + if isinstance(nested, dict): + for nname, nprop in nested.items(): + if not isinstance(nprop, dict) or nname.startswith("@"): + continue + out.add(id(nprop)) + _collect_nested_property_ids(nprop, out, _depth + 1) + + def _resolve_ref(ref, keys): """Return ('ok'|'warn'|'fail', normalized). Numeric → warn (live-schema form). Hash form resolves to its last path segment.""" @@ -386,6 +426,8 @@ def check_file(path, keys, findings, base_ref): if not isinstance(prop, dict) or pname.startswith("@"): continue property_ids.add(id(prop)) + # A relation nested in an array-of-objects is still a property. + _collect_nested_property_ids(prop, property_ids) pline = plines.get(pname, 0) in_diff = changed is None or pline in changed diff --git a/hydra-gates/scripts/lib/test_check_relation_dialect.py b/hydra-gates/scripts/lib/test_check_relation_dialect.py new file mode 100644 index 00000000..ceb59d5a --- /dev/null +++ b/hydra-gates/scripts/lib/test_check_relation_dialect.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python3 +"""test_check_relation_dialect.py — suite for check_relation_dialect.py (gate-54). + +Picked up automatically by tests/run-helper-suites.sh (it globs +scripts/lib/test_*), so it runs in CI the moment it lands. + +The suite is written so that EVERY assertion has a paired arm that must go the +other way. A checker test that only ever feeds it clean input passes just as +well against a checker that returns nothing at all, which is the failure mode +this file exists to rule out. + +Focus: check (c), misplaced `x-relation-filter`. Before ConductionNL/.github#231 +`property_ids` was built from `schema["properties"]` in one non-recursive pass, +so a correctly-shaped relation inside an array-of-objects +(`items.properties.`) was reported as "placed off a property" — a finding +that could not be cleared without damaging correct schema. +""" + +import json +import os +import subprocess +import sys +import tempfile + +HERE = os.path.dirname(os.path.abspath(__file__)) +CHECKER = os.path.join(HERE, "check_relation_dialect.py") + +FAILURES = [] +PASSES = 0 + + +def run_checker(register_doc): + """Run the checker over a one-file register set; return its finding lines.""" + with tempfile.TemporaryDirectory() as tmp: + settings = os.path.join(tmp, "lib", "Settings") + os.makedirs(settings) + reg = os.path.join(settings, "app_register.json") + with open(reg, "w", encoding="utf-8") as fh: + json.dump(register_doc, fh, indent=2) + log = os.path.join(tmp, "findings.log") + subprocess.run( + [sys.executable, CHECKER, log, reg], + capture_output=True, + text=True, + check=False, + ) + if not os.path.exists(log): + return [] + with open(log, encoding="utf-8") as fh: + return [ln for ln in fh.read().splitlines() if ln.strip()] + + +def check(label, condition, detail=""): + global PASSES + if condition: + PASSES += 1 + else: + FAILURES.append(f"{label}{(' — ' + detail) if detail else ''}") + + +def misplaced(lines): + return [ln for ln in lines if "placed off a property" in ln] + + +RELATION = { + "type": "string", + "format": "uuid", + "$ref": "skill", + "x-relation-filter": {"setting": "@object.setting"}, + "title": "Skill", +} + + +def register(character_props): + return { + "components": { + "registers": {"app": {"slug": "app", "schemas": ["character", "skill"]}}, + "schemas": { + "skill": {"slug": "skill", "title": "Skill", "type": "object", "properties": {}}, + "character": { + "slug": "character", + "title": "Character", + "type": "object", + "properties": character_props, + }, + }, + } + } + + +# -------------------------------------------------------------------------- +# 1. Top-level relation — the shape that always worked. Positive control for +# the whole suite: if this reports "misplaced", the harness is wrong, not +# the checker. +# -------------------------------------------------------------------------- +lines = run_checker(register({"skill": dict(RELATION)})) +check( + "top-level relation with x-relation-filter is accepted", + misplaced(lines) == [], + f"got {misplaced(lines)}", +) + +# -------------------------------------------------------------------------- +# 2. THE REGRESSION (.github#231). Same relation, nested in an array of +# objects. Must be accepted for exactly the same reason as case 1. +# -------------------------------------------------------------------------- +nested = { + "skillOverrides": { + "type": "array", + "title": "Skill overrides", + "items": { + "type": "object", + "properties": { + "skill": dict(RELATION), + "reason": {"type": "string", "title": "Reason"}, + }, + }, + } +} +lines = run_checker(register(nested)) +check( + "relation inside items.properties is accepted (gate-54 nested FP)", + misplaced(lines) == [], + f"got {misplaced(lines)}", +) + +# -------------------------------------------------------------------------- +# 3. CAN-FAIL for case 2. The check must still catch a genuinely misplaced +# filter. Here the filter rides on the ARRAY WRAPPER's `items` dict, which +# is not a property — the real rule-6 violation. +# -------------------------------------------------------------------------- +genuinely_misplaced = { + "skillOverrides": { + "type": "array", + "title": "Skill overrides", + "items": { + "type": "object", + "x-relation-filter": {"setting": "@object.setting"}, + "properties": {"reason": {"type": "string", "title": "Reason"}}, + }, + } +} +lines = run_checker(register(genuinely_misplaced)) +check( + "x-relation-filter on a non-property node is still reported", + len(misplaced(lines)) == 1, + f"expected exactly 1 misplaced finding, got {misplaced(lines)}", +) + +# -------------------------------------------------------------------------- +# 4. CAN-FAIL, second form. A filter parked in an arbitrary x-* block is not +# on a property and must still be caught. +# -------------------------------------------------------------------------- +doc = register({"skill": dict(RELATION)}) +doc["components"]["schemas"]["character"]["x-openregister-extras"] = { + "x-relation-filter": {"setting": "@object.setting"} +} +lines = run_checker(doc) +check( + "x-relation-filter inside a schema-level x-* block is still reported", + len(misplaced(lines)) == 1, + f"expected exactly 1 misplaced finding, got {misplaced(lines)}", +) + +# -------------------------------------------------------------------------- +# 5. Deeply nested (array of objects containing an array of objects). The +# collector recurses, so this is accepted too. +# -------------------------------------------------------------------------- +deep = { + "groups": { + "type": "array", + "title": "Groups", + "items": { + "type": "object", + "properties": { + "entries": { + "type": "array", + "title": "Entries", + "items": {"type": "object", "properties": {"skill": dict(RELATION)}}, + } + }, + }, + } +} +lines = run_checker(register(deep)) +check( + "relation nested two array levels deep is accepted", + misplaced(lines) == [], + f"got {misplaced(lines)}", +) + +# -------------------------------------------------------------------------- +# 6. The recursion is bounded. A register file is JSON, so it can never hold a +# true cycle — the realistic hazard is pathological DEPTH. Nest well past +# the collector's limit and require the run to terminate and stay sane: +# the deep relation sits below the bound so it is NOT registered, and the +# filter riding on it is reported rather than silently swallowed. That is +# the safe direction for a bounded walk (over-report, never under-report). +# -------------------------------------------------------------------------- +deep_prop = dict(RELATION) +for _ in range(14): + deep_prop = { + "type": "array", + "items": {"type": "object", "properties": {"inner": deep_prop}}, + } +lines = run_checker(register({"veryDeep": dict(deep_prop, title="Very deep")})) +check( + "a pathologically deep schema terminates and over-reports rather than hanging", + len(misplaced(lines)) >= 1, + f"expected the beyond-bound relation to be reported, got {misplaced(lines)}", +) + +# -------------------------------------------------------------------------- +print(f"test_check_relation_dialect: {PASSES} passed, {len(FAILURES)} failed") +for f in FAILURES: + print(f" FAIL {f}") +sys.exit(1 if FAILURES else 0)