From f5aa18f062a8fe01933ccf7b332445f17c04293c Mon Sep 17 00:00:00 2001 From: Conduction Release Bot Date: Sat, 8 Aug 2026 15:35:47 +0200 Subject: [PATCH 1/2] fix(gate-54): a correct nested relation was unrepresentable to the check (#231) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `property_ids` was built by a single non-recursive loop over `schema.properties.*` while `_raw_walk` recursed into the whole document. Every relation nested inside `items.properties.*` was therefore reported as "x-relation-filter is placed off a property" UNCONDITIONALLY — the collector could never contain it, so the finding was not a judgement, it was arithmetic. The finding was unfixable in the app. larpingapp's `character.requirementOverrides.items.properties.skill` is byte-for-byte the same shape as `character.skills` twenty lines above, which the gate accepts; the only difference is nesting depth. The three ways to clear it (move the filter off the property, flatten the array-of-objects, delete the filter) are all wrong. The same blind spot ran in the opposite direction for checks (b), (d) and (f): a nested relation MISSING its `$ref`, carrying a dangling `$ref` or an invalid filter token was never inspected. The gate was simultaneously false-positive on shape and false-negative on the thing that matters. One recursive collector (`_collect_properties`) fixes both directions. It descends `items.properties.*` (dict and tuple `items` forms) and `properties.*` for inline objects, guarded by an id() seen-set and a depth cap so a pathological document cannot crash the gate — a crashed gate reports nothing, which reads exactly like a pass. Invariants deliberately preserved, each pinned by a test: * `items` is a subschema, NOT a property — a filter riding on the items node is still a real rule-6 violation and is still reported. * `@object.` still resolves against the ROOT schema at every depth. `@object` is the object under edit, not the array element; larpingapp's nested `@object.setting` points at `character.setting`. Resolving against the element would have manufactured a fresh false positive. * rule-10 (frozen lifecycle) stays top-level: a lifecycle `field` names a property of the schema, never an element of a nested array. Also, because a finding count is not a defect count (gate-53 turned ~132 defects into "240 violations"): audited this helper for multi-emission. Exactly one overlap — a property with `x-relation-filter` and no `$ref` matched check (b) AND check (c), one defect stated twice with one fix. Check (c)'s duplicate is now suppressed when (b) already reported that property, so the ratio is 1:1. Check (d) stays per-token by design. And the placement findings now carry a JSON pointer. They named only the FILE, so a register emitting three of them gave the reader three identical lines for three different nodes — that ambiguity is what made this issue hard to triage. Measured, larpingapp@development registers (full-tree, no diff scope): before 4 findings / after 3 — the false positive is gone and all three genuine findings survive. Fleet-wide re-measure over 19 apps' registers: the placement false positive existed only on larpingapp; the closed false negative surfaces 8 previously silent nested missing-$ref findings (scholiq 4, decidesk/hermiq/openbuild/ openconnector 1 each). Check (b) is property-level diff-scoped in CI, so these only surface on a PR that touches those lines. Tests: new suite, 0 -> 23 tests, auto-discovered by run-helper-suites.sh (28 suites passing, 2 quarantined). Every new test mutation-checked: reverting the items recursion fails 10, collecting `items` as a property fails 2 (the true-positive guards), removing the dedupe fails 1, removing the top-level guard fails 1, and the whole suite run against origin/main's helper fails 16. --- .../scripts/lib/check_relation_dialect.py | 162 ++++++- .../lib/test_check_relation_dialect.py | 432 ++++++++++++++++++ 2 files changed, 569 insertions(+), 25 deletions(-) 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..dc9c7814 100644 --- a/hydra-gates/scripts/lib/check_relation_dialect.py +++ b/hydra-gates/scripts/lib/check_relation_dialect.py @@ -14,6 +14,34 @@ (85 converted), and procest's ``case.status`` proved the rule-10 lifecycle carve-out (an FK-scoped editable picker, NOT a frozen ``readOnly`` field). +NESTING (fixed 2026-08-08, issue #231) +-------------------------------------- +Properties are collected RECURSIVELY through ``items.properties.*`` (arrays of +objects) and ``properties.*`` (inline objects), because a relation is a relation +at any depth. Before the fix the collector was a single non-recursive loop over +``schema.properties.*`` while ``_raw_walk`` recursed into everything, so the gate +was wrong in BOTH directions at once: + + * FALSE POSITIVE on check (c) — a correctly shaped nested relation + (``character.requirementOverrides.items.properties.skill`` on larpingapp: + ``type`` + ``format:uuid`` + ``$ref`` + the filter on the same property) was + reported as "placed off a property" UNCONDITIONALLY, because the collector + could never contain it. The finding was unfixable in the app — the three + ways to clear it (move the filter off the property, flatten the array, drop + the filter) are all wrong. + * FALSE NEGATIVE on checks (b)/(d)/(f) — a nested relation missing its + ``$ref``, carrying a dangling ``$ref`` or an invalid filter token was never + inspected at all. + +Two invariants survive the recursion and are covered by regression tests: + * ``items`` itself is NOT a property. A filter riding on the ``items`` node + (rather than on one of ``items.properties.*``) is still a real rule-6 + violation and is still reported. + * ``@object.`` in check (d) resolves against the ROOT schema's + properties at every depth — ``@object`` is the object under edit, not the + nested array element. larpingapp's nested ``@object.setting`` points at + ``character.setting``, a top-level property. + Checks (each offending location prints one finding to stdout; WARN-prefixed lines are advisory and never fail the gate): a. BANNED DIALECT — any ``x-openregister-relations`` key anywhere in a @@ -47,6 +75,19 @@ contract (a banned dialect or a dangling $ref anywhere in a file the PR touched is a structural defect). +FINDING COUNT vs DEFECT COUNT +---------------------------- +A finding count is not a defect count. Gate 53 turned ~132 defects into "240 +violations" because one missing ``_note`` emitted a triplet. This helper was +audited for the same shape on 2026-08-08 and has exactly ONE overlap: a property +carrying ``x-relation-filter`` but no ``$ref`` matches check (b) ("lacks +canonical $ref") AND check (c) ("filter on a non-relation is inert") — one +defect, one fix, two messages. Check (c)'s duplicate is now suppressed when (b) +already reported that property, so the ratio here is 1:1. Checks (a), (d), (e) +and (f) each emit once per offending location and do not overlap each other: +(d) is intentionally per-TOKEN, so a filter with three bad tokens is three +independent defects, not one. + Usage: check_relation_dialect.py [ ...] """ @@ -352,6 +393,56 @@ def _resolve_ref(ref, keys): return ("ok", r) if r in keys else ("fail", r) +# Depth cap for the recursive property collector. Register documents in the +# fleet nest two levels at most; 12 is far above anything real and exists only +# so a hand-authored or generated pathological document cannot blow the Python +# recursion limit and turn a gate into a crash (a crashed gate reports nothing, +# which reads exactly like a pass). +_MAX_PROPERTY_DEPTH = 12 + + +def _collect_properties(props, prefix, depth, seen, out): + """Recursively collect every schema property into ``out``. + + Appends ``(qualified_name, prop_dict, declaration_line, depth)`` for each + property found under ``props``, then descends into: + + * ``prop.items.properties.*`` — an array of objects (``items`` may also + be a LIST for tuple validation; both forms are walked); + * ``prop.properties.*`` — an inline object property. + + ``items`` itself is deliberately NOT appended: it is a subschema, not a + property, so ``x-relation-filter`` riding on it stays a rule-6 violation. + + ``seen`` holds ``id()`` of every dict already visited. A JSON document + parsed from a file is a tree and cannot contain a cycle, but this collector + is also called on dicts assembled in tests and by future callers, so the + guard is unconditional; ``_MAX_PROPERTY_DEPTH`` bounds depth as well. Both + guards terminate quietly — they are protection against a crash, not checks. + """ + if not isinstance(props, dict) or depth > _MAX_PROPERTY_DEPTH: + return + plines = getattr(props, "key_lines", {}) + for pname, prop in props.items(): + if not isinstance(prop, dict) or pname.startswith("@"): + continue + if id(prop) in seen: + continue + seen.add(id(prop)) + qname = pname if prefix == "" else f"{prefix}.{pname}" + out.append((qname, prop, plines.get(pname, 0), depth)) + + items = prop.get("items") + item_nodes = [items] if isinstance(items, dict) else ( + [i for i in items if isinstance(i, dict)] if isinstance(items, list) else [] + ) + for node in item_nodes: + _collect_properties( + node.get("properties"), f"{qname}.items", depth + 1, seen, out + ) + _collect_properties(prop.get("properties"), qname, depth + 1, seen, out) + + # -------------------------------------------------------------------------- # Per-file checks. # -------------------------------------------------------------------------- @@ -367,9 +458,17 @@ def check_file(path, keys, findings, base_ref): changed = _changed_lines(path, base_ref) if base_ref else None schemas = _schemas_of(doc) - # Set of property dicts (by identity) that are legitimate direct schema - # properties — used to detect misplaced x-relation-filter (check c). + # Set of property dicts (by identity) that are legitimate schema + # properties AT ANY DEPTH — used to detect misplaced x-relation-filter + # (check c). Populated by the recursive collector: before issue #231 this + # was a flat one-level loop while _raw_walk recursed, so every nested + # relation was reported as misplaced and could not be represented at all. property_ids = set() + # Properties already reported by check (b) as missing a $ref. Check (c)'s + # "inert filter" message states the SAME defect and the SAME fix (add a + # $ref), so emitting both would count one defect twice — see the + # finding-vs-defect note in the module docstring. + missing_ref_ids = set() for sname, schema in schemas.items(): if not isinstance(schema, dict): @@ -380,13 +479,12 @@ def check_file(path, keys, findings, base_ref): lc = schema.get("x-openregister-lifecycle") lc_field = lc.get("field") if isinstance(lc, dict) else None lc_has_transitions = isinstance(lc, dict) and bool(lc.get("transitions")) - plines = getattr(props, "key_lines", {}) - for pname, prop in props.items(): - if not isinstance(prop, dict) or pname.startswith("@"): - continue - property_ids.add(id(prop)) - pline = plines.get(pname, 0) + collected = [] + _collect_properties(props, "", 0, set(), collected) + property_ids.update(id(prop) for _q, prop, _l, _d in collected) + + for pname, prop, pline, depth in collected: in_diff = changed is None or pline in changed # (b) relation-shape heuristic — property-level diff scoped. @@ -396,6 +494,7 @@ def check_file(path, keys, findings, base_ref): if isinstance(items, dict): desc = f"{desc} {items.get('description') or ''}" if _RELATION_DESC_RE.search(desc): + missing_ref_ids.add(id(prop)) findings.append((path, ( f"{path}: {sname}.{pname} — relation-shaped property " f"(format:uuid + relation description) lacks canonical " @@ -431,9 +530,12 @@ def check_file(path, keys, findings, base_ref): f"or @object.)" ))) - # (e) frozen lifecycle — rule 10. + # (e) frozen lifecycle — rule 10. Top-level only: a lifecycle + # 'field' names a property of the schema itself, never an element + # of a nested array, so a qualified name can never be the subject. if ( - "$ref" in prop + depth == 0 + and "$ref" in prop and prop.get("readOnly") is True and lc_field == pname and isinstance(lc, dict) @@ -464,23 +566,33 @@ def check_file(path, keys, findings, base_ref): ))) # (a) banned dialect + (c) misplaced/inert x-relation-filter — raw walk. - _raw_walk(doc, path, property_ids, findings) + _raw_walk(doc, path, property_ids, findings, missing_ref_ids) + +def _raw_walk(node, path, property_ids, findings, missing_ref_ids=frozenset(), loc=""): + """Walk every node of the document for checks (a) and (c). -def _raw_walk(node, path, property_ids, findings): + ``loc`` is a slash-joined JSON pointer to ``node``. Both findings used to + name only the FILE, which in a 2000-line register left the reader with no + way to tell WHICH node was flagged — three identical lines, three different + nodes (that ambiguity is exactly what made issue #231 hard to triage). + """ if isinstance(node, dict): + where = f" at {loc}" if loc else "" if "x-openregister-relations" in node: findings.append((path, ( - f"{path}: banned dialect — 'x-openregister-relations' block " - f"(canonical dialect is a property-level $ref; the bespoke " - f"per-schema block was retired 2026-07-08, ADR-062 rule 7)" + f"{path}: banned dialect — 'x-openregister-relations' block" + f"{where} (canonical dialect is a property-level $ref; the " + f"bespoke per-schema block was retired 2026-07-08, " + f"ADR-062 rule 7)" ))) if "x-relation-filter" in node: if id(node) not in property_ids: findings.append((path, ( - f"{path}: x-relation-filter is placed off a property (inside " - f"items / an x-* block / non-property node) — it rides only on " - f"the relation property itself (ADR-062 rule 6)" + f"{path}: x-relation-filter{where} is placed off a property " + f"(inside items / an x-* block / non-property node) — it " + f"rides only on the relation property itself " + f"(ADR-062 rule 6)" ))) elif not ( ("$ref" in node) @@ -491,16 +603,16 @@ def _raw_walk(node, path, property_ids, findings): "$ref" in node["items"] or "x-openregister-relation" in node["items"] )) - ): + ) and id(node) not in missing_ref_ids: findings.append((path, ( - f"{path}: x-relation-filter on a property with no $ref — " - f"filter on a non-relation is inert (ADR-062 rule 6)" + f"{path}: x-relation-filter{where} on a property with no " + f"$ref — filter on a non-relation is inert (ADR-062 rule 6)" ))) - for v in node.values(): - _raw_walk(v, path, property_ids, findings) + for k, v in node.items(): + _raw_walk(v, path, property_ids, findings, missing_ref_ids, f"{loc}/{k}") elif isinstance(node, list): - for v in node: - _raw_walk(v, path, property_ids, findings) + for i, v in enumerate(node): + _raw_walk(v, path, property_ids, findings, missing_ref_ids, f"{loc}/{i}") def main(argv): 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..64c994c6 --- /dev/null +++ b/hydra-gates/scripts/lib/test_check_relation_dialect.py @@ -0,0 +1,432 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 Conduction +# SPDX-License-Identifier: EUPL-1.2 +"""Tests for gate-54 helper check_relation_dialect.py. + +Focus: the recursive property collector added for issue #231. + +Before the fix, ``property_ids`` was built by a single non-recursive loop over +``schema.properties.*`` while ``_raw_walk`` recursed into the whole document. +That made the gate wrong in both directions at the same time: + + * a CORRECT nested relation was reported as "placed off a property" + unconditionally (structurally unrepresentable to the check — the finding + could not be cleared without mangling correct schema); + * a nested relation MISSING its ``$ref``, or carrying a dangling ``$ref`` or + a bad filter token, was never inspected at all. + +The suite therefore pins three things that must all hold together, because +widening a checker until it catches nothing is not a fix: + + 1. TRUE POSITIVES SURVIVE — a filter riding on an ``x-*`` block, or on the + ``items`` subschema itself (which is NOT a property), is still reported. + 2. FALSE POSITIVE GONE — the real larpingapp node + ``character.requirementOverrides.items.properties.skill`` passes. + 3. FALSE NEGATIVE CLOSED — nested shape/$ref/token defects are now reported. +""" + +from __future__ import annotations + +import json +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import check_relation_dialect as g # noqa: E402 + + +# -------------------------------------------------------------------------- +# Fixtures +# -------------------------------------------------------------------------- +def _flat_relation(): + """`character.skills` as larpingapp actually authors it — the shape the + gate already accepted before the fix. Used as the CONTROL: the nested case + below differs from it only in nesting depth.""" + return { + "type": "array", + "description": "Skills this character possesses", + "items": {"type": "string", "format": "uuid"}, + "$ref": "skill", + "x-relation-filter": {"setting": "@object.setting"}, + "title": "Skills", + } + + +def _nested_relation(skill_prop=None): + """`character.requirementOverrides` — an array of objects whose element + carries a canonical relation property. This is the node that produced the + false positive in issue #231.""" + return { + "type": "array", + "description": "Audited GM overrides of unmet skill requirements.", + "items": { + "type": "object", + "properties": { + "skill": skill_prop if skill_prop is not None else { + "type": "string", + "format": "uuid", + "$ref": "skill", + "x-relation-filter": {"setting": "@object.setting"}, + "description": "The assigned skill whose requirements are waived", + "title": "Skill", + }, + "reason": {"type": "string", "title": "Override Reason"}, + }, + }, + "title": "Requirement Overrides", + } + + +def _register(character_props): + props = { + "setting": { + "type": "string", + "format": "uuid", + "$ref": "setting", + "title": "Setting", + }, + } + props.update(character_props) + return { + "components": { + "schemas": { + "character": {"title": "Character", "properties": props}, + "skill": {"title": "Skill", "properties": {"name": {"type": "string"}}}, + "setting": {"title": "Setting", "properties": {"name": {"type": "string"}}}, + } + } + } + + +KEYS = {"character", "skill", "setting"} + + +class _Base(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.dir = Path(self._tmp.name) + + def tearDown(self): + self._tmp.cleanup() + + def run_check(self, doc, keys=None): + """Write `doc` to a register file and return the finding messages.""" + path = self.dir / "app_register.json" + path.write_text(json.dumps(doc, indent=2, ensure_ascii=False), encoding="utf-8") + findings = [] + g.check_file(str(path), KEYS if keys is None else keys, findings, "") + return [msg for _p, msg in findings] + + +# -------------------------------------------------------------------------- +# 1. The false positive (issue #231) — and its control. +# -------------------------------------------------------------------------- +class NestedRelationAcceptedTest(_Base): + def test_flat_relation_is_clean(self): + """CONTROL. If this ever goes red the fixture, not the fix, is wrong.""" + msgs = self.run_check(_register({"skills": _flat_relation()})) + self.assertEqual(msgs, [], f"flat relation must be clean, got {msgs}") + + def test_nested_relation_is_clean(self): + """The larpingapp node. Byte-for-byte the flat shape, one level deeper.""" + msgs = self.run_check(_register({"requirementOverrides": _nested_relation()})) + self.assertEqual(msgs, [], f"nested relation must be clean, got {msgs}") + + def test_nested_relation_is_not_reported_as_misplaced(self): + """Pins the exact message that made the finding unfixable in the app.""" + msgs = self.run_check(_register({"requirementOverrides": _nested_relation()})) + self.assertFalse( + any("placed off a property" in m for m in msgs), + f"nested relation reported as misplaced: {msgs}", + ) + + def test_doubly_nested_relation_is_clean(self): + """Arrays of objects nest arbitrarily; the collector must too.""" + inner = _nested_relation() + doc = _register({ + "chapters": { + "type": "array", + "items": {"type": "object", "properties": {"overrides": inner}}, + "title": "Chapters", + } + }) + msgs = self.run_check(doc) + self.assertEqual(msgs, [], f"doubly-nested relation must be clean, got {msgs}") + + def test_inline_object_property_relation_is_clean(self): + """`type: object` + `properties` (no array) is the same blind spot.""" + doc = _register({ + "origin": { + "type": "object", + "title": "Origin", + "properties": { + "skill": { + "type": "string", + "format": "uuid", + "$ref": "skill", + "x-relation-filter": {"setting": "@object.setting"}, + "title": "Skill", + } + }, + } + }) + msgs = self.run_check(doc) + self.assertEqual(msgs, [], f"inline-object relation must be clean, got {msgs}") + + +# -------------------------------------------------------------------------- +# 2. True positives must survive the widening. +# -------------------------------------------------------------------------- +class TruePositivesSurviveTest(_Base): + def test_filter_on_an_x_block_is_still_reported(self): + """A schema-level x-* block is not a property, at any depth.""" + doc = _register({"skills": _flat_relation()}) + doc["components"]["schemas"]["character"]["x-openregister-ui"] = { + "x-relation-filter": {"setting": "@object.setting"} + } + msgs = self.run_check(doc) + self.assertTrue( + any("placed off a property" in m for m in msgs), + f"filter on an x-* block must still be reported, got {msgs}", + ) + + def test_filter_on_the_items_node_itself_is_still_reported(self): + """`items` is a subschema, NOT a property. The filter must ride the + property. This is the genuine rule-6 violation the fix must not + swallow — it lives inside `items`, exactly where the collector now + descends.""" + bad = _nested_relation() + bad["items"]["x-relation-filter"] = {"setting": "@object.setting"} + msgs = self.run_check(_register({"requirementOverrides": bad})) + self.assertTrue( + any("placed off a property" in m for m in msgs), + f"filter on the items node must still be reported, got {msgs}", + ) + + def test_filter_on_a_non_property_node_inside_items_is_still_reported(self): + """Not every dict under `items.properties` reachable by _raw_walk is a + property: an x-* block nested one level further is not.""" + bad = _nested_relation() + bad["items"]["properties"]["skill"]["x-openregister-ui"] = { + "x-relation-filter": {"setting": "@object.setting"} + } + msgs = self.run_check(_register({"requirementOverrides": bad})) + self.assertTrue( + any("placed off a property" in m for m in msgs), + f"filter on a nested x-* block must still be reported, got {msgs}", + ) + + def test_placement_finding_names_the_offending_node(self): + """Three identical unlocated lines, three different nodes, is what made + issue #231 hard to triage. The finding must carry a JSON pointer.""" + bad = _nested_relation() + bad["items"]["x-relation-filter"] = {"setting": "@object.setting"} + msgs = self.run_check(_register({"requirementOverrides": bad})) + self.assertTrue( + any("/components/schemas/character/properties/" + "requirementOverrides/items" in m for m in msgs), + f"placement finding must name the node, got {msgs}", + ) + + def test_banned_dialect_still_reported(self): + doc = _register({"skills": _flat_relation()}) + doc["components"]["schemas"]["character"]["x-openregister-relations"] = {} + msgs = self.run_check(doc) + self.assertTrue( + any("banned dialect" in m for m in msgs), f"got {msgs}" + ) + + +# -------------------------------------------------------------------------- +# 3. False negatives closed — nested defects are now inspected. +# -------------------------------------------------------------------------- +class NestedFalseNegativesTest(_Base): + def test_nested_relation_missing_ref_is_reported(self): + """THE thing that matters: a nested relation with no `$ref`. Silent + before the fix, because check (b) never saw nested properties.""" + doc = _register({"requirementOverrides": _nested_relation({ + "type": "string", + "format": "uuid", + "description": "Reference to the skill whose requirements are waived", + "title": "Skill", + })}) + msgs = self.run_check(doc) + self.assertTrue( + any("lacks canonical $ref" in m and "requirementOverrides.items.skill" in m + for m in msgs), + f"nested missing-$ref must be reported, got {msgs}", + ) + + def test_nested_dangling_ref_is_reported(self): + doc = _register({"requirementOverrides": _nested_relation({ + "type": "string", + "format": "uuid", + "$ref": "nosuchschema", + "title": "Skill", + })}) + msgs = self.run_check(doc) + self.assertTrue( + any("does not resolve" in m and "nosuchschema" in m for m in msgs), + f"nested dangling $ref must be reported, got {msgs}", + ) + + def test_nested_unknown_filter_token_is_reported(self): + bad = _nested_relation() + bad["items"]["properties"]["skill"]["x-relation-filter"] = {"setting": "@nope"} + msgs = self.run_check(_register({"requirementOverrides": bad})) + self.assertTrue( + any("unknown token" in m for m in msgs), + f"nested bad filter token must be reported, got {msgs}", + ) + + def test_nested_filter_token_resolves_against_the_ROOT_schema(self): + """`@object` is the object under edit, not the array element. The real + larpingapp filter is `@object.setting` and `setting` is a property of + `character`, not of the array element — resolving against the element + would manufacture a fresh false positive.""" + clean = self.run_check(_register({"requirementOverrides": _nested_relation()})) + self.assertFalse( + any("nonexistent field" in m for m in clean), + f"root-level field must resolve, got {clean}", + ) + bad = _nested_relation() + bad["items"]["properties"]["skill"]["x-relation-filter"] = { + "reason": "@object.reason" # a sibling of the ELEMENT, not of the root + } + msgs = self.run_check(_register({"requirementOverrides": bad})) + self.assertTrue( + any("nonexistent field" in m and "'reason'" in m for m in msgs), + f"element-level field must NOT resolve, got {msgs}", + ) + + +# -------------------------------------------------------------------------- +# 4. Collector mechanics — termination guards and scope discipline. +# -------------------------------------------------------------------------- +class CollectorTest(unittest.TestCase): + def test_collects_nested_names_qualified(self): + props = _register({"requirementOverrides": _nested_relation()})[ + "components"]["schemas"]["character"]["properties"] + out = [] + g._collect_properties(props, "", 0, set(), out) + names = [q for q, _p, _l, _d in out] + self.assertIn("requirementOverrides", names) + self.assertIn("requirementOverrides.items.skill", names) + self.assertIn("requirementOverrides.items.reason", names) + + def test_items_itself_is_not_collected_as_a_property(self): + props = _register({"requirementOverrides": _nested_relation()})[ + "components"]["schemas"]["character"]["properties"] + out = [] + g._collect_properties(props, "", 0, set(), out) + items = props["requirementOverrides"]["items"] + self.assertNotIn(id(items), {id(p) for _q, p, _l, _d in out}, + "the items subschema must never count as a property") + + def test_terminates_on_a_cycle(self): + """A parsed JSON file is a tree, but the collector is also called on + hand-assembled dicts. A cycle must terminate, not recurse forever.""" + loop = {"type": "object"} + loop["properties"] = {"self": loop} + out = [] + g._collect_properties({"root": loop}, "", 0, set(), out) + self.assertGreaterEqual(len(out), 1) + self.assertLess(len(out), 50, "cycle guard did not stop the walk") + + def test_depth_is_capped(self): + node = {"type": "string"} + for _ in range(g._MAX_PROPERTY_DEPTH + 20): + node = {"type": "object", "properties": {"p": node}} + out = [] + g._collect_properties({"root": node}, "", 0, set(), out) + self.assertLessEqual(len(out), g._MAX_PROPERTY_DEPTH + 2, + f"depth cap not applied: collected {len(out)}") + + def test_tuple_form_items_list_is_walked(self): + props = { + "pairs": { + "type": "array", + "items": [{"type": "object", "properties": {"skill": {"type": "string"}}}], + } + } + out = [] + g._collect_properties(props, "", 0, set(), out) + self.assertIn("pairs.items.skill", [q for q, _p, _l, _d in out]) + + +class ScopeDisciplineTest(_Base): + def test_lifecycle_check_stays_top_level(self): + """Rule-10 names a property of the SCHEMA. A nested element property + that happens to share the lifecycle field's name must not trip it.""" + doc = _register({"requirementOverrides": _nested_relation({ + "type": "string", + "$ref": "setting", + "readOnly": True, + "title": "Status", + })}) + doc["components"]["schemas"]["character"]["x-openregister-lifecycle"] = { + "field": "requirementOverrides.items.skill" + } + msgs = self.run_check(doc) + self.assertFalse(any("permanently frozen" in m for m in msgs), + f"rule-10 must not fire on a nested property: {msgs}") + + +# -------------------------------------------------------------------------- +# 5. Finding count vs defect count (the gate-53 lesson). +# -------------------------------------------------------------------------- +class OneDefectOneFindingTest(_Base): + def test_missing_ref_with_a_filter_emits_one_finding_not_two(self): + """A property with `x-relation-filter` and no `$ref` matches check (b) + ("lacks canonical $ref") AND check (c) ("filter is inert"). Same defect, + same fix — it must be counted once.""" + doc = _register({"badRel": { + "type": "string", + "format": "uuid", + "description": "Reference to the skill", + "x-relation-filter": {"setting": "@object.setting"}, + "title": "Bad rel", + }}) + msgs = self.run_check(doc) + self.assertEqual(len(msgs), 1, f"one defect must emit one finding, got {msgs}") + self.assertIn("lacks canonical $ref", msgs[0]) + + def test_inert_filter_alone_is_still_reported(self): + """The de-duplication must not silence check (c) when check (b) did not + fire (no relation-shaped description, so (b) stays conservative).""" + doc = _register({"plainProp": { + "type": "string", + "x-relation-filter": {"setting": "@object.setting"}, + "title": "Plain", + }}) + msgs = self.run_check(doc) + self.assertTrue(any("inert" in m for m in msgs), + f"inert filter must still be reported, got {msgs}") + + +# -------------------------------------------------------------------------- +# 6. End-to-end through main() — findings land in the log file, not stdout. +# -------------------------------------------------------------------------- +class EndToEndTest(_Base): + def test_main_writes_nested_findings_to_the_log(self): + settings = self.dir / "lib" / "Settings" + settings.mkdir(parents=True) + reg = settings / "app_register.json" + reg.write_text(json.dumps(_register({ + "requirementOverrides": _nested_relation(), + "brokenNested": _nested_relation({ + "type": "string", "format": "uuid", "$ref": "nosuchschema", + }), + }), indent=2), encoding="utf-8") + log = self.dir / "gate.log" + g.main(["check_relation_dialect.py", str(log), str(reg)]) + text = log.read_text(encoding="utf-8") + self.assertIn("nosuchschema", text) + self.assertNotIn("placed off a property", text) + + +if __name__ == "__main__": + unittest.main() From 8c0b384633ac4702d6873dcb9d856be3c4b28ffb Mon Sep 17 00:00:00 2001 From: Conduction Release Bot Date: Sat, 8 Aug 2026 15:44:30 +0200 Subject: [PATCH 2/2] test(gate-54): pin the depth cap's fail-safe direction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Past the bound a nested property is no longer collected, so its filter is reported as misplaced — an over-report a human can dismiss. Silently accepting everything past the bound is the dangerous direction and looks exactly like a pass. Carried over from the earlier attempt on this issue, which asserted the same property. --- .../scripts/lib/test_check_relation_dialect.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/hydra-gates/scripts/lib/test_check_relation_dialect.py b/hydra-gates/scripts/lib/test_check_relation_dialect.py index 64c994c6..5d04e308 100644 --- a/hydra-gates/scripts/lib/test_check_relation_dialect.py +++ b/hydra-gates/scripts/lib/test_check_relation_dialect.py @@ -358,6 +358,22 @@ def test_tuple_form_items_list_is_walked(self): class ScopeDisciplineTest(_Base): + def test_beyond_the_depth_cap_over_reports_rather_than_hanging(self): + """The depth cap must fail in the SAFE direction. Past the bound a + nested property is no longer collected, so `_raw_walk` reports its + filter as misplaced — an over-report a human can dismiss. Silently + accepting everything past the bound would be the dangerous direction, + and it would look exactly like a pass.""" + deep = _nested_relation() + for _ in range(g._MAX_PROPERTY_DEPTH + 4): + deep = {"type": "array", + "items": {"type": "object", "properties": {"inner": deep}}} + msgs = self.run_check(_register({"veryDeep": dict(deep, title="Very deep")})) + self.assertTrue( + any("placed off a property" in m for m in msgs), + f"beyond-bound relation must be over-reported, got {msgs}", + ) + def test_lifecycle_check_stays_top_level(self): """Rule-10 names a property of the SCHEMA. A nested element property that happens to share the lifecycle field's name must not trip it."""