Skip to content

Commit fd03b22

Browse files
committed
feat(python): FR5a — thread source through parser + validation + MetaData.source
Wires the FR5a foundation (commit c163bae) into the loader pipeline so every parser-constructed metadata node carries its origin and every error raised during parse / per-node validation produces an ADR-0009-shaped envelope. * `meta/meta_data.py` — new `source` property + `set_source` setter (freeze-guarded). Defaults to `CodeSource.DEFAULT` for programmatic / test construction, mirroring the C# `MetaData.Source` contract. * `errors.py` — `MetaError` gains an `envelope: ErrorSource | None` field alongside the legacy `source` / `path` strings (conformance still inspects `code` only; the envelope is additive). * `parser.py` — threads a `JsonPathBuilder` through the recursion. Pushes the wrapper key at root, the `children` key + `[i]` index + child wrapper on each descent, and the `@key` for reserved-attr errors. Every node created in `_build` calls `set_source(JsonSource(files=(src,), json_path=…))`; every error raised mid-parse passes the same envelope via the new `_current_envelope` helper. Falls back to `CodeSource.DEFAULT` when the source id is missing — matches the C# `ParseState.CurrentSource` fallback so the FR5a length-1 invariant on `JsonSource.files` can't be violated. * `loader/validation_passes.py` — 29 `MetaError(...)` sites updated to pass `envelope=<node>.source` where a node is in scope. `_validate_entity_field_ref` and `_validate_via_path` gained a required `origin_node` parameter so `@from` / `@of` / `@via` failures attach to the origin node, not just a text label. * `loader/meta_data_loader.py` — top-level OSError / JSON-decode / YAML parse-error sites build a `JsonSource(files=(src.id,), json_path="$")` envelope (with `CodeSource` fallback when the source has no real id). * `source/error_source.py` — `CodeSource.DEFAULT` declared as a class-level `ClassVar["CodeSource"]` so mypy sees the singleton statically. * `source/semantic_diff.py` — lazy MetaData import to break the meta_data ↔ source module cycle; `bool(...)` cast on the return path for strict-mypy compatibility. The canonical JSON serializer already only emits recognized structural keys, so `source` is naturally omitted from `canonical_serialize` output — verified by the new `test_canonical_serializer_omits_source_from_output` test. Tests: +7 new in `tests/source/test_source_on_node.py` (programmatic default; freeze guard; parser populates JsonSource on every node with correct bracket-vs-dot JSONPath segmentation; canonical serializer omits `source`; parse-error envelope carries the right files+jsonPath; MetaData subclass inheritance). Conformance baseline unchanged (94/94). Full Python suite: 466 baseline + 28 new FR5a = 494/494 green.
1 parent c163bae commit fd03b22

8 files changed

Lines changed: 344 additions & 33 deletions

File tree

server/python/src/metaobjects/errors.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22
from __future__ import annotations
33

44
from enum import Enum
5+
from typing import TYPE_CHECKING, Optional
6+
7+
if TYPE_CHECKING: # avoid runtime import cycles
8+
from .source import ErrorSource
59

610

711
class ErrorCode(str, Enum):
@@ -47,19 +51,28 @@ class ErrorCode(str, Enum):
4751

4852

4953
class MetaError:
50-
"""A loader error. `code` is the conformance-compared value; `message` is human text."""
54+
"""A loader error. ``code`` is the conformance-compared value; ``message`` is human text.
55+
56+
FR5a / ADR-0009: ``envelope`` is the structured provenance envelope every
57+
cross-language port emits — populated by the parser (JSON tree-walk) and by
58+
validation passes that have access to a node's ``source``. Legacy ``source``
59+
(the file path) / ``path`` remain for backward-compat (the conformance
60+
adapter only inspects ``code``); new sites should pass ``envelope``.
61+
"""
5162

5263
def __init__(
5364
self,
5465
message: str,
5566
code: ErrorCode = ErrorCode.ERR_UNKNOWN,
5667
source: str | None = None,
5768
path: str | None = None,
69+
envelope: "Optional[ErrorSource]" = None,
5870
) -> None:
5971
self.message = message
6072
self.code = code
6173
self.source = source
6274
self.path = path
75+
self.envelope = envelope
6376

