Skip to content

Commit c163bae

Browse files
committed
feat(python): FR5a foundation — ErrorSource hierarchy, JsonPath, SemanticDiff
Mirrors the TS + C# FR5a foundation (per ADR-0009): a new `metaobjects.source` module ships the cross-port-aligned error envelope types, the canonical JSONPath builder, and the SemanticDiff skeleton that FR5c will consume for duplicate-with-no-change detection. * `error_source.py` — `@dataclass(frozen=True)` discriminated union: `ErrorSource` (abstract base) + `JsonSource` / `YamlSource` / `MergedSource` / `ResolvedSource` / `DatabaseSource` / `CodeSource`. Supporting types: `Contributor`, `NodeContext`, `YamlPosition`, `DbLocation`, `LoaderError`, `LoaderWarning`. `JsonSource.__post_init__` enforces the FR5a length-1 invariant — multi-file provenance is `MergedSource` (FR5c) territory. * `json_path.py` — `JsonPathBuilder` (push_key / push_index / pop) + `JsonPath.segment_for_key` / `segment_for_index`. Identifier-safe keys use dot notation; everything else (including `@`-prefixed attrs and fused-key wrappers like `object.entity`) uses single-quoted bracket form with `'` escaped to `\\'`. Matches the C# / TS canonical regex `^[A-Za-z_][A-Za-z0-9_]*$` byte-identically. * `semantic_diff.py` — `semantic_diff(a, b) -> bool` over MetaData (via canonical_serialize) or raw JSON values; key-order independent for objects, ordered for arrays, `source` excluded. Skeleton ships now; FR5c consumes it. Tests: +21 new (13 JsonPath edge cases mirroring the C# fixture; 8 SemanticDiff smoke cases including identical-tree and divergent-tree MetaData diffs).
1 parent 36be2e7 commit c163bae

7 files changed

Lines changed: 708 additions & 0 deletions

