Skip to content

Commit e13b5f2

Browse files
committed
Merge worktree-warn-envelope-python-2026-05-27 — WARN envelope-shape Python port
2 parents 3987527 + da78352 commit e13b5f2

2 files changed

Lines changed: 104 additions & 20 deletions

File tree

server/python/tests/conformance/conformance_adapter.py

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -49,11 +49,13 @@ def _relativize(file_path: str, input_dir: Path) -> str:
4949
return file_path.replace("\\", "/")
5050

5151

52-
def _build_envelope(err, input_dir: Path) -> ErrorEnvelopeRecord:
53-
"""Convert a Python MetaError into the cross-port envelope record."""
54-
code = err.code.name
55-
env = err.envelope
52+
def _build_envelope_from_source(code: str, env, input_dir: Path) -> ErrorEnvelopeRecord:
53+
"""Build an envelope record from a (code, ErrorSource) pair.
5654
55+
Shared between error-side and warning-side normalization so both channels
56+
produce structurally identical records (mirrors the TS adapter's single
57+
normalization path).
58+
"""
5759
def rel_files() -> tuple[str, ...]:
5860
return tuple(_relativize(f, input_dir) for f in env.files)
5961

@@ -75,6 +77,20 @@ def rel_files() -> tuple[str, ...]:
7577
return ErrorEnvelopeRecord(code, "json", (), "$")
7678

7779

80+
def _build_envelope(err, input_dir: Path) -> ErrorEnvelopeRecord:
81+
"""Convert a Python MetaError into the cross-port envelope record."""
82+
return _build_envelope_from_source(err.code.name, err.envelope, input_dir)
83+
84+
85+
def _build_warning_envelope(warn, input_dir: Path) -> ErrorEnvelopeRecord:
86+
"""Convert a Python LoaderWarning into the cross-port envelope record.
87+
88+
FR5c-finalize — mirrors :func:`_build_envelope` so the cross-port runner
89+
can assert warning envelopes the same way it asserts error envelopes.
90+
"""
91+
return _build_envelope_from_source(warn.code, warn.source, input_dir)
92+
93+
7894
def load_fixture(input_dir: Path) -> tuple[list[str], list[str], str]:
7995
"""Return (error_codes, warnings, canonical_serialization)."""
8096
result = MetaDataLoader.from_directory(input_dir, providers=_PROVIDERS)
@@ -85,17 +101,30 @@ def load_fixture(input_dir: Path) -> tuple[list[str], list[str], str]:
85101

