Skip to content

Commit 78ff0b8

Browse files
dmealingclaude
andcommitted
fix(python): pre-merge review — complete FR5a envelope coverage
Three issues surfaced by the pre-merge review: * Parser-constructed attr nodes were never source-stamped. Both the inline `@`-attr path (`_build`) and the typed attr-child path (`_parse_attr_child`) called `parent.set_attr(...)` which constructs a fresh MetaAttribute via `MetaData.set_attr` (meta_data.py:61-69) without copying provenance — so every parser-created attr node wore `CodeSource.DEFAULT` instead of a `JsonSource`. Now: locate the just-set attr via `own_meta_attr` and call `set_source(_current_envelope(...))`. Mirrors C# Parser.cs:1039 (`attrModel.SetSource(st.CurrentSource())`). Two regression tests cover both call sites. * `_parse_source` carved out a `<inline>` case and fell back to `CodeSource.DEFAULT` while the parser's `_current_envelope` built a `JsonSource(files=("<inline>",), ...)`. Two same-source errors carried inconsistent envelope formats. Drop the carve-out — match C# Parser.cs:140 which only checks `Source is null`. No tests relied on the old behavior. * `ERR_PROVIDER_ATTR_CONFLICT` was the only one of 29 MetaError sites that omitted `envelope=`. Thread the current node into `_effective_schemas` and attach `envelope=node.source`. Mirrors C# ValidationPasses.cs:593-596. Test count: 494 → 496 (2 new tests; all pass). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent fd03b22 commit 78ff0b8

4 files changed

Lines changed: 111 additions & 3 deletions

