Skip to content

Commit f5cf019

Browse files
dmealingclaude
andcommitted
fix(csharp): reconcile FR5a envelope drifts — 3 fixtures off the ledger
Three drifts between the C# port and the TS reference port for FR5a / ADR-0009 envelope shape, now closed: 1. error-parse-malformed-json (server/csharp/MetaObjects/Parser.cs:97-110): The malformed-JSON catch in ParseJson threw without an envelope, so the conformance adapter's fallback synthesized format=json + empty files[]. Now builds a $-rooted JsonSource(files=[sourceName ?? "<unknown>"], jsonPath="$") and threads it through ParseException.Envelope. Mirrors TS parser-json.ts:25-29. 2. error-reserved-word-as-attr (server/csharp/MetaObjects/Parser.cs:711-806): ApplyInlineAttrsAndUnknownKeys pushed each attr key onto the JSONPath builder BEFORE the ERR_RESERVED_ATTR / ERR_UNKNOWN_ATTR / ERR_BAD_ATTR_VALUE checks, so emitted envelopes threaded one level too deep (down to the offending @attr). TS parser-core.ts:626-690 never pushes the attr key in this loop — the path stays at the parent node. Removed the PushKey/Pop so all errors raised from the inline-attr loop surface at the parent's canonical path (the node body that contains the bad key). This is the FR5a contract. 3. error-yaml-reserved-as-attr (server/csharp/MetaObjects/YamlDesugar.cs:284-310): The YAML desugar pre-empted ERR_RESERVED_ATTR by dropping @-prefixed-reserved keys and emitting its own desugar diagnostic — which carried no envelope, so the YAML harness synthesized format=yaml + jsonPath="$". TS yaml-desugar.ts:197-203 and Java YamlDesugar.java:340-349 PASS THROUGH @-prefixed reserved keys to the canonical parser, which owns the ERR_RESERVED_ATTR diagnostic and emits it with the proper JsonSource envelope (format=json + parent node's JSONPath). Aligned C# to that contract. Updated the YamlDesugarTests unit test to match the new behavior. Both ledger files now empty (full FR5a envelope parity with TS for the JSON and YAML corpora). Verified: 302 conformance tests pass; the wider C# test suite (Render 88, Cli 7, Codegen 165) is unaffected. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a971644 commit f5cf019

5 files changed

Lines changed: 115 additions & 109 deletions

File tree

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

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,13 +80,23 @@ public void ReservedKey_StaysBare()
8080
}
8181