86102
def load_fixture_with_envelopes(
87103
input_dir: Path,
88-
) -> tuple[list[str], list[ErrorEnvelopeRecord], list[str], str]:
89-
"""Return (error_codes, error_envelopes, warnings, canonical_serialization).
104+
) -> tuple[
105+
list[str],
106+
list[ErrorEnvelopeRecord],
107+
list[str],
108+
list[ErrorEnvelopeRecord],
109+
str,
110+
]:
111+
"""Return (error_codes, error_envelopes, warnings, warning_envelopes, canonical).
90112
91113
FR5a / ADR-0009 — provides the per-error envelope alongside the legacy
92114
code-list so the conformance runner can do the envelope assertion.
115+
116+
FR5c-finalize — additionally provides per-warning envelopes (same
117+
normalization as the error side) so the runner can assert warning
118+
envelope shape when ``expected-errors.json#warnings[]`` declares it.
93119
"""
94120
result = MetaDataLoader.from_directory(input_dir, providers=_PROVIDERS)
95121
codes = [e.code.name for e in result.errors]
96122
envelopes = [_build_envelope(e, input_dir) for e in result.errors]
123+
warning_envelopes = [
124+
_build_warning_envelope(w, input_dir) for w in result.get_envelope_warnings()
125+
]
97126
canonical = canonical_serialize(result.root)
98-
return codes, envelopes, list(result.warnings), canonical
127+
return codes, envelopes, list(result.warnings), warning_envelopes, canonical
99128

100129

101130
def load_fixture_result(input_dir: Path) -> LoadResult:

server/python/tests/conformance/test_conformance.py

Lines changed: 68 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,20 @@
1515
_FIXTURES = discover_fixtures(corpus_root())
1616

1717

18-
def _parse_expected_errors(raw: object) -> tuple[list[dict], int, bool]:
18+
def _parse_expected_errors(raw: object) -> tuple[list[dict], list[dict], bool]:
1919
"""Accept both legacy ``[{code}]`` and FR5a envelope shapes.
2020
21-
Returns (errors, warnings_count, legacy_flag).
21+
Returns (errors, warnings, legacy_flag) where each entry is
22+
``{"code": str, "source": dict | None}``.
23+
24+
FR5c-finalize — ``warnings[]`` entries may now carry ``source`` (envelope
25+
shape) so the runner can assert per-warning envelope alignment. Legacy
26+
fixtures (empty ``[]`` or pre-finalize envelope-of-counts-only) parse
27+
cleanly with ``source = None`` and the per-element source assertion is
28+
skipped element-by-element below.
2229
"""
2330
if isinstance(raw, list):
24-
return ([{"code": e["code"], "source": None} for e in raw], 0, True)
31+
return ([{"code": e["code"], "source": None} for e in raw], [], True)
2532
if isinstance(raw, dict) and "errors" in raw and isinstance(raw["errors"], list):
2633
errors = []
2734
for idx, e in enumerate(raw["errors"]):
@@ -41,20 +48,36 @@ def _parse_expected_errors(raw: object) -> tuple[list[dict], int, bool]:
4148
"code": e["code"],
4249
"source": src if isinstance(src, dict) else None,
4350
})
44-
warnings = raw.get("warnings", [])
45-
wc = len(warnings) if isinstance(warnings, list) else 0
46-
return (errors, wc, False)
51+
raw_warnings = raw.get("warnings", [])
52+
warnings: list[dict] = []
53+
if isinstance(raw_warnings, list):
54+
for idx, w in enumerate(raw_warnings):
55+
if not isinstance(w, dict):
56+
raise ValueError(
57+
f"expected-errors.json warnings entry {idx} is not an object")
58+
if not isinstance(w.get("code"), str):
59+
raise ValueError(
60+
f"expected-errors.json warnings entry {idx} "
61+
"missing string 'code' field")
62+
src = w.get("source")
63+
warnings.append({
64+
"code": w["code"],
65+
"source": src if isinstance(src, dict) else None,
66+
})
67+
return (errors, warnings, False)
4768
raise ValueError(
4869
"expected-errors.json must be a legacy array or an FR5a envelope object")
4970

5071

5172
def _run_checks(fix: Fixture) -> tuple[bool, str]:
52-
codes, envelopes, warnings, canonical = load_fixture_with_envelopes(fix.input_dir)
73+
codes, envelopes, warnings, warning_envelopes, canonical = load_fixture_with_envelopes(
74+
fix.input_dir
75+
)
5376
failures: list[str] = []
5477

5578
if fix.has_expected_errors:
5679
raw = json.loads((fix.dir / "expected-errors.json").read_text())
57-
expected_errors, expected_warnings_count, legacy = _parse_expected_errors(raw)
80+
expected_errors, expected_warnings, legacy = _parse_expected_errors(raw)
5881

5982
# Code-set check (order-independent) — legacy semantics, always run.
6083
want = sorted(e["code"] for e in expected_errors)
@@ -102,11 +125,39 @@ def _run_checks(fix: Fixture) -> tuple[bool, str]:
102125
failures.append(
103126
f"envelope[{i}].source.target: expected '{want_tgt}', "
104127
f"got '{g.target}'")
105-
# warnings count check (FR5a fixtures all have []).
106-
if expected_warnings_count != len(warnings):
128+
# FR5c-finalize — warnings: assert count first, then per-element
129+
# envelope shape when the fixture declares ``source`` on a warning
130+
# entry. Mirrors the error-side block above (and TS runner.ts).
131+
# When the fixture omits ``source`` on a warning entry, only the
132+
# count + per-element code are asserted for that entry.
133+
if len(expected_warnings) != len(warnings):
107134
failures.append(
108-
f"warnings count: expected {expected_warnings_count}, "
109-
f"got {len(warnings)}")
135+
f"warnings count: expected {len(expected_warnings)}, "
136+
f"got {len(warnings)}"
137+
)
138+
elif len(expected_warnings) == len(warning_envelopes):
139+
for i, (w, g) in enumerate(zip(expected_warnings, warning_envelopes)):
140+
if w["code"] != g.code:
141+
failures.append(
142+
f"warning[{i}].code: expected '{w['code']}', got '{g.code}'")
143+
continue
144+
src = w["source"]
145+
if src is None:
146+
continue
147+
if src["format"] != g.format:
148+
failures.append(
149+
f"warning[{i}].source.format: expected '{src['format']}', "
150+
f"got '{g.format}'")
151+
want_files = tuple(src["files"])
152+
if want_files != g.files:
153+
failures.append(
154+
f"warning[{i}].source.files: expected {list(want_files)}, "
155+
f"got {list(g.files)}")
156+
want_path = src.get("jsonPath")
157+
if want_path is not None and want_path != g.json_path:
158+
failures.append(
159+
f"warning[{i}].source.jsonPath: expected '{want_path}', "
160+
f"got '{g.json_path}'")
110161

111162
# Tree-check: run whenever expected.json exists, matching the TS reference
112163
# runner. A load that produced errors still gets its tree compared —
@@ -124,7 +175,11 @@ def _run_checks(fix: Fixture) -> tuple[bool, str]:
124175
want_w = sorted(json.loads((fix.dir / "expected-warnings.json").read_text()))
125176
if want_w != sorted(warnings):
126177
failures.append(f"warnings: want {want_w} got {sorted(warnings)}")
127-
elif fix.has_expected and warnings:
178+
elif fix.has_expected and not fix.has_expected_errors and warnings:
179+
# FR5c-finalize — skip the happy-path "unexpected warnings" guard when
180+
# the fixture uses expected-errors.json (envelope shape); the block
181+
# above already asserted warning count + per-element envelope. Mirrors
182+
# the TS runner's `!fix.hasExpectedErrors` guard.
128183
failures.append(f"unexpected warnings: {warnings}")
129184

130185
if fix.has_script:

0 commit comments

Comments
 (0)