Skip to content
Closed
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
42 changes: 42 additions & 0 deletions hydra-gates/scripts/lib/check_relation_dialect.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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

Expand Down
217 changes: 217 additions & 0 deletions hydra-gates/scripts/lib/test_check_relation_dialect.py
Original file line number Diff line number Diff line change
@@ -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.<name>`) 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)
Loading