File tree

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
"""FR5a / ADR-0009 — Loader error envelope + source-on-node.
2+
3+
Cross-port-aligned types: every metaobjects port emits the same envelope shape
4+
so a tool consuming errors from multiple language ports can compare them
5+
byte-identically.
6+
7+
Public surface re-exported here:
8+
* ErrorSource hierarchy (abstract base + 6 variants).
9+
* JsonPathBuilder + JsonPath helpers (canonical JSONPath construction).
10+
* LoaderError / LoaderWarning / NodeContext / Contributor envelope types.
11+
* semantic_diff(a, b) for FR5c overlay-merge consumption.
12+
13+
See `docs/superpowers/specs/2026-05-25-fr5a-json-shape-loader-errors.md` and
14+
`spec/decisions/ADR-0009-loader-error-envelope-and-source-on-node.md`.
15+
"""
16+
from __future__ import annotations
17+
18+
from .error_source import (
19+
CodeSource,
20+
Contributor,
21+
DatabaseSource,
22+
DbLocation,
23+
ErrorSource,
24+
JsonSource,
25+
LoaderError,
26+
LoaderWarning,
27+
MergedSource,
28+
NodeContext,
29+
ResolvedSource,
30+
YamlPosition,
31+
YamlSource,
32+
)
33+
from .json_path import JsonPath, JsonPathBuilder
34+
from .semantic_diff import semantic_diff
35+
36+
__all__ = [
37+
# ErrorSource hierarchy
38+
"ErrorSource",
39+
"JsonSource",
40+
"YamlSource",
41+
"MergedSource",
42+
"ResolvedSource",
43+
"DatabaseSource",
44+
"CodeSource",
45+
# Supporting types
46+
"Contributor",
47+
"DbLocation",
48+
"NodeContext",
49+
"YamlPosition",
50+
"LoaderError",
51+
"LoaderWarning",
52+
# JSONPath
53+
"JsonPathBuilder",
54+
"JsonPath",
55+
# Semantic diff
56+
"semantic_diff",
57+
]
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
"""FR5a / ADR-0009 — ErrorSource discriminated union + envelope types.
2+
3+
Closed hierarchy over the provenance variants a metadata node or error can
4+
carry. The cross-port realization in Python uses ``@dataclass(frozen=True)`` so
5+
each variant is value-equal, hashable, and immutable.
6+
7+
Pattern-match on the concrete subtype (``isinstance(src, JsonSource)``) to
8+
access variant-specific fields, or read ``src.format`` for the discriminant
9+
tag.
10+
11+
Mirrors:
12+
* TS — `server/typescript/packages/metadata/src/source.ts`
13+
* C# — `server/csharp/MetaObjects/Source/ErrorSource.cs`
14+
"""
15+
from __future__ import annotations
16+
17+
from dataclasses import dataclass, field
18+
from typing import ClassVar, Optional
19+
20+
21+
# ---------------------------------------------------------------------------
22+
# Discriminated union: ErrorSource hierarchy
23+
# ---------------------------------------------------------------------------
24+
25+
26+
@dataclass(frozen=True)
27+
class ErrorSource:
28+
"""Provenance envelope for a metadata node or loader error.
29+
30+
Abstract base — never instantiated directly. Every concrete variant
31+
declares a class-level ``format`` constant that names its discriminant tag
32+
in the cross-port wire shape.
33+
"""
34+
35+
# Subclasses override this class-level constant. Declared here as a
36+
# placeholder so static type-checkers see a string attribute on the base.
37+
format: ClassVar[str] = ""
38+
39+
40+
@dataclass(frozen=True)
41+
class JsonSource(ErrorSource):
42+
"""Authoring-time: single JSON file (FR5a).
43+
44+
Args:
45+
files: Length-1 tuple of project-root-relative file paths. The FR5a
46+
invariant guarantees exactly one file — multi-file provenance lives
47+
on :class:`MergedSource` (FR5c). Enforced at construction.
48+
json_path: Canonical JSONPath string for the node within ``files[0]``.
49+
"""
50+
51+
files: tuple[str, ...]
52+
json_path: str
53+
format: ClassVar[str] = "json"
54+
55+
def __post_init__(self) -> None:
56+
# FR5a invariant: exactly one file. Multi-file provenance is
57+
# MergedSource (FR5c) territory; enforce here so the type can be
58+
# trusted by cross-port comparison.
59+
if len(self.files) != 1:
60+
raise ValueError(
61+
f"JsonSource requires exactly one file path; got {len(self.files)}. "
62+
"Use MergedSource for multi-file provenance."
63+
)
64+
65+
66+
@dataclass(frozen=True)
67+
class YamlPosition:
68+
"""Optional YAML line/col source position (FR5b). 1-based."""
69+
70+
line: int
71+
col: int
72+
73+
74+
@dataclass(frozen=True)
75+
class YamlSource(ErrorSource):
76+
"""Authoring-time: single YAML file with optional source-map positions (FR5b)."""
77+
78+
files: tuple[str, ...]
79+
json_path: str
80+
yaml_position: Optional[YamlPosition] = None
81+
format: ClassVar[str] = "yaml"
82+
83+
84+
@dataclass(frozen=True)
85+
class Contributor:
86+
"""One contributor in a :class:`MergedSource`.
87+
88+
Args:
89+
file: Path to the contributing file (project-root relative; forward slashes).
90+
role: One of ``"overlay-base"``, ``"overlay-extension"``,
91+
``"extends-base"``, ``"extends-extension"``.
92+
"""
93+
94+
file: str
95+
role: str
96+
97+
98+
@dataclass(frozen=True)
99+
class MergedSource(ErrorSource):
100+
"""Post-load: overlay merge that produced semantic change (FR5c)."""
101+
102+
files: tuple[str, ...]
103+
json_path: str
104+
contributors: tuple[Contributor, ...]
105+
format: ClassVar[str] = "merged"
106+
107+
108+
@dataclass(frozen=True)
109+
class ResolvedSource(ErrorSource):
110+
"""Post-load: extends / @via / @objectRef / @payloadRef resolution failure (FR5d)."""
111+
112+
files: tuple[str, ...]
113+
json_path: Optional[str] = None
114+
referrer: Optional[str] = None
115+
target: Optional[str] = None
116+
format: ClassVar[str] = "resolved"
117+
118+
119+
@dataclass(frozen=True)
120+
class DbLocation:
121+
"""Database location for a node sourced from a row."""
122+
123+
table: str
124+
id: str
125+
126+
127+
@dataclass(frozen=True)
128+
class DatabaseSource(ErrorSource):
129+
"""Future: database-sourced metadata (FR5e, gated on FR-003)."""
130+
131+
db_location: DbLocation
132+
json_path: Optional[str] = None
133+
format: ClassVar[str] = "database"
134+
135+
136+
@dataclass(frozen=True)
137+
class CodeSource(ErrorSource):
138+
"""Programmatic / test construction.
139+
140+
The default for any node not built by a loader phase. Mirrors TS
141+
``codeSource(caller?)``.
142+
143+
Args:
144+
caller: Optional human label (e.g. ``"QueriesTest.makePost"``).
145+
"""
146+
147+
caller: Optional[str] = None
148+
format: ClassVar[str] = "code"
149+
150+
151+
# Canonical singleton for the no-caller case. Frozen dataclasses are
152+
# hash-equal across instances, but a single shared default makes intent
153+
# explicit and matches the C# `CodeSource.Default` convention.
154+
CodeSource.DEFAULT = CodeSource(caller=None) # type: ignore[attr-defined]
155+
156+
157+
# ---------------------------------------------------------------------------
158+
# Envelope types: error / warning structures
159+
# ---------------------------------------------------------------------------
160+
161+
162+
@dataclass(frozen=True)
163+
class NodeContext:
164+
"""Optional structural context attached to a loader error.
165+
166+
RECOMMENDED per ADR-0009; conformance does not enforce population.
167+
"""
168+
169+
type: Optional[str] = None
170+
sub_type: Optional[str] = None
171+
name: Optional[str] = None
172+
fqn: Optional[str] = None
173+
174+
175+
@dataclass(frozen=True)
176+
class LoaderError:
177+
"""Envelope shape every loader error conforms to. ADR-0009 §Decision.
178+
179+
``code``, ``message`` and ``source`` are REQUIRED (conformance-enforced).
180+
The remaining fields are RECOMMENDED; FR5a does not populate them, FR5b–FR5e
181+
may.
182+
"""
183+
184+
code: str
185+
message: str
186+
source: ErrorSource
187+
suggestions: tuple[str, ...] = field(default_factory=tuple)
188+
fixture: Optional[str] = None
189+
node: Optional[NodeContext] = None
190+
191+
192+
@dataclass(frozen=True)
193+
class LoaderWarning:
194+
"""Warning envelope — same shape as :class:`LoaderError` but a ``WARN_*`` code.
195+
196+
Today's only initial warning code is ``WARN_DUPLICATE_DECLARATION``
197+
(emitted by overlay-merge when a duplicate-with-no-change is detected;
198+
FR5c will produce these). FR5a does not raise warnings via this channel —
199+
legacy ``warnings: list[str]`` continues to flow through the loader.
200+
"""
201+
202+
code: str
203+
message: str
204+
source: ErrorSource
205+
suggestions: tuple[str, ...] = field(default_factory=tuple)
206+
fixture: Optional[str] = None
207+
node: Optional[NodeContext] = None
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
"""FR5a / ADR-0009 — Canonical JSONPath builder.
2+
3+
Construction rules (cross-port-aligned; every port emits this canonical form
4+
byte-identically):
5+
* Root is ``$``.
6+
* Object keys matching ``^[A-Za-z_][A-Za-z0-9_]*$`` use dot notation: ``.foo``.
7+
* All other keys use single-quoted bracket form: ``['my-key']``,
8+
``['@attr']``. Embedded single quotes are escaped with ``\\'``.
9+
* Array indices use bracket form: ``[N]`` (zero-based).
10+
* No trailing dots, no whitespace.
11+
12+
Mirrors:
13+
* TS — `server/typescript/packages/metadata/src/json-path.ts`
14+
* C# — `server/csharp/MetaObjects/Source/JsonPath.cs`
15+
"""
16+
from __future__ import annotations
17+
18+
import re
19+
from typing import Optional, Union
20+
21+
# Identifier regex shared with the static :class:`JsonPath` helpers. Compiled
22+
# once at module load.
23+
_IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
24+
25+
26+
def _render_key_segment(key: str) -> str:
27+
"""Render a single object-key segment: dot notation if identifier-safe, else bracket form."""
28+
if _IDENT_RE.match(key):
29+
return f".{key}"
30+
escaped = key.replace("'", "\\'")
31+
return f"['{escaped}']"
32+
33+
34+
def _render_index_segment(idx: int) -> str:
35+
"""Render a single array-index segment: ``[N]``."""
36+
return f"[{idx}]"
37+
38+
39+
class JsonPathBuilder:
40+
"""Builds the canonical JSONPath string for a node as the parser walks the
41+
JSON tree. Push a key or index when descending; pop when returning.
42+
43+
Mirrors ``JsonPathBuilder`` in
44+
``typescript/packages/metadata/src/json-path.ts`` and the C# implementation.
45+
"""
46+
47+
__slots__ = ("_segments",)
48+
49+
def __init__(self) -> None:
50+
# Each segment is (kind, payload): kind is "key" or "index".
51+
self._segments: list[tuple[str, Union[str, int]]] = []
52+
53+
def push_key(self, key: str) -> None:
54+
"""Push an object key segment (e.g. ``.foo`` or ``['my-key']``)."""
55+
self._segments.append(("key", key))
56+
57+
def push_index(self, idx: int) -> None:
58+
"""Push an array-index segment (e.g. ``[2]``)."""
59+
self._segments.append(("index", idx))
60+
61+
def pop(self) -> None:
62+
"""Pop the most recently pushed segment."""
63+
if self._segments:
64+
self._segments.pop()
65+
66+
@property
67+
def depth(self) -> int:
68+
"""Number of segments currently on the stack (root is segment 0; not counted)."""
69+
return len(self._segments)
70+
71+
def to_string(self) -> str:
72+
"""Render the current stack as a canonical JSONPath string."""
73+
parts: list[str] = ["$"]
74+
for kind, payload in self._segments:
75+
if kind == "index":
76+
# mypy-friendly: cast not needed; assignment ensures int.
77+
parts.append(_render_index_segment(int(payload)))
78+
else:
79+
parts.append(_render_key_segment(str(payload)))
80+
return "".join(parts)
81+
82+
def __str__(self) -> str: # pragma: no cover — convenience
83+
return self.to_string()
84+
85+
86+
class JsonPath:
87+
"""Static helpers for one-shot JSONPath rendering when a builder is overkill.
88+
89+
Equivalent to the C# ``JsonPath`` static helpers; namespaced as a class so
90+
the call sites read identically in Python (``JsonPath.segment_for_key(...)``).
91+
"""
92+
93+
@staticmethod
94+
def segment_for_key(key: str) -> str:
95+
"""Render a single object-key segment as it would appear in canonical
96+
form, without the leading ``$`` — i.e. ``.foo`` or ``['my-key']``.
97+
"""
98+
return _render_key_segment(key)
99+
100+
@staticmethod
101+
def segment_for_index(idx: int) -> str:
102+
"""Render a single array-index segment: ``[N]``."""
103+
return _render_index_segment(idx)
104+
105+
106+
__all__ = ["JsonPath", "JsonPathBuilder"]

0 commit comments

Comments
 (0)