8282
[Fact]
83-
public void AtPrefixedReservedKey_IsErrReservedAttr()
83+
public void AtPrefixedReservedKey_PassesThroughToCanonicalParser()
8484
{
85-
// Author wrote `@isArray: true` — reserved word with sigil, must be flagged.
85+
// Author wrote `@isArray: true` — reserved word with sigil. The YAML
86+
// desugar passes it THROUGH to canonical JSON (it does not pre-empt the
87+
// diagnostic). The canonical parser is the cross-language owner of the
88+
// ERR_RESERVED_ATTR check and emits it with the proper FR5a envelope
89+
// (format=json + parent JSONPath). Mirrors TS yaml-desugar.ts:197-203
90+
// and Java YamlDesugar.java:340-349.
8691
var yaml = "object.entity:\n name: Product\n \"@isArray\": true\n";
8792
var result = Desugar(yaml);
88-
Assert.Contains(result.Errors,
93+
// Desugar itself does NOT emit ERR_RESERVED_ATTR.
94+
Assert.DoesNotContain(result.Errors,
8995
e => e.Code == ErrorCode.ERR_RESERVED_ATTR);
96+
// The @-prefixed reserved key survives in the canonical JSON, where the
97+
// downstream canonical parser will flag it.
98+
var json = result.Canonical.ToJsonString();
99+
Assert.Contains("\"@isArray\"", json);
90100
}
91101

92102
[Fact]
Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
11
{
22
"language": "csharp",
3-
"_comment": "FR5a envelope-shape gates: TS is the reference port for source.format / source.files / source.jsonPath. C# drifts on two fixtures (parser source-threading differences): error-parse-malformed-json (the C# malformed-JSON path returns an empty files[]; TS captures the filename), and error-reserved-word-as-attr (C# threads source one level deeper, to the offending @attr; TS stops at the parent field.object). Track until the C# parser is reconciled to TS's source-emission sites.",
4-
"fixtures": [
5-
"error-parse-malformed-json",
6-
"error-reserved-word-as-attr"
7-
]
3+
"_comment": "FR5a envelope-shape gates: TS is the reference port for source.format / source.files / source.jsonPath. C# is now reconciled with TS on the JSON-side FR5a envelopes (parser malformed-JSON catch builds a $-rooted JsonSource with the source-file name; inline-attr loop reports ERR_RESERVED_ATTR / ERR_UNKNOWN_ATTR / ERR_BAD_ATTR_VALUE at the parent's path — not key-deeper). Keep this file as the documented ledger surface (empty = full FR5a envelope parity with TS for the JSON corpus).",
4+
"fixtures": []
85
}
Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
{
22
"language": "csharp",
3-
"_comment": "FR5a envelope-shape gate: error-yaml-reserved-as-attr is a known cross-port drift — TS labels the cascading post-desugar error as format=\"json\" (the user only sees YAML; the JSON format leaks through TS's desugar pipeline), whereas C# threads format=\"yaml\" all the way through. C# is arguably MORE correct here per ADR-0009 §Decision, but TS is the FR5a reference port and the fixture matches what TS emits. Track until TS narrows its YAML→JSON envelope handoff (or the cross-port contract relaxes to allow either label).",
4-
"fixtures": [
5-
"error-yaml-reserved-as-attr"
6-
]
3+
"_comment": "FR5a envelope-shape gates: C# YAML pipeline is now reconciled with TS — the YAML desugar no longer pre-empts ERR_RESERVED_ATTR (it passed-through `@<reserved>` keys, mirroring TS yaml-desugar.ts and Java YamlDesugar.java), so the canonical parser owns the diagnostic and emits a JsonSource envelope (format=\"json\") with the parent node's JSONPath. Keep this file as the documented ledger surface (empty = full FR5a envelope parity with TS for the YAML corpus).",
4+
"fixtures": []
75
}

server/csharp/MetaObjects/Parser.cs

Lines changed: 82 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -96,11 +96,19 @@ public static ParseResult ParseJson(string content, ParseOptions opts)
9696
}
9797
catch (JsonException err)
9898
{
99+
// FR5a / ADR-0009: pre-BuildTree errors can't reach the parser's
100+
// per-run JsonPathBuilder (BuildTree hasn't started yet); build a
101+
// minimal $-rooted JsonSource envelope so cross-port callers see a
102+
// consistent shape. Mirrors TS parser-json.ts:25-29.
103+
var envelope = new JsonSource(
104+
new[] { opts.SourceName ?? "<unknown>" },
105+
"$");
99106
throw new ParseException(
100107
$"Invalid JSON: {err.Message}",
101108
ErrorCode.ERR_MALFORMED_JSON,
102109
opts.SourceName,
103-
null);
110+
"$",
111+
envelope);
104112
}
105113

