Skip to content

Commit 0947122

Browse files
committed
Merge worktree-warn-envelope-java-2026-05-27 — WARN envelope-shape Java port
2 parents d5655a4 + d10a00a commit 0947122

2 files changed

Lines changed: 154 additions & 62 deletions

File tree

server/java/metadata/src/test/java/com/metaobjects/conformance/ConformanceTest.java

Lines changed: 104 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -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
}

server/java/metadata/src/test/java/com/metaobjects/conformance/FixtureLint.java

Lines changed: 50 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -127,14 +127,23 @@ public ExpectedError(String code, ExpectedErrorSource source) {
127127
}
128128
}
129129

130-
/** Parsed expected-errors.json — envelope-shape-aware. */
130+
/**
131+
* Parsed expected-errors.json — envelope-shape-aware.
132+
*
133+
* <p>FR5c-finalize: {@code warnings} carries envelope-shape entries
134+
* (mirroring {@code errors}). When the fixture's {@code warnings} array
135+
* is empty or entries omit {@code source}, the per-element source check
136+
* is skipped element-by-element; the count check still runs.</p>
137+
*/
131138
public static final class ExpectedErrorsEnvelope {
132139
public final List<ExpectedError> errors;
133-
public final int warningsCount;
140+
public final List<ExpectedError> warnings;
134141
public final boolean legacy;
135-
public ExpectedErrorsEnvelope(List<ExpectedError> errors, int warningsCount, boolean legacy) {
142+
public ExpectedErrorsEnvelope(List<ExpectedError> errors,
143+
List<ExpectedError> warnings,
144+
boolean legacy) {
136145
this.errors = errors;
137-
this.warningsCount = warningsCount;
146+
this.warnings = warnings;
138147
this.legacy = legacy;
139148
}
140149
}
@@ -168,7 +177,7 @@ public static ExpectedErrorsEnvelope parseExpectedErrorsEnvelope(JsonElement el)
168177
"expected-errors entry must be a string or an object with a 'code' field");
169178
}
170179
}
171-
return new ExpectedErrorsEnvelope(errors, 0, true);
180+
return new ExpectedErrorsEnvelope(errors, new ArrayList<>(), true);
172181
}
173182
// FR5a envelope.
174183
if (!el.isJsonObject()) {
@@ -180,18 +189,41 @@ public static ExpectedErrorsEnvelope parseExpectedErrorsEnvelope(JsonElement el)
180189
if (errorsEl == null || !errorsEl.isJsonArray()) {
181190
throw new IllegalArgumentException("expected-errors.json: 'errors' must be an array");
182191
}
183-
List<ExpectedError> errors = new ArrayList<>();
192+
List<ExpectedError> errors = parseEnvelopeEntries(errorsEl.getAsJsonArray(), "errors");
193+
194+
// FR5c-finalize — warnings carry envelope shape too. Empty array is
195+
// valid (no warnings expected); entries without `source` skip the
196+
// per-element source assertion but still participate in the count.
197+
List<ExpectedError> warnings = new ArrayList<>();
198+
JsonElement warnEl = root.get("warnings");
199+
if (warnEl != null) {
200+
if (!warnEl.isJsonArray()) {
201+
throw new IllegalArgumentException("expected-errors.json: 'warnings' must be an array");
202+
}
203+
warnings = parseEnvelopeEntries(warnEl.getAsJsonArray(), "warnings");
204+
}
205+
return new ExpectedErrorsEnvelope(errors, warnings, false);
206+
}
207+
208+
/**
209+
* Parse a {@code [{code, source?}, ...]} array — shared between the
210+
* {@code errors} and {@code warnings} channels in the envelope. The
211+
* {@code label} parameter ({@code "errors"} / {@code "warnings"}) is woven
212+
* into thrown messages so corpus errors say which channel was malformed.
213+
*/
214+
private static List<ExpectedError> parseEnvelopeEntries(JsonArray arr, String label) {
215+
List<ExpectedError> out = new ArrayList<>(arr.size());
184216
int idx = 0;
185-
for (JsonElement item : errorsEl.getAsJsonArray()) {
217+
for (JsonElement item : arr) {
186218
if (!item.isJsonObject()) {
187219
throw new IllegalArgumentException(
188-
"expected-errors.json entry " + idx + " is not an object");
220+
"expected-errors.json " + label + " entry " + idx + " is not an object");
189221
}
190222
JsonObject obj = item.getAsJsonObject();
191223
JsonElement code = obj.get("code");
192224
if (code == null || !code.isJsonPrimitive() || !code.getAsJsonPrimitive().isString()) {
193225
throw new IllegalArgumentException(
194-
"expected-errors.json entry " + idx + " missing string 'code' field");
226+
"expected-errors.json " + label + " entry " + idx + " missing string 'code' field");
195227
}
196228
ExpectedErrorSource source = null;
197229
JsonElement srcEl = obj.get("source");
@@ -200,18 +232,18 @@ public static ExpectedErrorsEnvelope parseExpectedErrorsEnvelope(JsonElement el)
200232
JsonElement fmtEl = srcObj.get("format");
201233
if (fmtEl == null || !fmtEl.isJsonPrimitive() || !fmtEl.getAsJsonPrimitive().isString()) {
202234
throw new IllegalArgumentException(
203-
"expected-errors.json entry " + idx + " source.format must be a string");
235+
"expected-errors.json " + label + " entry " + idx + " source.format must be a string");
204236
}
205237
JsonElement filesEl = srcObj.get("files");
206238
if (filesEl == null || !filesEl.isJsonArray()) {
207239
throw new IllegalArgumentException(
208-
"expected-errors.json entry " + idx + " source.files must be a string[]");
240+
"expected-errors.json " + label + " entry " + idx + " source.files must be a string[]");
209241
}
210242
List<String> files = new ArrayList<>();
211243
for (JsonElement fe : filesEl.getAsJsonArray()) {
212244
if (!fe.isJsonPrimitive() || !fe.getAsJsonPrimitive().isString()) {
213245
throw new IllegalArgumentException(
214-
"expected-errors.json entry " + idx + " source.files contains non-string");
246+
"expected-errors.json " + label + " entry " + idx + " source.files contains non-string");
215247
}
216248
files.add(fe.getAsString());
217249
}
@@ -235,24 +267,21 @@ public static ExpectedErrorsEnvelope parseExpectedErrorsEnvelope(JsonElement el)
235267
if ("resolved".equals(fmt)) {
236268
if (referrer == null) {
237269
throw new IllegalArgumentException(
238-
"expected-errors.json entry " + idx + " source.referrer is required when format='resolved'");
270+
"expected-errors.json " + label + " entry " + idx
271+
+ " source.referrer is required when format='resolved'");
239272
}
240273
if (target == null) {
241274
throw new IllegalArgumentException(
242-
"expected-errors.json entry " + idx + " source.target is required when format='resolved'");
275+
"expected-errors.json " + label + " entry " + idx
276+
+ " source.target is required when format='resolved'");
243277
}
244278
}
245279
source = new ExpectedErrorSource(fmt, files, jsonPath, referrer, target);
246280
}
247-
errors.add(new ExpectedError(code.getAsString(), source));
281+
out.add(new ExpectedError(code.getAsString(), source));
248282
idx++;
249283
}
250-
int warningsCount = 0;
251-
JsonElement warnEl = root.get("warnings");
252-
if (warnEl != null && warnEl.isJsonArray()) {
253-
warningsCount = warnEl.getAsJsonArray().size();
254-
}
255-
return new ExpectedErrorsEnvelope(errors, warningsCount, false);
284+
return out;
256285
}
257286

258287
/** Backward-compat helper — returns just the codes from either shape. */

0 commit comments

Comments
 (0)