Skip to content

Commit 43b8ac2

Browse files
dmealingclaude
andcommitted
feat(conformance): #37 Python — flattened kitchen-sink byte-match + effective-serialization gating
Surfaced + fixed a latent Python super-resolution divergence: a cross-package fully-qualified `extends` (e.g. `acme::catalog::Product extends acme::common::BaseEntity`) failed to resolve over the MERGED tree because the FQN index keyed only on bare `fqn()` and the referrer's effective package was derived from the threaded walk context (objects carry no own package post-merge). Now each node captures `file_default_package` at parse time; the super-resolve index also keys on `<file_default_package>::<name>` and the referrer uses it as the effective package — mirroring the TS `findInTree` matcher (own `fqn()` OR `resolutionKey()`). This eliminated four false-positive "no primary identity" warnings (the inherited `identity.primary` from BaseEntity was invisible) and made `expected.json` byte-match. Wired `expected-effective.json` gating: fixture discovery detects the file, the adapter exposes `canonical_serialize_effective` (children()/attrs() — inherited members inlined, extends still emitted), and the runner byte-compares (newline-normalized) when present. New serializer threads an `effective` flag. Kitchen-sink byte-matches BOTH expected.json + expected-effective.json. Conformance 234 passed; full Python suite 1108 passed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 0641496 commit 43b8ac2

8 files changed

Lines changed: 147 additions & 12 deletions

File tree

server/python/src/metaobjects/meta/meta_data.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,14 @@ def __init__(self, type_: str, sub_type: str, name: str) -> None:
1717
self.sub_type = sub_type
1818
self.name = name
1919
self.package: Optional[str] = None
20+
# The file-default package captured at PARSE time (the package declared
21+
# on the owning file's root). Distinct from ``package`` (which an object
22+
# node leaves unset — the parser does not fold the file-default package
23+
# onto an object's own package). Used by super-resolution to match a
24+
# cross-package fully-qualified ``extends`` over the MERGED tree, where
25+
# per-file root packages are no longer reachable via the parent chain.
26+
# Mirrors TS ``MetaData.fileDefaultPackage`` / ``resolutionKey()``.
27+
self.file_default_package: Optional[str] = None
2028
self.super_ref: Optional[str] = None
2129
self.super_data: Optional[MetaData] = None
2230
self.is_abstract = False