106114
// The JsonDocument must stay alive while buildTree walks it.
@@ -712,102 +720,99 @@ private static void ApplyInlineAttrsAndUnknownKeys(
712720
JsonElement nodeData,
713721
ParseState st)
714722
{
723+
// PARENT-PATH SCOPE: errors raised from this loop use the parent node's
724+
// canonical path (the path of the node that owns these inline attrs), NOT
725+
// a key-deeper path. Mirrors TS parser-core.ts:626-690, which never pushes
726+
// the attr key onto _currentPath inside this loop — `path` is the parent's.
727+
// This is the FR5a contract: ERR_RESERVED_ATTR / ERR_UNKNOWN_ATTR /
728+
// ERR_BAD_ATTR_VALUE all surface at the parent (the node body that
729+
// contains the bad key). The previous C# code threaded one level deeper,
730+
// which produced cross-port envelope drift on error-reserved-word-as-attr.
731+
string path = st.Builder.ToString();
715732
foreach (JsonProperty prop in nodeData.EnumerateObject())
716733
{
717734
string key = prop.Name;
718735

719736
// Skip all reserved structural keys.
720737
if (RESERVED_KEYS.Contains(key)) continue;
721738

722-
// Push the attr key onto the builder so its canonical path is visible
723-
// to any error / source envelope built within this iteration.
724-
st.Builder.PushKey(key);
725-
try
739+
if (!key.StartsWith(ATTR_PREFIX, StringComparison.Ordinal))
726740
{
727-
string path = st.Builder.ToString();
728-
729-
if (!key.StartsWith(ATTR_PREFIX, StringComparison.Ordinal))
730-
{
731-
string displayName = model.Name != ""
732-
? $"{model.Type}.{model.SubType} '{model.Name}'"
733-
: $"{model.Type}.{model.SubType}";
734-
ReportProblem(
735-
$"Unknown key '{key}' on {displayName} at {path} (must be reserved or {ATTR_PREFIX}-prefixed)",
736-
st, ErrorCode.ERR_UNKNOWN_ATTR);
737-
continue;
738-
}
741+
string displayName = model.Name != ""
742+
? $"{model.Type}.{model.SubType} '{model.Name}'"
743+
: $"{model.Type}.{model.SubType}";
744+
ReportProblem(
745+
$"Unknown key '{key}' on {displayName} at {path} (must be reserved or {ATTR_PREFIX}-prefixed)",
746+
st, ErrorCode.ERR_UNKNOWN_ATTR);
747+
continue;
748+
}
739749

740-
// Inline attribute (@-prefixed).
741-
string attrName = key[ATTR_PREFIX.Length..];
742-
JsonElement rawVal = prop.Value;
743-
744-
// ERR_RESERVED_ATTR: any @-prefixed reserved structural key (e.g. "@isArray",
745-
// "@name") is always a metadata-author error and is reported as a hard
746-
// error regardless of strict mode — downstream code must never see a
747-
// bogus MetaAttr named after a reserved word. Mirrors the TS
748-
// parser-core.ts (errors-sink-direct in lax mode, throw in strict) and
749-
// Java CanonicalJsonParser. The YAML desugar layer has its own pre-check
750-
// that fires first when authoring in YAML; the canonical parser is the
751-
// last-line cross-language gate.
752-
if (RESERVED_KEYS.Contains(attrName))
750+
// Inline attribute (@-prefixed).
751+
string attrName = key[ATTR_PREFIX.Length..];
752+
JsonElement rawVal = prop.Value;
753+
754+
// ERR_RESERVED_ATTR: any @-prefixed reserved structural key (e.g. "@isArray",
755+
// "@name") is always a metadata-author error and is reported as a hard
756+
// error regardless of strict mode — downstream code must never see a
757+
// bogus MetaAttr named after a reserved word. Mirrors the TS
758+
// parser-core.ts (errors-sink-direct in lax mode, throw in strict) and
759+
// Java CanonicalJsonParser. The YAML desugar layer has its own pre-check
760+
// that fires first when authoring in YAML; the canonical parser is the
761+
// last-line cross-language gate.
762+
if (RESERVED_KEYS.Contains(attrName))
763+
{
764+
string displayName = model.Name != ""
765+
? $"{model.Type}.{model.SubType} '{model.Name}'"
766+
: $"{model.Type}.{model.SubType}";
767+
string msg = $"Reserved structural key '{attrName}' must not be " +
768+
$"{ATTR_PREFIX}-prefixed on {displayName} at {path} (write it bare)";
769+
if (st.Strict)
753770
{
754-
string displayName = model.Name != ""
755-
? $"{model.Type}.{model.SubType} '{model.Name}'"
756-
: $"{model.Type}.{model.SubType}";
757-
string msg = $"Reserved structural key '{attrName}' must not be " +
758-
$"{ATTR_PREFIX}-prefixed on {displayName} at {path} (write it bare)";
759-
if (st.Strict)
760-
{
761-
throw new ParseException(msg, ErrorCode.ERR_RESERVED_ATTR, st.Source, path,
762-
st.CurrentSource());
763-
}
764-
st.Errors.Add(new MetaError(msg, ErrorCode.ERR_RESERVED_ATTR, st.Source, path,
765-
st.CurrentSource()));
766-
continue;
771+
throw new ParseException(msg, ErrorCode.ERR_RESERVED_ATTR, st.Source, path,
772+
st.CurrentSource());
767773
}
774+
st.Errors.Add(new MetaError(msg, ErrorCode.ERR_RESERVED_ATTR, st.Source, path,
775+
st.CurrentSource()));
776+
continue;
777+
}
768778

769-
AttrSchema? attrSpec = st.Registry
770-
.AttrsOf(model.Type, model.SubType)
771-
.FirstOrDefault(a => a.Name == attrName);
779+
AttrSchema? attrSpec = st.Registry
780+
.AttrsOf(model.Type, model.SubType)
781+
.FirstOrDefault(a => a.Name == attrName);
772782

773-
object? value;
774-
try
783+
object? value;
784+
try
785+
{
786+
if (attrSpec is not null && attrSpec.ValueType is not null)
775787
{
776-
if (attrSpec is not null && attrSpec.ValueType is not null)
777-
{
778-
// Declared attr with a concrete value-type — convert toward
779-
// that DataType.
780-
DataType dataType = st.Registry.Find(TYPE_ATTR, attrSpec.ValueType)?.DataType
781-
?? DataType.String;
782-
value = DataConverter.ConvertToDataType(dataType, rawVal);
783-
}
784-
else
785-
{
786-
// Undeclared @-attr OR declared-but-untyped — store the value
787-
// type-preserved, exactly as the JSON author wrote it.
788-
value = DataConverter.ToAttrValue(rawVal);
789-
}
788+
// Declared attr with a concrete value-type — convert toward
789+
// that DataType.
790+
DataType dataType = st.Registry.Find(TYPE_ATTR, attrSpec.ValueType)?.DataType
791+
?? DataType.String;
792+
value = DataConverter.ConvertToDataType(dataType, rawVal);
790793
}
791-
catch (FormatException err)
794+
else
792795
{
793-
ReportProblem(
794-
$"Failed to convert attribute \"{ATTR_PREFIX}{attrName}\" at {path}: {err.Message}",
795-
st, ErrorCode.ERR_BAD_ATTR_VALUE);
796-
continue;
796+
// Undeclared @-attr OR declared-but-untyped — store the value
797+
// type-preserved, exactly as the JSON author wrote it.
798+
value = DataConverter.ToAttrValue(rawVal);
797799
}
798-
799-
// A bare string for a declared stringArray attr → one-element array.
800-
object? normalized = NormalizeStringArrayAttr(
801-
model.Type, model.SubType, attrName, value, st.Registry);
802-
// An object-valued filter attr → canonical { field: { op: value } } form.
803-
normalized = NormalizeFilterAttr(
804-
model.Type, model.SubType, attrName, normalized, st.Registry);
805-
model.SetAttr(attrName, normalized);
806800
}
807-
finally
801+
catch (FormatException err)
808802
{
809-
st.Builder.Pop();
803+
ReportProblem(
804+
$"Failed to convert attribute \"{ATTR_PREFIX}{attrName}\" at {path}: {err.Message}",
805+
st, ErrorCode.ERR_BAD_ATTR_VALUE);
806+
continue;
810807
}
808+
809+
// A bare string for a declared stringArray attr → one-element array.
810+
object? normalized = NormalizeStringArrayAttr(
811+
model.Type, model.SubType, attrName, value, st.Registry);
812+
// An object-valued filter attr → canonical { field: { op: value } } form.
813+
normalized = NormalizeFilterAttr(
814+
model.Type, model.SubType, attrName, normalized, st.Registry);
815+
model.SetAttr(attrName, normalized);
811816
}
812817
}
813818

