Skip to content

Commit d5655a4

Browse files
committed
Merge worktree-warn-envelope-csharp-2026-05-27 — WARN envelope-shape C# port
2 parents e13b5f2 + 7231467 commit d5655a4

3 files changed

Lines changed: 119 additions & 27 deletions

File tree

server/csharp/MetaObjects.Conformance.Tests/ConformanceAdapter.cs

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,19 @@ public sealed record ErrorEnvelopeRecord(
3636
/// <param name="ErrorCodes">Error code strings collected during loading.</param>
3737
/// <param name="Warnings">Warning strings collected during loading.</param>
3838
/// <param name="Errors">Full envelope records (FR5a / ADR-0009). Populated alongside <see cref="ErrorCodes"/>.</param>
39+
/// <param name="WarningEnvelopes">
40+
/// FR5c-finalize — full warning envelopes (same shape as <see cref="Errors"/>).
41+
/// When present AND the fixture's expected-errors.json declares envelope-shape
42+
/// warnings, the runner asserts per-warning envelope alignment (mirrors the
43+
/// error-side assertion). When absent, the runner falls back to comparing
44+
/// <see cref="Warnings"/> as a flat string list against expected-warnings.json.
45+
/// </param>
3946
public sealed record LoadOutcome(
4047
MetaRoot Tree,
4148
IReadOnlyList<string> ErrorCodes,
4249
IReadOnlyList<string> Warnings,
43-
IReadOnlyList<ErrorEnvelopeRecord> Errors);
50+
IReadOnlyList<ErrorEnvelopeRecord> Errors,
51+
IReadOnlyList<ErrorEnvelopeRecord> WarningEnvelopes);
4452

4553
/// <summary>
4654
/// Adapter that bridges the conformance test infrastructure to the C# MetaObjects library.
@@ -76,19 +84,34 @@ public static LoadOutcome LoadFixture(string inputDir, IReadOnlyList<string> pro
7684
// be relative to inputDir (the harness's portable file token).
7785
var envelopes = result.Errors.Select(e => BuildEnvelope(e, inputDir)).ToList();
7886

87+
// FR5c-finalize — surface warning envelopes (same envelope shape as
88+
// errors). The loader's EnvelopeWarnings list carries envelope-shaped
89+
// warnings (e.g. WARN_DUPLICATE_DECLARATION) with full LoaderWarning
90+
// provenance. Mirror the error-side normalization so the cross-port
91+
// runner can assert per-warning envelope alignment.
92+
var warningEnvelopes = (result.EnvelopeWarnings ?? Array.Empty<LoaderWarning>())
93+
.Select(w => BuildWarningEnvelope(w, inputDir))
94+
.ToList();
95+
7996
return new LoadOutcome(
8097
result.Root,
8198
result.Errors.Select(e => e.Code.ToString()).ToList(),
8299
result.Warnings.ToList(),
83-
envelopes);
100+
envelopes,
101+
warningEnvelopes);
84102
}
85103