server/python/src/metaobjects/parser.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,13 @@ def _build(
213213
node.set_source(_current_envelope(source, builder, yaml_position))
214214

215215
pkg = body_dict.get(KEY_PACKAGE)
216+
# Capture the file-default package at PARSE time so cross-package
217+
# fully-qualified ``extends`` resolves over the MERGED tree (where per-file
218+
# root packages are no longer reachable via the parent chain). The node's
219+
# own ``package`` if declared, else the inherited context package (the
220+
# file's root package). Mirrors TS ``MetaData.fileDefaultPackage``.
221+
node.file_default_package = (str(pkg) if pkg else None) or (ctx_pkg or None)
222+
216223
if pkg:
217224
node.package = str(pkg)
218225
elif type_ == TYPE_FIELD and parent_type != TYPE_OBJECT and ctx_pkg:

server/python/src/metaobjects/serializer_json.py

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,24 @@
2626

2727

2828
def canonical_serialize(node: MetaData) -> str:
29-
parsed = _to_canonical(node)
29+
return _serialize(node, effective=False)
30+
31+
32+
def canonical_serialize_effective(node: MetaData) -> str:
33+
"""Like :func:`canonical_serialize`, but emits the EFFECTIVE tree —
34+
``children()`` + ``attrs()`` at every node (own + inherited via the super
35+
chain), so the super-chain merge is materialized in the output.
36+
37+
Used by the conformance harness's ``expected-effective.json`` fixtures.
38+
Mirrors the TS reference ``canonicalSerializeEffective``: ``extends`` is
39+
still emitted on every node (the ref stays in the body), but inherited
40+
members are inlined rather than referenced.
41+
"""
42+
return _serialize(node, effective=True)
43+
44+
45+
def _serialize(node: MetaData, effective: bool) -> str:
46+
parsed = _to_canonical(node, effective)
3047
# FR-016 / ADR-0018 — rewrite legacy @table → kind-matching alias on
3148
# source.rdb wrappers; run before serialization so the rewritten key sorts
3249
# naturally with the rest of the body (alphabetical at our depth).
@@ -35,11 +52,11 @@ def canonical_serialize(node: MetaData) -> str:
3552
return text + "\n"
3653

3754

38-
def _to_canonical(node: MetaData) -> dict[str, object]:
39-
return {f"{node.type}{FUSED_KEY_SEP}{node.sub_type}": _body(node)}
55+
def _to_canonical(node: MetaData, effective: bool = False) -> dict[str, object]:
56+
return {f"{node.type}{FUSED_KEY_SEP}{node.sub_type}": _body(node, effective)}
4057

4158

42-
def _body(node: MetaData) -> dict[str, object]:
59+
def _body(node: MetaData, effective: bool = False) -> dict[str, object]:
4360
body: dict[str, object] = {}
4461
if node.name:
4562
body[KEY_NAME] = node.name
@@ -52,12 +69,18 @@ def _body(node: MetaData) -> dict[str, object]:
5269
if node.is_array:
5370
body[KEY_IS_ARRAY] = True
5471

55-
for attr in sorted(node.own_meta_attrs(), key=lambda a: a.name):
56-
body[f"{ATTR_PREFIX}{attr.name}"] = _normalize(getattr(attr, "value", None))
72+
# In effective mode use attrs()/children() (own + inherited via the super
73+
# chain); in own mode use own_meta_attrs()/own_children() (declared here).
74+
if effective:
75+
for name in sorted(node.attrs()):
76+
body[f"{ATTR_PREFIX}{name}"] = _normalize(node.attrs()[name])
77+
else:
78+
for attr in sorted(node.own_meta_attrs(), key=lambda a: a.name):
79+
body[f"{ATTR_PREFIX}{attr.name}"] = _normalize(getattr(attr, "value", None))
5780

58-
children = node.own_children()
81+
children = node.children() if effective else node.own_children()
5982
if children:
60-
body[KEY_CHILDREN] = [_to_canonical(c) for c in children]
83+
body[KEY_CHILDREN] = [_to_canonical(c, effective) for c in children]
6184
return body
6285

6386

server/python/src/metaobjects/super_resolve.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,15 @@ def _walk(
3333
) -> None:
3434
"""Visit *node* then recurse over own_children(), carrying an inherited context package."""
3535
if node.super_ref and node.super_data is None:
36-
effective_pkg = node.package or ctx_pkg or None
36+
# Referrer context package: own ``package`` if declared, else the
37+
# file-default package captured at parse time, else the inherited
38+
# walk context. Using file_default_package (not just the threaded
39+
# ctx_pkg) is what lets a bare / same-package / cross-package
40+
# ``extends`` resolve over the MERGED tree, where object nodes carry
41+
# no own package and the parent chain no longer reaches the per-file
42+
# root. Mirrors TS ``resolveDeferredSupers``
43+
# (``node.package ?? node.fileDefaultPackage``).
44+
effective_pkg = node.package or node.file_default_package or ctx_pkg or None
3745
target = _resolve(node.super_ref, effective_pkg, index)
3846
if target is None:
3947
# FR5d / ADR-0009: emit a ResolvedSource envelope carrying the
@@ -56,7 +64,16 @@ def _walk(
5664

5765

5866
def _build_index(root: MetaData) -> dict[str, MetaData]:
59-
"""Build a fqn() → node index over the whole merged tree (own_children walk)."""
67+
"""Build a lookup index over the whole merged tree (own_children walk).
68+
69+
Each named node is registered under its own ``fqn()`` AND — when it has no
70+
own ``package`` but a file-default package was captured at parse time —
71+
under the package-folded key ``<file_default_package>::<name>``. The second
72+
key is what makes a cross-package fully-qualified ``extends`` resolve over
73+
the merged tree (object ``fqn()`` stays bare because the parser does not
74+
fold the file-default package onto the object's own package). Mirrors the
75+
TS ``findInTree`` matcher (own ``fqn()`` OR ``resolutionKey()``).
76+
"""
6077
idx: dict[str, MetaData] = {}
6178
_index_walk(root, idx)
6279
return idx
@@ -65,6 +82,9 @@ def _build_index(root: MetaData) -> dict[str, MetaData]:
6582
def _index_walk(node: MetaData, idx: dict[str, MetaData]) -> None:
6683
if node.name:
6784
idx.setdefault(node.fqn(), node)
85+
if not node.package and node.file_default_package:
86+
folded = f"{node.file_default_package}{PACKAGE_SEP}{node.name}"
87+
idx.setdefault(folded, node)
6888
for child in node.own_children():
6989
_index_walk(child, idx)
7090

server/python/tests/conformance/conformance_adapter.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@
1313
from metaobjects.meta.template.meta_template import MetaTemplate
1414
from metaobjects.provider import Provider
1515
from metaobjects.registry import AttrSchema, ChildRule, TypeDefinition
16-
from metaobjects.serializer_json import canonical_serialize
16+
from metaobjects.serializer_json import (
17+
canonical_serialize,
18+
canonical_serialize_effective,
19+
)
1720
from metaobjects.shared.base_types import TYPE_ATTR
1821
from metaobjects.source.error_source import (
1922
CodeSource,
@@ -266,3 +269,23 @@ def load_fixture_result(
266269
"""
267270
providers = _resolve_providers(provider_ids)
268271
return MetaDataLoader.from_directory(input_dir, providers=providers)
272+
273+
274+
def load_fixture_effective(
275+
input_dir: Path,
276+
provider_ids: Optional[Iterable[str]] = None,
277+
) -> str:
278+
"""Return the EFFECTIVE canonical serialization (extends resolved —
279+
inherited members inlined) of the loaded fixture root.
280+
281+
Mirrors the TS adapter's ``canonicalSerializeEffective(tree)``. Gated by a
282+
fixture's ``expected-effective.json`` (see the conformance runner). On a
283+
composition failure (no tree), returns ``""`` — same shape the canonical
284+
path uses, so the runner's byte-compare surfaces the mismatch.
285+
"""
286+
providers = _resolve_providers(provider_ids)
287+
result, compose_code = _load_or_capture_compose_error(input_dir, providers)
288+
if compose_code is not None:
289+
return ""
290+
assert result is not None
291+
return canonical_serialize_effective(result.root)

server/python/tests/conformance/fixture_discovery.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ class Fixture:
1717
input_dir: Path
1818
providers: tuple[str, ...]
1919
has_expected: bool
20+
has_expected_effective: bool
2021
has_expected_errors: bool
2122
has_expected_warnings: bool
2223
has_script: bool
@@ -50,6 +51,7 @@ def discover_fixtures(corpus: Path) -> list[Fixture]:
5051
input_dir=input_dir,
5152
providers=providers,
5253
has_expected=(entry / "expected.json").is_file(),
54+
has_expected_effective=(entry / "expected-effective.json").is_file(),
5355
has_expected_errors=(entry / "expected-errors.json").is_file(),
5456
has_expected_warnings=(entry / "expected-warnings.json").is_file(),
5557
has_script=(entry / "script.json").is_file(),

server/python/tests/conformance/test_conformance.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@
66
import pytest
77

88
from .capabilities import invoke
9-
from .conformance_adapter import load_fixture_result, load_fixture_with_envelopes
9+
from .conformance_adapter import (
10+
load_fixture_effective,
11+
load_fixture_result,
12+
load_fixture_with_envelopes,
13+
)
1014
from .corpus import corpus_root
1115
from .expected_failures import classify
1216
from .fixture_discovery import Fixture, discover_fixtures
@@ -172,6 +176,17 @@ def _run_checks(fix: Fixture) -> tuple[bool, str]:
172176
if want_tree != got_tree:
173177
failures.append("canonical serialization mismatch")
174178

179+
# Effective-tree check — run whenever expected-effective.json exists,
180+
# matching the TS reference runner (hasExpectedEffective). Emits the
181+
# EFFECTIVE canonical serialization (extends resolved — inherited members
182+
# inlined) and byte-compares newline-normalized text. Asserted only when
183+
# the fixture declares it.
184+
if fix.has_expected_effective:
185+
want_eff = (fix.dir / "expected-effective.json").read_text()
186+
got_eff = load_fixture_effective(fix.input_dir, provider_ids=fix.providers)
187+
if want_eff.strip() != got_eff.strip():
188+
failures.append("effective serialization mismatch")
189+
175190
if fix.has_expected_warnings:
176191
want_w = sorted(json.loads((fix.dir / "expected-warnings.json").read_text()))
177192
if want_w != sorted(warnings):

server/python/tests/unit/test_super_resolve.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,3 +188,40 @@ def test_resolve_over_deep_relative_ref_emits_error() -> None:
188188
assert len(errors) == 1
189189
assert errors[0].code == ErrorCode.ERR_UNRESOLVED_SUPER
190190
assert sub.super_data is None
191+
192+
193+
# ---------------------------------------------------------------------------
194+
# (h) cross-PACKAGE fully-qualified ref over a MERGED tree (#37)
195+
# ---------------------------------------------------------------------------
196+
197+
def test_resolve_cross_package_fq_ref_via_file_default_package() -> None:
198+
"""A fully-qualified cross-package ``extends`` resolves over the merged tree.
199+
200+
After merge, every object lives under one root and carries NO own
201+
``package`` (the package was declared on each *file's* root). The
202+
file-default package is captured at parse time as ``file_default_package``;
203+
resolution must index both the bare ``fqn()`` AND the package-folded
204+
``<file_default_package>::<name>`` so an ``extends: "acme::common::Base"``
205+
from a node whose file-default package is ``acme::catalog`` resolves to the
206+
Base declared under file-default package ``acme::common``.
207+
208+
Mirrors the TS reference (``findInTree`` matching ``resolutionKey()``).
209+
"""
210+
root = MetaRoot(TYPE_METADATA, SUBTYPE_ROOT, "") # merged root: no package
211+
212+
# Base — declared in file with package acme::common; no own package post-merge.
213+
base = _node("object", "entity", "Base")
214+
base.file_default_package = "acme::common"
215+
root.add_child(base)
216+
217+
# Sub — declared in file with package acme::catalog; FQ cross-package ref.
218+
sub = _node("object", "entity", "Sub")
219+
sub.file_default_package = "acme::catalog"
220+
sub.super_ref = "acme::common::Base"
221+
root.add_child(sub)
222+
223+
errors: list[MetaError] = []
224+
resolve_supers(root, errors)
225+
226+
assert errors == [], f"expected no errors, got {errors}"
227+
assert sub.super_data is base

0 commit comments

Comments
 (0)