server/csharp/MetaObjects/YamlDesugar.cs

Lines changed: 16 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -281,29 +281,25 @@ private static JsonObject DesugarBody(
281281
continue;
282282
}
283283

284-
if (RESERVED_KEYS.Contains(key))
284+
if (RESERVED_KEYS.Contains(key) ||
285+
key.StartsWith(ATTR_PREFIX, StringComparison.Ordinal))
285286
{
286-
// Reserved structural key, written bare — accept as-is.
287+
// Reserved structural key written bare, OR author-written `@<name>:`
288+
// form — pass through as-is. An `@`-prefixed RESERVED keyword (e.g.
289+
// "@isArray") is NOT caught here: the canonical parser owns
290+
// ERR_RESERVED_ATTR and emits it with the proper FR5a envelope
291+
// (format=json, jsonPath at the parent node). Mirrors TS
292+
// yaml-desugar.ts:197-203 and Java YamlDesugar.java:340-349.
287293
output[key] = YamlToJsonNode(value);
288-
}
289-
else if (key.StartsWith(ATTR_PREFIX, StringComparison.Ordinal))
290-
{
291-
// Author wrote `@<name>:` directly. Two sub-cases:
292-
// (a) @-prefixed RESERVED keyword (e.g. "@isArray") — ERR_RESERVED_ATTR.
293-
// Drop the entry so the canonical parser doesn't double-report.
294-
// (b) @-prefixed regular attr — accept; D2 coercion guard still applies.
295-
string attrName = key[ATTR_PREFIX.Length..];
296-
if (attrName != "" && RESERVED_KEYS.Contains(attrName))
294+
if (key.StartsWith(ATTR_PREFIX, StringComparison.Ordinal))
297295
{
298-
errors.Add(new YamlCollectedError(
299-
$"Reserved structural key '{attrName}' must not be {ATTR_PREFIX}-prefixed at {path} (write it bare)",
300-
ErrorCode.ERR_RESERVED_ATTR));
301-
continue;
302-
}
303-
output[key] = YamlToJsonNode(value);
304-
if (attrName != "")
305-
{
306-
CheckCoercion(attrName, value, schemaIndex, errors, path);
296+
// D2 coercion guard also applies to author-written @-keys (the
297+
// awkward form) — but only for non-reserved attr names.
298+
string attrName = key[ATTR_PREFIX.Length..];
299+
if (attrName != "" && !RESERVED_KEYS.Contains(attrName))
300+
{
301+
CheckCoercion(attrName, value, schemaIndex, errors, path);
302+
}
307303
}
308304
}
309305
else

0 commit comments

Comments
 (0)