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
5172def _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