File tree

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -141,10 +141,13 @@ def _parse_source(
141141
the merge set.
142142
"""
143143
# FR5a / ADR-0009 — top-level envelope for the source. Until the parser
144-
# walk descends, the offending location is the root (`$`).
144+
# walk descends, the offending location is the root (`$`). Matches C#
145+
# Parser.cs:140 — only fall back to CodeSource when there is no source
146+
# id at all; "<inline>" is a valid source identifier and must yield a
147+
# JsonSource so envelope formats remain consistent across error sites.
145148
envelope: ErrorSource = (
146149
JsonSource(files=(src.id,), json_path="$")
147-
if src.id and src.id != "<inline>"
150+
if src.id
148151
else CodeSource.DEFAULT
149152
)
150153

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,11 +139,14 @@ def _effective_schemas(
139139
common_attrs: list[AttrSchema],
140140
registry: TypeRegistry,
141141
errors: list[MetaError],
142+
node: MetaData,
142143
) -> tuple[list[AttrSchema], dict[str, AttrSchema]]:
143144
"""Compute the effective attr schema for a (type, sub_type).
144145
145146
Per-type attrs win over common attrs of the same name. If any collision
146147
exists, append a single ERR_PROVIDER_ATTR_CONFLICT for this (type, sub_type).
148+
*node* supplies the FR5a envelope for the conflict error (matches C#
149+
ValidationPasses.cs:593-596 — ``Envelope: node.Source``).
147150
"""
148151
per_type_attrs = registry.attrs_of(type_, sub_type)
149152
per_type_names = {s.name for s in per_type_attrs}
@@ -155,6 +158,7 @@ def _effective_schemas(
155158
f"{type_}.{sub_type} has a per-type attr '@{ca.name}' "
156159
f"that conflicts with a common attr of the same name",
157160
ErrorCode.ERR_PROVIDER_ATTR_CONFLICT,
161+
envelope=node.source,
158162
)
159163
)
160164
break # one error per (type, sub_type) is sufficient
@@ -177,7 +181,7 @@ def _validate_attr_schema(
177181
key = (node.type, node.sub_type)
178182
cached = schema_cache.get(key)
179183
if cached is None:
180-
cached = _effective_schemas(node.type, node.sub_type, common_attrs, registry, errors)
184+
cached = _effective_schemas(node.type, node.sub_type, common_attrs, registry, errors, node)
181185
schema_cache[key] = cached
182186
schemas, schema_by_name = cached
183187

server/python/src/metaobjects/parser.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,14 @@ def _build(
174174
continue
175175
schema = registry.attr_schema(type_, sub_type, attr_name)
176176
node.set_attr(attr_name, value, sub_type=schema.value_type if schema else None)
177+
# FR5a / ADR-0009 — stamp the just-constructed MetaAttribute node with
178+
# its origin envelope. Mirrors C# Parser.cs:1039 (attrModel.SetSource).
179+
# The attr's JsonPath points at the @-key on the parent body.
180+
attr_node = node.own_meta_attr(attr_name)
181+
if attr_node is not None:
182+
builder.push_key(key)
183+
attr_node.set_source(_current_envelope(source, builder))
184+
builder.pop()
177185

178186
# The context package for children: use this node's own package if set, else inherit.
179187
child_ctx_pkg = node.package or ctx_pkg
@@ -233,6 +241,12 @@ def _parse_attr_child(
233241
# Resolve the attr sub_type; fall back to base if unregistered.
234242
resolved_sub = sub_type if registry.find(TYPE_ATTR, sub_type) is not None else None
235243
parent.set_attr(attr_name, raw_value, sub_type=resolved_sub)
244+
# FR5a / ADR-0009 — stamp the just-constructed MetaAttribute node with its
245+
# origin envelope. Mirrors C# Parser.cs:1039 (attrModel.SetSource). The
246+
# builder already points at the `attr.<sub>` wrapper (caller pushed it).
247+
attr_node = parent.own_meta_attr(attr_name)
248+
if attr_node is not None:
249+
attr_node.set_source(_current_envelope(source, builder))
236250

237251

238252
def _iter_children(body: dict[str, object]) -> list[tuple[str, object]]:

server/python/tests/source/test_source_on_node.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,3 +157,90 @@ def test_metadata_subclass_inherits_source_attribute() -> None:
157157
root = MetaRoot(TYPE_METADATA, SUBTYPE_ROOT, "")
158158
assert isinstance(root, MetaData)
159159
assert root.source.format == "code"
160+
161+
162+
def test_parser_stamps_source_on_inline_attribute_nodes() -> None:
163+
# FR5a / ADR-0009 — every parser-constructed MetaAttribute node (inline
164+
# @-prefixed attr on a body) carries a JsonSource. Mirrors C# Parser.cs:1039
165+
# (attrModel.SetSource). Without source-stamping the attr node wears
166+
# CodeSource.DEFAULT, which violates the FR5a envelope-coverage invariant.
167+
json_text = """
168+
{ "metadata.root": {
169+
"package": "demo",
170+
"children": [
171+
{ "object.entity": {
172+
"name": "Widget",
173+
"children": [
174+
{ "field.string": { "name": "email", "@filterable": true } }
175+
]
176+
} }
177+
]
178+
} }
179+
"""
180+
loader = MetaDataLoader()
181+
result = loader.load([
182+
InMemoryStringSource(
183+
json_text, format=MetaDataFormat.JSON, id="metaobjects/widget.json",
184+
),
185+
])
186+
assert not result.errors
187+
188+
root = result.root
189+
entity = next(c for c in root.own_children() if c.type == TYPE_OBJECT)
190+
email = next(c for c in entity.own_children() if c.type == TYPE_FIELD)
191+
filterable_attr = email.own_meta_attr("filterable")
192+
assert filterable_attr is not None
193+
assert isinstance(filterable_attr.source, JsonSource), (
194+
f"expected JsonSource on inline @filterable attr node, "
195+
f"got {type(filterable_attr.source).__name__}"
196+
)
197+
assert filterable_attr.source.files == ("metaobjects/widget.json",)
198+
# The JsonPath points at the @-key on the parent body.
199+
assert filterable_attr.source.json_path == (
200+
"$['metadata.root'].children[0]['object.entity']"
201+
".children[0]['field.string']['@filterable']"
202+
)
203+
204+
205+
def test_parser_stamps_source_on_typed_attr_child_nodes() -> None:
206+
# FR5a / ADR-0009 — typed attr children (the `{ "attr.<sub>": {...} }` form)
207+
# are also parser-constructed; they must carry a JsonSource too. Mirrors
208+
# the same C# Parser.cs:1039 source-stamping path.
209+
json_text = """
210+
{ "metadata.root": {
211+
"package": "demo",
212+
"children": [
213+
{ "object.entity": {
214+
"name": "Widget",
215+
"children": [
216+
{ "field.string": { "name": "email", "children": [
217+
{ "attr.boolean": { "name": "filterable", "value": true } }
218+
] } }
219+
]
220+
} }
221+
]
222+
} }
223+
"""
224+
loader = MetaDataLoader()
225+
result = loader.load([
226+
InMemoryStringSource(
227+
json_text, format=MetaDataFormat.JSON, id="metaobjects/widget.json",
228+
),
229+
])
230+
assert not result.errors
231+
232+
root = result.root
233+
entity = next(c for c in root.own_children() if c.type == TYPE_OBJECT)
234+
email = next(c for c in entity.own_children() if c.type == TYPE_FIELD)
235+
filterable_attr = email.own_meta_attr("filterable")
236+
assert filterable_attr is not None
237+
assert isinstance(filterable_attr.source, JsonSource), (
238+
f"expected JsonSource on typed attr.boolean child node, "
239+
f"got {type(filterable_attr.source).__name__}"
240+
)
241+
assert filterable_attr.source.files == ("metaobjects/widget.json",)
242+
# Path threads to the attr.boolean wrapper key on the parent's children array.
243+
assert filterable_attr.source.json_path == (
244+
"$['metadata.root'].children[0]['object.entity']"
245+
".children[0]['field.string'].children[0]['attr.boolean']"
246+
)

0 commit comments

Comments
 (0)