@@ -317,48 +317,34 @@ private static void runConformanceChecks(FixtureDiscovery.Fixture fix,
317317 failures .add ("expected errors " + wantSet + ", got " + gotSet );
318318 }
319319 // FR5a — per-error envelope assertion.
320+ // FR5c-finalize — same algorithm now also runs over warnings
321+ // when the fixture declares envelope-shape warnings.
320322 if (!envelope .legacy ) {
321- if (envelope .errors .size () != envelopesSeen .size ()) {
322- failures .add ("envelope length mismatch: expected " + envelope .errors .size ()
323- + ", got " + envelopesSeen .size ());
323+ assertEnvelopeAlignment ("envelope" , envelope .errors , envelopesSeen , failures );
324+
325+ // FR5c-finalize — warning length check uses the LEGACY warning
326+ // channel (mirrors the TS runner: any warning, envelope-shaped
327+ // or not, counts toward the total). The per-element envelope
328+ // shape assertion runs against the envelope-shaped channel,
329+ // and only when counts agree (otherwise the message already
330+ // names the count delta and per-element output would be noise).
331+ int expectedCount = envelope .warnings .size ();
332+ int gotCount = loader .getWarnings ().size ();
333+ if (expectedCount != gotCount ) {
334+ failures .add ("warning length mismatch: expected " + expectedCount
335+ + ", got " + gotCount );
324336 } else {
325- for (int i = 0 ; i < envelope .errors .size (); i ++) {
326- FixtureLint .ExpectedError w = envelope .errors .get (i );
327- EnvelopeRecord g = envelopesSeen .get (i );
328- if (!w .code .equals (g .code )) {
329- failures .add ("envelope[" + i + "].code: expected '" + w .code
330- + "', got '" + g .code + "'" );
331- continue ;
332- }
333- if (w .source == null ) continue ;
334- if (!w .source .format .equals (g .format )) {
335- failures .add ("envelope[" + i + "].source.format: expected '"
336- + w .source .format + "', got '" + g .format + "'" );
337- }
338- if (!w .source .files .equals (g .files )) {
339- failures .add ("envelope[" + i + "].source.files: expected "
340- + w .source .files + ", got " + g .files );
341- }
342- if (w .source .jsonPath != null && !w .source .jsonPath .equals (g .jsonPath )) {
343- failures .add ("envelope[" + i + "].source.jsonPath: expected '"
344- + w .source .jsonPath + "', got '" + g .jsonPath + "'" );
345- }
346- // FR5d — assert referrer + target for format=resolved envelopes.
347- if (w .source .referrer != null && !w .source .referrer .equals (g .referrer )) {
348- failures .add ("envelope[" + i + "].source.referrer: expected '"
349- + w .source .referrer + "', got '" + g .referrer + "'" );
350- }
351- if (w .source .target != null && !w .source .target .equals (g .target )) {
352- failures .add ("envelope[" + i + "].source.target: expected '"
353- + w .source .target + "', got '" + g .target + "'" );
354- }
337+ List <EnvelopeRecord > warningEnvelopesSeen = new ArrayList <>();
338+ for (com .metaobjects .source .LoaderWarning lw : loader .getEnvelopeWarnings ()) {
339+ warningEnvelopesSeen .add (buildEnvelope (lw , fix .inputDir ));
340+ }
341+ // Only run per-element assertion when the envelope channel
342+ // matches the expected count too (legacy-only warnings
343+ // would otherwise produce a confusing index mismatch).
344+ if (expectedCount == warningEnvelopesSeen .size ()) {
345+ assertEnvelopeAlignment ("warning" , envelope .warnings ,
346+ warningEnvelopesSeen , failures );
355347 }
356- }
357- // Loader warnings — must match envelope.warningsCount.
358- int gotWarnings = loader .getWarnings ().size ();
359- if (envelope .warningsCount != gotWarnings ) {
360- failures .add ("warnings count: expected " + envelope .warningsCount
361- + ", got " + gotWarnings );
362348 }
363349 }
364350 return ;
@@ -474,6 +460,67 @@ private static List<String> parseExpectedWarnings(JsonElement parsed) {
474460 return out ;
475461 }
476462
463+ /**
464+ * Assert per-element envelope alignment between the expected list (from
465+ * {@code expected-errors.json}, either the {@code errors} or
466+ * {@code warnings} channel) and what the loader surfaced. Mirrors the TS
467+ * runner's identical block for errors + warnings.
468+ *
469+ * <p>Algorithm:</p>
470+ * <ul>
471+ * <li>Length mismatch is a single failure (no per-element checks run).</li>
472+ * <li>Per element: {@code code} must match. When the expected entry has
473+ * no {@code source}, the per-element source check is skipped (count
474+ * alone is the contract). Otherwise {@code format} + {@code files}
475+ * always assert; {@code jsonPath}, {@code referrer}, {@code target}
476+ * assert only when the expected entry declares them.</li>
477+ * </ul>
478+ *
479+ * @param label "envelope" for errors, "warning" for warnings — woven
480+ * into failure messages so the channel is unambiguous.
481+ */
482+ private static void assertEnvelopeAlignment (String label ,
483+ List <FixtureLint .ExpectedError > expected ,
484+ List <EnvelopeRecord > got ,
485+ List <String > failures ) {
486+ if (expected .size () != got .size ()) {
487+ failures .add (label + " length mismatch: expected " + expected .size ()
488+ + ", got " + got .size ());
489+ return ;
490+ }
491+ for (int i = 0 ; i < expected .size (); i ++) {
492+ FixtureLint .ExpectedError w = expected .get (i );
493+ EnvelopeRecord g = got .get (i );
494+ if (!w .code .equals (g .code )) {
495+ failures .add (label + "[" + i + "].code: expected '" + w .code
496+ + "', got '" + g .code + "'" );
497+ continue ;
498+ }
499+ if (w .source == null ) continue ;
500+ if (!w .source .format .equals (g .format )) {
501+ failures .add (label + "[" + i + "].source.format: expected '"
502+ + w .source .format + "', got '" + g .format + "'" );
503+ }
504+ if (!w .source .files .equals (g .files )) {
505+ failures .add (label + "[" + i + "].source.files: expected "
506+ + w .source .files + ", got " + g .files );
507+ }
508+ if (w .source .jsonPath != null && !w .source .jsonPath .equals (g .jsonPath )) {
509+ failures .add (label + "[" + i + "].source.jsonPath: expected '"
510+ + w .source .jsonPath + "', got '" + g .jsonPath + "'" );
511+ }
512+ // FR5d — assert referrer + target for format=resolved envelopes.
513+ if (w .source .referrer != null && !w .source .referrer .equals (g .referrer )) {
514+ failures .add (label + "[" + i + "].source.referrer: expected '"
515+ + w .source .referrer + "', got '" + g .referrer + "'" );
516+ }
517+ if (w .source .target != null && !w .source .target .equals (g .target )) {
518+ failures .add (label + "[" + i + "].source.target: expected '"
519+ + w .source .target + "', got '" + g .target + "'" );
520+ }
521+ }
522+ }
523+
477524 // -----------------------------------------------------------------------
478525 // helpers
479526 // -----------------------------------------------------------------------
@@ -598,8 +645,24 @@ private static final class EnvelopeRecord {
598645 * cross-port harness has a portable file token.
599646 */
600647 private static EnvelopeRecord buildEnvelope (MetaDataException ex , Path inputDir ) {
601- String code = extractErrorCode (ex );
602- ErrorSource env = ex .getEnvelope ().orElse (null );
648+ return buildEnvelope (extractErrorCode (ex ), ex .getEnvelope ().orElse (null ), inputDir );
649+ }
650+
651+ /**
652+ * FR5c-finalize — build the cross-port envelope from a
653+ * {@link com.metaobjects.source.LoaderWarning}. Shares the variant-by-
654+ * variant dispatch with the exception-side {@link #buildEnvelope(MetaDataException, Path)}.
655+ */
656+ private static EnvelopeRecord buildEnvelope (com .metaobjects .source .LoaderWarning warning ,
657+ Path inputDir ) {
658+ return buildEnvelope (warning .code (), warning .source (), inputDir );
659+ }
660+
661+ /**
662+ * Shared envelope builder — same algorithm whether the source comes from
663+ * a thrown exception, a recorded error, or a {@code LoaderWarning}.
664+ */
665+ private static EnvelopeRecord buildEnvelope (String code , ErrorSource env , Path inputDir ) {
603666 if (env instanceof JsonSource js ) {
604667 return new EnvelopeRecord (code , "json" , relativizeFiles (js .files (), inputDir ), js .jsonPath ());
605668 }
0 commit comments