86-
private static ErrorEnvelopeRecord BuildEnvelope(MetaError err, string inputDir)
104+
private static ErrorEnvelopeRecord BuildEnvelope(MetaError err, string inputDir) =>
105+
BuildEnvelopeFromSource(err.Code.ToString(), err.Envelope, inputDir);
106+
107+
private static ErrorEnvelopeRecord BuildWarningEnvelope(LoaderWarning w, string inputDir) =>
108+
BuildEnvelopeFromSource(w.Code, w.Source, inputDir);
109+
110+
private static ErrorEnvelopeRecord BuildEnvelopeFromSource(string code, ErrorSource? envelope, string inputDir)
87111
{
88-
var code = err.Code.ToString();
89112
IReadOnlyList<string> Rel(IEnumerable<string> files) =>
90113
files.Select(f => RelativizeFile(f, inputDir)).ToList();
91-
return err.Envelope switch
114+
return envelope switch
92115
{
93116
JsonSource js => new ErrorEnvelopeRecord(code, "json", Rel(js.Files), js.JsonPath),
94117
YamlSource ys => new ErrorEnvelopeRecord(code, "yaml", Rel(ys.Files), ys.JsonPath),

server/csharp/MetaObjects.Conformance.Tests/ConformanceTests.cs

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -151,10 +151,55 @@ private static bool RunChecks(Fixture fix, LoadOutcome outcome, out string detai
151151
$"envelope[{i}].source.target: expected '{w.Source.Target}', got '{g.Target}'");
152152
}
153153
}
154-
if (envelope.WarningsCount != outcome.Warnings.Count)
154+
}
155+
// FR5c-finalize — warnings: when the fixture's envelope declares
156+
// warning entries with `source`, assert per-warning envelope shape
157+
// (same algorithm as errors above). When the fixture declares only
158+
// counts (warnings: [{code}] without source) or empty `[]`, the
159+
// length check still runs; the per-element source assertion is
160+
// skipped element-by-element when expected.source is null.
161+
if (!envelope.Legacy)
162+
{
163+
var expectedW = envelope.Warnings;
164+
var gotW = outcome.WarningEnvelopes;
165+
if (expectedW.Count != outcome.Warnings.Count)
166+
{
155167
failures.Add(
156-
$"warnings count: expected {envelope.WarningsCount}, " +
168+
$"warnings count: expected {expectedW.Count}, " +
157169
$"got {outcome.Warnings.Count}");
170+
}
171+
else if (expectedW.Count == gotW.Count)
172+
{
173+
for (int i = 0; i < expectedW.Count; i++)
174+
{
175+
var w = expectedW[i];
176+
var g = gotW[i];
177+
if (w.Code != g.Code)
178+
{
179+
failures.Add(
180+
$"warning[{i}].code: expected '{w.Code}', got '{g.Code}'");
181+
continue;
182+
}
183+
if (w.Source is null) continue;
184+
if (w.Source.Format != g.Format)
185+
failures.Add(
186+
$"warning[{i}].source.format: expected '{w.Source.Format}', got '{g.Format}'");
187+
if (!w.Source.Files.SequenceEqual(g.Files, StringComparer.Ordinal))
188+
failures.Add(
189+
$"warning[{i}].source.files: expected [{string.Join(",", w.Source.Files)}], " +
190+
$"got [{string.Join(",", g.Files)}]");
191+
if (w.Source.JsonPath != null && w.Source.JsonPath != g.JsonPath)
192+
failures.Add(
193+
$"warning[{i}].source.jsonPath: expected '{w.Source.JsonPath}', got '{g.JsonPath}'");
194+
// FR5d — assert referrer / target for format=resolved envelopes.
195+
if (w.Source.Referrer != null && w.Source.Referrer != g.Referrer)
196+
failures.Add(
197+
$"warning[{i}].source.referrer: expected '{w.Source.Referrer}', got '{g.Referrer}'");
198+
if (w.Source.Target != null && w.Source.Target != g.Target)
199+
failures.Add(
200+
$"warning[{i}].source.target: expected '{w.Source.Target}', got '{g.Target}'");
201+
}
202+
}
158203
}
159204
}
160205
}

server/csharp/MetaObjects.Conformance.Tests/OperationScript.cs

Lines changed: 44 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -59,10 +59,16 @@ public sealed record ExpectedError(string Code, ExpectedErrorSource? Source);
5959
/// Result of parsing expected-errors.json.
6060
/// <see cref="Legacy"/> is true when the file used the pre-FR5a
6161
/// <c>[{code}]</c> array shape (no envelope assertions possible).
62+
/// <para>
63+
/// FR5c-finalize — <see cref="Warnings"/> holds per-entry expected warnings
64+
/// (same shape as <see cref="Errors"/>). When each entry has a non-null
65+
/// <c>Source</c>, the runner asserts per-warning envelope alignment;
66+
/// otherwise only the count is asserted.
67+
/// </para>
6268
/// </summary>
6369
public sealed record ExpectedErrorsEnvelope(
6470
IReadOnlyList<ExpectedError> Errors,
65-
int WarningsCount,
71+
IReadOnlyList<ExpectedError> Warnings,
6672
bool Legacy);
6773

6874
/// <summary>
@@ -92,7 +98,7 @@ public static ExpectedErrorsEnvelope ParseExpectedErrorsEnvelope(JsonElement roo
9298
legacyErrors.Add(new ExpectedError(codeProp.GetString()!, null));
9399
i++;
94100
}
95-
return new ExpectedErrorsEnvelope(legacyErrors, 0, Legacy: true);
101+
return new ExpectedErrorsEnvelope(legacyErrors, Array.Empty<ExpectedError>(), Legacy: true);
96102
}
97103

