Skip to content

Commit 38f5226

Browse files
dmealingclaude
andcommitted
feat(fr-033): Python — wire+gate structural-child placement enforcement
Verifies + pins that a misplaced structural child is rejected at strict load (a relationship under object.projection; a field under an attr-only validator.required). Python's S-B2a strict child_rules were registered but the loader's attr-schema pass did NOT consult them for STRUCTURAL placement (only ERR_UNKNOWN_ATTR for misplaced attrs) — the two cases loaded clean. Wires the ERR_CHILD_NOT_ALLOWED check into loader/validation_passes.py (_validate_attr_schema Check 0b): a structural child (own_children — not an @-attr) not admitted by the parent's registered child_rules now raises under strict, mirroring TS attr-schema-validate.ts (shared wildcard / subtype-list match; lax mode stays a no-op; unregistered parent skipped). registry-conformance stays GREEN. Corpus fixtures fixed: none. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a70081c commit 38f5226

2 files changed

Lines changed: 132 additions & 1 deletion

File tree

server/python/src/metaobjects/loader/validation_passes.py

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@
5050
ATTR_SUBTYPE_PROPERTIES,
5151
ATTR_SUBTYPE_STRINGARRAY,
5252
)
53-
from ..registry import AttrSchema, TypeRegistry
53+
from ..registry import AttrSchema, ChildRule, TypeRegistry
5454
from ..shared.base_types import (
5555
TYPE_FIELD,
5656
TYPE_IDENTITY,
@@ -212,6 +212,33 @@ def _node_label(node: MetaData) -> str:
212212
return f"{head} '{node.name}'" if node.name else head
213213

214214

215+
# The any-name / any-subtype wildcard a ChildRule field may carry. Mirrors TS's
216+
# CHILD_RULE_WILDCARD in shared/structural.ts.
217+
_CHILD_RULE_WILDCARD = "*"
218+
219+
220+
def _child_rule_admits(rule: "ChildRule", child: MetaData) -> bool:
221+
"""Whether *rule* admits *child* under the shared wildcard match semantics.
222+
223+
childType / childName may be ``"*"``; childSubType may be ``"*"``, a single
224+
subtype, or a LIST of subtypes (FR-033). Mirrors the TS ``childRuleMatches``
225+
in registry.ts used by Check 0b in attr-schema-validate.ts.
226+
"""
227+
if rule.child_type != _CHILD_RULE_WILDCARD and rule.child_type != child.type:
228+
return False
229+
230+
sub = rule.child_sub_type
231+
if isinstance(sub, list):
232+
if child.sub_type not in sub:
233+
return False
234+
elif sub != _CHILD_RULE_WILDCARD and sub != child.sub_type:
235+
return False
236+
237+
if rule.child_name != _CHILD_RULE_WILDCARD and rule.child_name != child.name:
238+
return False
239+
return True
240+
241+
215242
def _effective_schemas(
216243
type_: str,
217244
sub_type: str,
@@ -298,6 +325,37 @@ def _validate_attr_schema(
298325
)
299326
)
300327

328+
# --- Check 0b (FR-033): strict-load structural-child placement ---
329+
#
330+
# The structural analogue of Check 0. A STRUCTURAL child (field/identity/
331+
# source/validator/… — attrs never appear in own_children(); they live in
332+
# own_meta_attrs()) must be admitted by the parent's registered child_rules
333+
# under the same wildcard match semantics used everywhere (childType /
334+
# childSubType / childName may be "*", and childSubType may be a list). A
335+
# child the rules do not admit → ERR_CHILD_NOT_ALLOWED (the structural
336+
# analogue of Check 0's ERR_UNKNOWN_ATTR). Strict-load only; lax keeps the
337+
# legacy open policy. An UNREGISTERED parent cannot be judged here
338+
# (ERR_UNKNOWN_TYPE / ERR_UNKNOWN_SUBTYPE is reported elsewhere) — skip so
339+
# we never double-report. Mirrors the TS reference Check 0b in
340+
# attr-schema-validate.ts.
341+
if strict:
342+
parent_def = registry.find(node.type, node.sub_type)
343+
if parent_def is not None:
344+
rules = parent_def.child_rules
345+
for child in node.own_children():
346+
if not any(_child_rule_admits(r, child) for r in rules):
347+
errors.append(
348+
MetaError(
349+
f"Child {_node_label(child)} is not allowed under "
350+
f"{_node_label(node)} — no registered child rule for "
351+
f"{node.type}.{node.sub_type} admits "
352+
f"(type='{child.type}', subType='{child.sub_type}', "
353+
f"name='{child.name}')",
354+
ErrorCode.ERR_CHILD_NOT_ALLOWED,
355+
envelope=node.source,
356+
)
357+
)
358+
301359
if not schemas:
302360
continue
303361

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
"""FR-033 — strict structural-child placement enforcement (gating test).
2+
3+
A STRUCTURAL child (field/identity/source/validator/… — not an @-attr) placed
4+
under a parent whose strict registered ``child_rules`` do NOT admit it is
5+
rejected at strict load with ``ERR_CHILD_NOT_ALLOWED`` (the structural analogue
6+
of the ``ERR_UNKNOWN_ATTR`` misplaced-attr check). Mirrors the TS gating test
7+
(``server/typescript/packages/metadata/test/child-placement-enforcement.test.ts``)
8+
and the Java ``StrictChildPlacementTest`` — the same two cases, the same code.
9+
10+
Pins the loader-wired Check 0b in ``loader/validation_passes.py``. Lax load
11+
(the default) keeps the legacy open policy — a no-op.
12+
"""
13+
from __future__ import annotations
14+
15+
from metaobjects.errors import ErrorCode
16+
from metaobjects.loader.meta_data_loader import MetaDataFormat, MetaDataLoader
17+
18+
# Case 1 — a `relationship.association` under an `object.projection`. Strict
19+
# projection children = field/identity/validator/layout/source — NO relationship.
20+
_RELATIONSHIP_UNDER_PROJECTION = """
21+
{ "metadata.root": { "package": "test", "children": [
22+
{ "object.entity": { "name": "Account", "children": [
23+
{ "field.string": { "name": "id" }},
24+
{ "identity.primary": { "name": "pk", "@fields": ["id"] }}
25+
]}},
26+
{ "object.projection": { "name": "AccountView", "extends": "Account", "children": [
27+
{ "relationship.association": {
28+
"name": "bogus", "@cardinality": "one", "@objectRef": "Account" }}
29+
]}}
30+
]}}
31+
"""
32+
33+
# Case 2 — a `field.string` under an attr-only `validator.required`. Strict
34+
# validator children = [] (attr-only) — a structural field is never admitted.
35+
_FIELD_UNDER_VALIDATOR = """
36+
{ "metadata.root": { "package": "test", "children": [
37+
{ "object.entity": { "name": "Account", "children": [
38+
{ "field.string": { "name": "id", "children": [
39+
{ "validator.required": { "name": "req", "children": [
40+
{ "field.string": { "name": "bogus" }}
41+
]}}
42+
]}}
43+
]}}
44+
]}}
45+
"""
46+
47+
48+
def _codes(content: str, *, strict: bool) -> list[ErrorCode]:
49+
result = MetaDataLoader.from_string(
50+
content, format=MetaDataFormat.JSON, strict=strict
51+
)
52+
return [e.code for e in result.errors]
53+
54+
55+
def test_relationship_under_projection_rejected_strict() -> None:
56+
codes = _codes(_RELATIONSHIP_UNDER_PROJECTION, strict=True)
57+
assert ErrorCode.ERR_CHILD_NOT_ALLOWED in codes
58+
59+
60+
def test_field_under_validator_rejected_strict() -> None:
61+
codes = _codes(_FIELD_UNDER_VALIDATOR, strict=True)
62+
assert ErrorCode.ERR_CHILD_NOT_ALLOWED in codes
63+
64+
65+
def test_misplaced_structural_child_is_noop_in_lax_mode() -> None:
66+
# Lax load (the default) preserves the legacy open placement policy — the
67+
# check never fires (downstream apps can loosen the model).
68+
assert ErrorCode.ERR_CHILD_NOT_ALLOWED not in _codes(
69+
_RELATIONSHIP_UNDER_PROJECTION, strict=False
70+
)
71+
assert ErrorCode.ERR_CHILD_NOT_ALLOWED not in _codes(
72+
_FIELD_UNDER_VALIDATOR, strict=False
73+
)

0 commit comments

Comments
 (0)