Skip to content

Commit 20e0558

Browse files
dmealingclaude
andcommitted
feat(python): FR-013/014/015 loader validation passes (per-port fan-out)
Ports the three deferred validation passes from the TS reference into the Python loader, clearing all 10 deferred error fixtures from the Python conformance ledger. - FR-013 validate_field_readonly.py — ERR_READONLY_ASSIGNED_PRIMARY, ERR_READONLY_DOWNGRADE, WARN_READONLY_VALUE_OBJECT. - FR-014 validate_discriminator.py — ERR_DISCRIMINATOR_FIELD_NOT_FOUND, _VALUE_DUPLICATE, _VALUE_MISSING, _VALUE_TYPE_MISMATCH (4-pass: name resolution, value type-check, duplicate detection, missing-on-concrete). - FR-015 validate_source_parameter_ref.py — ERR_PARAMETER_REF_ON_NON_CALLABLE_KIND (checked before resolution), _UNRESOLVED, _NOT_VALUE_OBJECT, _PASSTHROUGH_TYPE_MISMATCH. Wired into run_validations after the FR-016 physical-name pass. Each mirrors its TS counterpart's logic and error-code/envelope contract exactly (the conformance corpus is the oracle). Attrs were already registered (positive round-trips passed); this adds the validation logic. Conformance: 152/152 green. (The codegen/integration test failures in the local env are missing-dev-dep issues — ruff, pg8000 — not loader-related.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 2aea5a2 commit 20e0558

5 files changed

Lines changed: 499 additions & 13 deletions