98104
// FR5a envelope shape.
@@ -104,34 +110,59 @@ public static ExpectedErrorsEnvelope ParseExpectedErrorsEnvelope(JsonElement roo
104110
throw new InvalidOperationException(
105111
"expected-errors.json: 'errors' must be an array");
106112

107-
var errors = new List<ExpectedError>();
113+
var errors = ParseEnvelopeEntryArray(errorsEl, "errors");
114+
115+
// FR5c-finalize — warnings entries are parsed with the same envelope
116+
// shape as errors. When a fixture omits `source` on a warning entry,
117+
// the runner asserts only the count for that entry.
118+
IReadOnlyList<ExpectedError> warnings = Array.Empty<ExpectedError>();
119+
if (root.TryGetProperty("warnings", out var warnEl))
120+
{
121+
if (warnEl.ValueKind != JsonValueKind.Array)
122+
throw new InvalidOperationException(
123+
"expected-errors.json: 'warnings' must be an array");
124+
warnings = ParseEnvelopeEntryArray(warnEl, "warnings");
125+
}
126+
return new ExpectedErrorsEnvelope(errors, warnings, Legacy: false);
127+
}
128+
129+
/// <summary>
130+
/// Parse an array of envelope entries (<c>[{code, source?}, ...]</c>).
131+
/// Used by both <c>errors[]</c> and <c>warnings[]</c> in expected-errors.json.
132+
/// <paramref name="fieldName"/> is the parent property name, used purely for
133+
/// error messages.
134+
/// </summary>
135+
private static IReadOnlyList<ExpectedError> ParseEnvelopeEntryArray(
136+
JsonElement arrEl, string fieldName)
137+
{
138+
var entries = new List<ExpectedError>();
108139
int idx = 0;
109-
foreach (var item in errorsEl.EnumerateArray())
140+
foreach (var item in arrEl.EnumerateArray())
110141
{
111142
if (item.ValueKind != JsonValueKind.Object)
112143
throw new InvalidOperationException(
113-
$"expected-errors.json entry {idx} is not an object");
144+
$"expected-errors.json {fieldName}[{idx}] is not an object");
114145
if (!item.TryGetProperty("code", out var codeProp) ||
115146
codeProp.ValueKind != JsonValueKind.String)
116147
throw new InvalidOperationException(
117-
$"expected-errors.json entry {idx} missing string 'code' field");
148+
$"expected-errors.json {fieldName}[{idx}] missing string 'code' field");
118149
ExpectedErrorSource? source = null;
119150
if (item.TryGetProperty("source", out var srcEl) &&
120151
srcEl.ValueKind == JsonValueKind.Object)
121152
{
122153
if (!srcEl.TryGetProperty("format", out var fmtEl) ||
123154
fmtEl.ValueKind != JsonValueKind.String)
124155
throw new InvalidOperationException(
125-
$"expected-errors.json entry {idx}: 'source.format' must be a string");
156+
$"expected-errors.json {fieldName}[{idx}]: 'source.format' must be a string");
126157
if (!srcEl.TryGetProperty("files", out var filesEl) ||
127158
filesEl.ValueKind != JsonValueKind.Array)
128159
throw new InvalidOperationException(
129-
$"expected-errors.json entry {idx}: 'source.files' must be a string[]");
160+
$"expected-errors.json {fieldName}[{idx}]: 'source.files' must be a string[]");
130161
var files = filesEl.EnumerateArray()
131162
.Select(f => f.ValueKind == JsonValueKind.String
132163
? f.GetString()!
133164
: throw new InvalidOperationException(
134-
$"expected-errors.json entry {idx}: 'source.files' contains non-string"))
165+
$"expected-errors.json {fieldName}[{idx}]: 'source.files' contains non-string"))
135166
.ToList();
136167
string? jsonPath = null;
137168
if (srcEl.TryGetProperty("jsonPath", out var jpEl) &&
@@ -157,24 +188,17 @@ public static ExpectedErrorsEnvelope ParseExpectedErrorsEnvelope(JsonElement roo
157188
{
158189
if (referrer is null)
159190
throw new InvalidOperationException(
160-
$"expected-errors.json entry {idx}: 'source.referrer' is required when format='resolved'");
191+
$"expected-errors.json {fieldName}[{idx}]: 'source.referrer' is required when format='resolved'");
161192
if (target is null)
162193
throw new InvalidOperationException(
163-
$"expected-errors.json entry {idx}: 'source.target' is required when format='resolved'");
194+
$"expected-errors.json {fieldName}[{idx}]: 'source.target' is required when format='resolved'");
164195
}
165196
source = new ExpectedErrorSource(fmt, files, jsonPath, referrer, target);
166197
}
167-
errors.Add(new ExpectedError(codeProp.GetString()!, source));
198+
entries.Add(new ExpectedError(codeProp.GetString()!, source));
168199
idx++;
169200
}
170-
171-
int warningsCount = 0;
172-
if (root.TryGetProperty("warnings", out var warnEl) &&
173-
warnEl.ValueKind == JsonValueKind.Array)
174-
{
175-
warningsCount = warnEl.GetArrayLength();
176-
}
177-
return new ExpectedErrorsEnvelope(errors, warningsCount, Legacy: false);
201+
return entries;
178202
}
179203

180204
/// <summary>

0 commit comments

Comments
 (0)