6477
def __repr__(self) -> str:
6578
return f"MetaError({self.code.name}: {self.message!r})"

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

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from ..provider import Provider, compose_registry
2828
from ..registry import TypeRegistry
2929
from ..shared.base_types import SUBTYPE_ROOT, TYPE_METADATA
30+
from ..source import CodeSource, ErrorSource, JsonSource
3031
from ..super_resolve import resolve_supers
3132
from .merge import merge_roots
3233
from .sources import (
@@ -139,23 +140,38 @@ def _parse_source(
139140
malformed; the caller appends an error and skips this source from
140141
the merge set.
141142
"""
143+
# FR5a / ADR-0009 — top-level envelope for the source. Until the parser
144+
# walk descends, the offending location is the root (`$`).
145+
envelope: ErrorSource = (
146+
JsonSource(files=(src.id,), json_path="$")
147+
if src.id and src.id != "<inline>"
148+
else CodeSource.DEFAULT
149+
)
150+
142151
try:
143152
text = src.read()
144153
except OSError as exc:
145-
errors.append(MetaError(str(exc), ErrorCode.ERR_UNKNOWN, src.id))
154+
errors.append(MetaError(
155+
str(exc), ErrorCode.ERR_UNKNOWN, src.id, envelope=envelope,
156+
))
146157
return None
147158

148159
match src.format:
149160
case MetaDataFormat.YAML:
150161
try:
151162
return parse_yaml(text, self._registry, source=src.id)
152163
except ParseError as exc:
153-
errors.append(MetaError(str(exc), exc.code, src.id))
164+
errors.append(MetaError(
165+
str(exc), exc.code, src.id, envelope=envelope,
166+
))
154167
return None
155168
case MetaDataFormat.JSON:
156169
try:
157170
doc = json.loads(text)
158171
except json.JSONDecodeError as exc:
159-
errors.append(MetaError(str(exc), ErrorCode.ERR_MALFORMED_JSON, src.id))
172+
errors.append(MetaError(
173+
str(exc), ErrorCode.ERR_MALFORMED_JSON, src.id,
174+
envelope=envelope,
175+
))
160176
return None
161177
return parse_document(doc, self._registry, source=src.id)

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

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,7 @@ def _validate_attr_schema(
195195
MetaError(
196196
f"{_node_label(node)} is missing required attribute '@{schema.name}'",
197197
ErrorCode.ERR_MISSING_REQUIRED_ATTR,
198+
envelope=node.source,
198199
)
199200
)
200201

@@ -218,6 +219,7 @@ def _validate_attr_schema(
218219
f"{_node_label(node)} attribute '@{attr_node.name}' has value "
219220
f"{raw_value!r} which does not match expected type '{schema.value_type}'",
220221
ErrorCode.ERR_BAD_ATTR_VALUE,
222+
envelope=node.source,
221223
)
222224
)
223225
continue # type wrong — skip allowed_values check
@@ -231,6 +233,7 @@ def _validate_attr_schema(
231233
f"{_node_label(node)} attribute '@{attr_node.name}' has value "
232234
f"'{raw_value}' which is not one of the allowed values: {allowed_str}",
233235
ErrorCode.ERR_BAD_ATTR_VALUE,
236+
envelope=node.source,
234237
)
235238
)
236239

@@ -277,6 +280,7 @@ def _validate_enum_values(
277280
MetaError(
278281
f"{label} attribute '@{FIELD_ATTR_VALUES}' must not be empty",
279282
ErrorCode.ERR_BAD_ATTR_VALUE,
283+
envelope=node.source,
280284
)
281285
)
282286
continue # further checks don't apply to empty list
@@ -289,6 +293,7 @@ def _validate_enum_values(
289293
f"{label} attribute '@{FIELD_ATTR_VALUES}' member {member!r} "
290294
f"is not a valid identifier (must match {ENUM_MEMBER_PATTERN})",
291295
ErrorCode.ERR_BAD_ATTR_VALUE,
296+
envelope=node.source,
292297
)
293298
)
294299
break # one error per field is sufficient
@@ -299,6 +304,7 @@ def _validate_enum_values(
299304
MetaError(
300305
f"{label} attribute '@{FIELD_ATTR_VALUES}' contains duplicate members",
301306
ErrorCode.ERR_BAD_ATTR_VALUE,
307+
envelope=node.source,
302308
)
303309
)
304310

@@ -337,6 +343,7 @@ def _validate_datagrid_sort_fields(
337343
f"@defaultSortField='{sort_field}' which is not a field on this object "
338344
f"(known fields: {sorted(field_names)})",
339345
ErrorCode.ERR_BAD_DEFAULT_SORT_FIELD,
346+
envelope=child.source,
340347
)
341348
)
342349

@@ -415,6 +422,7 @@ def _validate_datagrid_filter_values(
415422
f"references field '{field_name}' which is not a filterable field "
416423
f"on this object",
417424
ErrorCode.ERR_BAD_ATTR_FILTER,
425+
envelope=child.source,
418426
)
419427
)
420428
continue
@@ -434,6 +442,7 @@ def _validate_datagrid_filter_values(
434442
f"uses operator '{op}' on field '{field_name}' which is not "
435443
f"allowed for field subtype '{sub}'",
436444
ErrorCode.ERR_BAD_ATTR_FILTER,
445+
envelope=child.source,
437446
)
438447
)
439448

@@ -480,19 +489,22 @@ def _validate_entity_field_ref(
480489
context: str,
481490
object_index: dict[str, MetaObject],
482491
errors: list[MetaError],
492+
origin_node: MetaData,
483493
) -> bool:
484494
"""Validate a dotted 'Entity.fieldName' reference.
485495
486496
Appends ERR_INVALID_ORIGIN to *errors* if invalid; returns True if valid.
487497
*attr_name* is used only for the error message text; *context* identifies the
488-
origin node for diagnostic purposes.
498+
origin node for diagnostic purposes; *origin_node* carries the provenance
499+
envelope attached to any emitted error.
489500
"""
490501
parts = ref.split(".", 1)
491502
if len(parts) != 2:
492503
errors.append(
493504
MetaError(
494505
f"{context} @{attr_name}='{ref}' must be in 'EntityName.fieldName' format",
495506
ErrorCode.ERR_INVALID_ORIGIN,
507+
envelope=origin_node.source,
496508
)
497509
)
498510
return False
@@ -503,6 +515,7 @@ def _validate_entity_field_ref(
503515
MetaError(
504516
f"{context} @{attr_name}='{ref}' references unknown entity '{entity_name}'",
505517
ErrorCode.ERR_INVALID_ORIGIN,
518+
envelope=origin_node.source,
506519
)
507520
)
508521
return False
@@ -513,6 +526,7 @@ def _validate_entity_field_ref(
513526
f"{context} @{attr_name}='{ref}' references field '{field_name}' which does "
514527
f"not exist on entity '{entity_name}' (known fields: {sorted(field_names)})",
515528
ErrorCode.ERR_INVALID_ORIGIN,
529+
envelope=origin_node.source,
516530
)
517531
)
518532
return False
@@ -524,17 +538,20 @@ def _validate_via_path(
524538
context: str,
525539
object_index: dict[str, MetaObject],
526540
errors: list[MetaError],
541+
origin_node: MetaData,
527542
) -> bool:
528543
"""Validate a dotted relationship path 'Entity.rel1[.rel2...]'.
529544
530545
Returns True if valid; appends ERR_INVALID_ORIGIN and returns False if not.
546+
*origin_node* carries the provenance envelope attached to any emitted error.
531547
"""
532548
segments = via.split(".")
533549
if len(segments) < 2:
534550
errors.append(
535551
MetaError(
536552
f"{context} @via='{via}' must be in 'EntityName.relName[.relName...]' format",
537553
ErrorCode.ERR_INVALID_ORIGIN,
554+
envelope=origin_node.source,
538555
)
539556
)
540557
return False
@@ -547,6 +564,7 @@ def _validate_via_path(
547564
MetaError(
548565
f"{context} @via='{via}' references unknown entity '{current_name}'",
549566
ErrorCode.ERR_INVALID_ORIGIN,
567+
envelope=origin_node.source,
550568
)
551569
)
552570
return False
@@ -561,6 +579,7 @@ def _validate_via_path(
561579
f"{context} @via='{via}' — entity '{current_entity.name}' has no "
562580
f"relationship '{rel_name}' (known relationships: {sorted(rels)})",
563581
ErrorCode.ERR_INVALID_ORIGIN,
582+
envelope=origin_node.source,
564583
)
565584
)
566585
return False
@@ -573,6 +592,7 @@ def _validate_via_path(
573592
f"{context} @via='{via}' — relationship '{rel_name}' on entity "
574593
f"'{current_entity.name}' has no @objectRef",
575594
ErrorCode.ERR_INVALID_ORIGIN,
595+
envelope=origin_node.source,
576596
)
577597
)
578598
return False
@@ -584,6 +604,7 @@ def _validate_via_path(
584604
f"{context} @via='{via}' — relationship '{rel_name}' on entity "
585605
f"'{current_entity.name}' references unknown entity '{obj_ref}'",
586606
ErrorCode.ERR_INVALID_ORIGIN,
607+
envelope=origin_node.source,
587608
)
588609
)
589610
return False
@@ -619,15 +640,16 @@ def _validate_origin_paths(
619640
MetaError(
620641
f"{ctx} is missing required attribute '@{ORIGIN_ATTR_FROM}'",
621642
ErrorCode.ERR_INVALID_ORIGIN,
643+
envelope=origin.source,
622644
)
623645
)
624646
else:
625647
_validate_entity_field_ref(
626-
from_ref, ORIGIN_ATTR_FROM, ctx, object_index, errors
648+
from_ref, ORIGIN_ATTR_FROM, ctx, object_index, errors, origin,
627649
)
628650
via = origin.attr(ORIGIN_ATTR_VIA)
629651
if isinstance(via, str) and via:
630-
_validate_via_path(via, ctx, object_index, errors)
652+
_validate_via_path(via, ctx, object_index, errors, origin)
631653

632654
elif origin.sub_type == ORIGIN_SUBTYPE_AGGREGATE:
633655
of_ref = origin.attr(ORIGIN_ATTR_OF)
@@ -636,22 +658,24 @@ def _validate_origin_paths(
636658
MetaError(
637659
f"{ctx} is missing required attribute '@{ORIGIN_ATTR_OF}'",
638660
ErrorCode.ERR_INVALID_ORIGIN,
661+
envelope=origin.source,
639662
)
640663
)
641664
else:
642665
_validate_entity_field_ref(
643-
of_ref, ORIGIN_ATTR_OF, ctx, object_index, errors
666+
of_ref, ORIGIN_ATTR_OF, ctx, object_index, errors, origin,
644667
)
645668
via = origin.attr(ORIGIN_ATTR_VIA)
646669
if not isinstance(via, str) or not via:
647670
errors.append(
648671
MetaError(
649672
f"{ctx} is missing required attribute '@{ORIGIN_ATTR_VIA}'",
650673
ErrorCode.ERR_INVALID_ORIGIN,
674+
envelope=origin.source,
651675
)
652676
)
653677
else:
654-
_validate_via_path(via, ctx, object_index, errors)
678+
_validate_via_path(via, ctx, object_index, errors, origin)
655679

656680

657681
# ---------------------------------------------------------------------------
@@ -691,6 +715,7 @@ def _validate_one_primary_source(
691715
f"{_node_label(node)} declares {len(sources)} source(s) but "
692716
f"none has role '{SOURCE_ROLE_PRIMARY}'",
693717
ErrorCode.ERR_SOURCE_NO_PRIMARY,
718+
envelope=node.source,
694719
)
695720
)
696721
elif primary_count > 1:
@@ -699,6 +724,7 @@ def _validate_one_primary_source(
699724
f"{_node_label(node)} declares {primary_count} sources with "
700725
f"role '{SOURCE_ROLE_PRIMARY}'; exactly one is required",
701726
ErrorCode.ERR_SOURCE_MULTIPLE_PRIMARY,
727+
envelope=node.source,
702728
)
703729
)
704730

@@ -736,6 +762,7 @@ def _validate_subtype_rules(
736762
MetaError(
737763
f"{_node_label(node)} is a value object but declares a primary identity",
738764
ErrorCode.ERR_SUBTYPE_RULE_VIOLATION,
765+
envelope=node.source,
739766
)
740767
)
741768

@@ -812,6 +839,7 @@ def _validate_field_object_storage(root: MetaData, errors: list[MetaError]) -> N
812839
f"field.object '{node.name}' has @storage but no @objectRef — "
813840
f"@storage shape only applies to referenced objects"
814841
),
842+
envelope=node.source,
815843
))
816844
continue
817845
if storage == "flattened" and getattr(node, "is_array", False):
@@ -821,6 +849,7 @@ def _validate_field_object_storage(root: MetaData, errors: list[MetaError]) -> N
821849
f"field.object '{node.name}' @storage=\"flattened\" cannot be combined "
822850
f"with isArray=true (use @storage=\"jsonb\" for owned-array storage)"
823851
),
852+
envelope=node.source,
824853
))
825854

826855

@@ -853,6 +882,7 @@ def _validate_templates(root: MetaData, errors: list[MetaError]) -> None:
853882
errors.append(MetaError(
854883
code=ErrorCode.ERR_MISSING_REQUIRED_ATTR,
855884
message=f"template.prompt '{tpl.name}' is missing required @payloadRef",
885+
envelope=tpl.source,
856886
))
857887
continue
858888

@@ -868,6 +898,7 @@ def _validate_templates(root: MetaData, errors: list[MetaError]) -> None:
868898
f"template '{tpl.name}' @payloadRef '{payload_ref}' "
869899
f"does not resolve to an object.value at root"
870900
),
901+
envelope=tpl.source,
871902
))
872903
continue
873904

@@ -885,6 +916,7 @@ def _validate_templates(root: MetaData, errors: list[MetaError]) -> None:
885916
f"template.prompt '{tpl.name}' @requiredSlots includes '{slot}' "
886917
f"which is not a field on payload '{payload_ref}'"
887918
),
919+
envelope=tpl.source,
888920
))
889921

890922

0 commit comments

Comments
 (0)