File tree

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
"""FR-014 — TPH discriminator cross-attribute rules.
2+
3+
Codes (all errors):
4+
* ``ERR_DISCRIMINATOR_FIELD_NOT_FOUND`` — ``@discriminator`` names a field
5+
that does not exist on the entity (own or via extends chain).
6+
* ``ERR_DISCRIMINATOR_VALUE_DUPLICATE`` — two subtypes of the same
7+
``@discriminator``-bearing root claim the same ``@discriminatorValue``.
8+
* ``ERR_DISCRIMINATOR_VALUE_MISSING`` — a concrete (non-abstract) entity
9+
extends a chain whose root carries ``@discriminator`` but lacks
10+
``@discriminatorValue``.
11+
* ``ERR_DISCRIMINATOR_VALUE_TYPE_MISMATCH`` — ``@discriminatorValue`` cannot be
12+
coerced to the discriminator field's subtype (enum: not in ``@values``;
13+
integer-family: not numeric; string: always OK).
14+
15+
Mirrors the TS reference
16+
``packages/metadata/src/core/object/validate-discriminator.ts``.
17+
"""
18+
from __future__ import annotations
19+
20+
import re
21+
22+
from ..errors import ErrorCode, MetaError
23+
from ..meta.meta_data import MetaData
24+
from ..meta.core.field.field_constants import (
25+
FIELD_ATTR_VALUES,
26+
FIELD_SUBTYPE_BYTE,
27+
FIELD_SUBTYPE_ENUM,
28+
FIELD_SUBTYPE_INT,
29+
FIELD_SUBTYPE_LONG,
30+
FIELD_SUBTYPE_SHORT,
31+
FIELD_SUBTYPE_STRING,
32+
)
33+
from ..meta.core.object.object_constants import (
34+
OBJECT_ATTR_DISCRIMINATOR,
35+
OBJECT_ATTR_DISCRIMINATOR_VALUE,
36+
OBJECT_SUBTYPE_ENTITY,
37+
)
38+
from ..shared.base_types import TYPE_FIELD, TYPE_OBJECT
39+
40+
_NUMERIC_DISCRIMINATOR_SUBTYPES = frozenset(
41+
{FIELD_SUBTYPE_INT, FIELD_SUBTYPE_LONG, FIELD_SUBTYPE_SHORT, FIELD_SUBTYPE_BYTE}
42+
)
43+
_INT_RE = re.compile(r"^-?\d+$")
44+
45+
46+
def _find_field_on_entity(entity: MetaData, name: str) -> MetaData | None:
47+
"""A field with ``name`` on ``entity`` — own first, then via extends chain."""
48+
for child in entity.own_children():
49+
if child.type == TYPE_FIELD and child.name == name:
50+
return child
51+
cursor = entity.super_data
52+
while cursor is not None:
53+
for child in cursor.own_children():
54+
if child.type == TYPE_FIELD and child.name == name:
55+
return child
56+
cursor = cursor.super_data
57+
return None
58+
59+
60+
def _find_discriminator_root(entity: MetaData) -> tuple[MetaData | None, str | None]:
61+
"""First ancestor (or self) carrying ``@discriminator``: (root, fieldName)."""
62+
cursor: MetaData | None = entity
63+
while cursor is not None:
64+
v = cursor.attr(OBJECT_ATTR_DISCRIMINATOR)
65+
if isinstance(v, str) and v != "":
66+
return cursor, v
67+
cursor = cursor.super_data
68+
return None, None
69+
70+
71+
def validate_discriminator(root: MetaData, errors: list[MetaError]) -> None:
72+
entities = [
73+
c
74+
for c in root.own_children()
75+
if c.type == TYPE_OBJECT and c.sub_type == OBJECT_SUBTYPE_ENTITY
76+
]
77+
78+
# Pass 1: @discriminator name resolution (own + inherited fields).
79+
for obj in entities:
80+
disc = obj.attr(OBJECT_ATTR_DISCRIMINATOR)
81+
if not isinstance(disc, str) or disc == "":
82+
continue
83+
if _find_field_on_entity(obj, disc) is None:
84+
errors.append(
85+
MetaError(
86+
f'object.entity "{obj.name}" @discriminator: "{disc}" does not '
87+
"name a field on this entity (checked own children and the "
88+
"extends chain)",
89+
ErrorCode.ERR_DISCRIMINATOR_FIELD_NOT_FOUND,
90+
envelope=obj.source,
91+
)
92+
)
93+
94+
# Pass 2: @discriminatorValue type-check + collect bindings per root.
95+
bindings_by_root: list[tuple[MetaData, list[tuple[MetaData, str]]]] = []
96+
_root_index: dict[int, list[tuple[MetaData, str]]] = {}
97+
98+
for obj in entities:
99+
value = obj.attr(OBJECT_ATTR_DISCRIMINATOR_VALUE)
100+
if not isinstance(value, str) or value == "":
101+
continue
102+
103+
disc_root, field_name = _find_discriminator_root(obj)
104+
if disc_root is None or field_name is None:
105+
continue
106+
field = _find_field_on_entity(disc_root, field_name)
107+
if field is None:
108+
continue # root's own ERR_DISCRIMINATOR_FIELD_NOT_FOUND already fires
109+
110+
if field.sub_type == FIELD_SUBTYPE_ENUM:
111+
enum_values = field.attr(FIELD_ATTR_VALUES)
112+
members = [str(v) for v in enum_values] if isinstance(enum_values, (list, tuple)) else []
113+
if value not in members:
114+
errors.append(
115+
MetaError(
116+
f'object.entity "{obj.name}" @discriminatorValue: "{value}" '
117+
f'is not a member of the discriminator enum field '
118+
f'"{field_name}" @values [{", ".join(members)}]',
119+
ErrorCode.ERR_DISCRIMINATOR_VALUE_TYPE_MISMATCH,
120+
envelope=obj.source,
121+
)
122+
)
123+
elif field.sub_type in _NUMERIC_DISCRIMINATOR_SUBTYPES:
124+
if _INT_RE.match(value) is None:
125+
errors.append(
126+
MetaError(
127+
f'object.entity "{obj.name}" @discriminatorValue: "{value}" '
128+
f'does not coerce to numeric discriminator field '
129+
f'"{field_name}" (field.{field.sub_type})',
130+
ErrorCode.ERR_DISCRIMINATOR_VALUE_TYPE_MISMATCH,
131+
envelope=obj.source,
132+
)
133+
)
134+
elif field.sub_type != FIELD_SUBTYPE_STRING:
135+
# Non-{enum, integer-family, string} discriminators accepted silently.
136+
pass
137+
138+
existing = _root_index.get(id(disc_root))
139+
if existing is None:
140+
existing = []
141+
_root_index[id(disc_root)] = existing
142+
bindings_by_root.append((disc_root, existing))
143+
existing.append((obj, value))
144+
145+
# Pass 3: ERR_DISCRIMINATOR_VALUE_DUPLICATE within each root's subtypes.
146+
for _disc_root, bindings in bindings_by_root:
147+
seen: dict[str, MetaData] = {}
148+
for subtype, value in bindings:
149+
prev = seen.get(value)
150+
if prev is not None:
151+
errors.append(
152+
MetaError(
153+
f'object.entity "{subtype.name}" @discriminatorValue: '
154+
f'"{value}" duplicates the value already claimed by '
155+
f'"{prev.name}"',
156+
ErrorCode.ERR_DISCRIMINATOR_VALUE_DUPLICATE,
157+
envelope=subtype.source,
158+
)
159+
)
160+
else:
161+
seen[value] = subtype
162+
163+
# Pass 4: ERR_DISCRIMINATOR_VALUE_MISSING — every concrete entity that extends
164+
# a @discriminator-bearing root must declare a value.
165+
for obj in entities:
166+
if obj.is_abstract is True:
167+
continue
168+
if isinstance(obj.attr(OBJECT_ATTR_DISCRIMINATOR_VALUE), str):
169+
continue
170+
if isinstance(obj.attr(OBJECT_ATTR_DISCRIMINATOR), str):
171+
continue # a root, not a subtype
172+
disc_root, _ = _find_discriminator_root(obj)
173+
if disc_root is None or disc_root is obj:
174+
continue
175+
errors.append(
176+
MetaError(
177+
f'object.entity "{obj.name}" extends the @discriminator-bearing '
178+
f'root "{disc_root.name}" but is missing @discriminatorValue '
179+
"(required on every concrete subtype)",
180+
ErrorCode.ERR_DISCRIMINATOR_VALUE_MISSING,
181+
envelope=obj.source,
182+
)
183+
)
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
"""FR-013 — field-level ``@readOnly`` cross-attribute rules.
2+
3+
Codes:
4+
* ``ERR_READONLY_ASSIGNED_PRIMARY`` — ``@readOnly: true`` on a field that is
5+
the target of an ``identity.primary`` with ``@generation: "assigned"``.
6+
The application has no path to populate the identity value.
7+
* ``ERR_READONLY_DOWNGRADE`` — a concrete subtype declares ``@readOnly:
8+
false`` on a field whose extends-chain parent declares ``@readOnly: true``.
9+
Read-only-ness can only be upgraded, never downgraded.
10+
* ``WARN_READONLY_VALUE_OBJECT`` — ``@readOnly: true`` on a field child of an
11+
``object.value``. No persistence semantics apply; advisory only.
12+
13+
Mirrors the TS reference
14+
``packages/metadata/src/core/field/validate-field-readonly.ts``.
15+
"""
16+
from __future__ import annotations
17+
18+
from ..errors import ErrorCode, MetaError
19+
from ..meta.meta_data import MetaData
20+
from ..meta.core.field.field_constants import FIELD_ATTR_READ_ONLY
21+
from ..meta.core.identity.identity_constants import (
22+
GENERATION_ASSIGNED,
23+
IDENTITY_ATTR_FIELDS,
24+
IDENTITY_ATTR_GENERATION,
25+
IDENTITY_SUBTYPE_PRIMARY,
26+
)
27+
from ..meta.core.object.object_constants import OBJECT_SUBTYPE_VALUE
28+
from ..shared.base_types import TYPE_FIELD, TYPE_IDENTITY, TYPE_OBJECT
29+
from ..source.error_source import LoaderWarning
30+
31+
WARN_READONLY_VALUE_OBJECT = "WARN_READONLY_VALUE_OBJECT"
32+
33+
34+
def _read_only_flag(field: MetaData) -> bool | None:
35+
"""The explicit ``@readOnly`` value (True/False) or None when absent."""
36+
v = field.attr(FIELD_ATTR_READ_ONLY)
37+
return v if isinstance(v, bool) else None
38+
39+
40+
def _inherited_field(obj: MetaData, name: str) -> MetaData | None:
41+
"""Walk the extends chain for a field with ``name``; return its declaring
42+
node (own attrs intact) if found."""
43+
cursor = obj.super_data
44+
while cursor is not None:
45+
for c in cursor.own_children():
46+
if c.type == TYPE_FIELD and c.name == name:
47+
return c
48+
cursor = cursor.super_data
49+
return None
50+
51+
52+
def _primary_assigned_field_names(obj: MetaData) -> set[str]:
53+
"""Names of fields participating in any ``identity.primary`` with
54+
``@generation: "assigned"`` on ``obj`` or its extends chain (effective)."""
55+
out: set[str] = set()
56+
for ident in obj.children():
57+
if ident.type != TYPE_IDENTITY:
58+
continue
59+
if ident.sub_type != IDENTITY_SUBTYPE_PRIMARY:
60+
continue
61+
if ident.attr(IDENTITY_ATTR_GENERATION) != GENERATION_ASSIGNED:
62+
continue
63+
fields = ident.attr(IDENTITY_ATTR_FIELDS)
64+
if isinstance(fields, (list, tuple)):
65+
for f_name in fields:
66+
if isinstance(f_name, str):
67+
out.add(f_name)
68+
elif isinstance(fields, str):
69+
out.add(fields)
70+
return out
71+
72+
73+
def validate_field_readonly(
74+
root: MetaData,
75+
errors: list[MetaError],
76+
envelope_warnings: list[LoaderWarning] | None = None,
77+
legacy_warnings: list[str] | None = None,
78+
) -> None:
79+
for obj in root.own_children():
80+
if obj.type != TYPE_OBJECT:
81+
continue
82+
is_value_object = obj.sub_type == OBJECT_SUBTYPE_VALUE
83+
84+
# 1) WARN_READONLY_VALUE_OBJECT — any @readOnly field child of object.value.
85+
if is_value_object:
86+
for child in obj.own_children():
87+
if child.type == TYPE_FIELD and _read_only_flag(child) is True:
88+
msg = (
89+
f'field "{child.name}" on object.value "{obj.name}" '
90+
"declares @readOnly: true; value-objects have no "
91+
"persistence semantics so the read-only contract is "
92+
"advisory (codegen may use it for record/struct treatment)."
93+
)
94+
if envelope_warnings is not None:
95+
envelope_warnings.append(
96+
LoaderWarning(
97+
code=WARN_READONLY_VALUE_OBJECT,
98+
message=msg,
99+
source=child.source,
100+
)
101+
)
102+
if legacy_warnings is not None:
103+
legacy_warnings.append(WARN_READONLY_VALUE_OBJECT)
104+
105+
# 2) ERR_READONLY_DOWNGRADE — read-only-ness can only be upgraded across
106+
# extends. Only the explicit own @readOnly: false case matters.
107+
for own_field in obj.own_children():
108+
if own_field.type != TYPE_FIELD:
109+
continue
110+
if _read_only_flag(own_field) is not False:
111+
continue
112+
inherited = _inherited_field(obj, own_field.name)
113+
if inherited is not None and _read_only_flag(inherited) is True:
114+
errors.append(
115+
MetaError(
116+
f'field "{own_field.name}" on "{obj.name}" sets @readOnly: '
117+
"false, but the extends-chain parent declares @readOnly: "
118+
"true. Read-only-ness can only be upgraded, not downgraded "
119+
"(FR-013).",
120+
ErrorCode.ERR_READONLY_DOWNGRADE,
121+
envelope=own_field.source,
122+
)
123+
)
124+
125+
# 3) ERR_READONLY_ASSIGNED_PRIMARY — @readOnly: true on a field used in an
126+
# identity.primary whose @generation is "assigned" (effective tree).
127+
if not is_value_object:
128+
assigned = _primary_assigned_field_names(obj)
129+
if assigned:
130+
for field in obj.children():
131+
if field.type != TYPE_FIELD:
132+
continue
133+
if field.name not in assigned:
134+
continue
135+
if _read_only_flag(field) is not True:
136+
continue
137+
errors.append(
138+
MetaError(
139+
f'field "{field.name}" on "{obj.name}" is @readOnly: '
140+
"true AND the target of identity.primary with "
141+
'@generation: "assigned"; the application has no path '
142+
"to populate the identity value (FR-013).",
143+
ErrorCode.ERR_READONLY_ASSIGNED_PRIMARY,
144+
envelope=field.source,
145+
)
146+
)

0 commit comments

Comments
 (0)