Skip to content

Commit af46e65

Browse files
dmealingclaude
andcommitted
feat(fr-032): T6 — wire ERR_RELATIVE_REF_IN_CANONICAL guard (Java/C#/Python)
Completes FR-032: canonical JSON must reject a relative reference. A ref value starting with `::` (root-absolute) or `..::` (parent-relative) is a YAML-authoring affordance the desugar expands — it must never survive into canonical JSON. TS already had the guard (parser-core.ts); this wires it in the JVM, C#, and Python canonical-JSON parsers so the behavior is consistent and cross-port-gated. - New shared fixture `fixtures/conformance/error-relative-ref-in-canonical/` (`extends: "::Base"` in canonical JSON → exactly one `ERR_RELATIVE_REF_IN_CANONICAL`; legacy code-set assertion so each port need only emit the code, not match jsonPath). - Java: ErrorCode + ErrorMessageConstants enum/constant; guard in CanonicalJsonParser on the `extends` key + inline `@`-ref attrs (throws, halting parse → one error). Also FQN-qualified 4 Java-INTERNAL test resources (fruitbasket*) that used the now-rejected `::Apple` form in canonical JSON. - C#: ERR_RELATIVE_REF_IN_CANONICAL in Errors.cs; guard in Parser.cs (ApplyReservedKeys + ApplyInlineAttrsAndUnknownKeys). - Python: is_relative_ref() in naming_refs.py; guard in parser.py (extends + inline ref attrs), reusing REF_BEARING_ATTR_NAMES. The guard lives ONLY in each port's canonical-JSON parser (JSON by construction — no format check), never the YAML desugar. Verified: new fixture passes in all 5 ports; TS 378/0, Java ConformanceTest 378/0 (full metadata 1040/0), C# 649/0 (codegen 216/0), Python conformance 295/0 (full 1255/0). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 2a50b20 commit af46e65

13 files changed

Lines changed: 232 additions & 16 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
[{"code":"ERR_RELATIVE_REF_IN_CANONICAL"}]
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"metadata.root": {
3+
"package": "demo",
4+
"children": [
5+
{
6+
"object.entity": {
7+
"name": "Widget",
8+
"extends": "::Base",
9+
"children": [
10+
{ "field.long": { "name": "id" } },
11+
{ "identity.primary": { "name": "id", "@fields": "id" } }
12+
]
13+
}
14+
},
15+
{
16+
"object.entity": {
17+
"name": "Base",
18+
"children": [
19+
{ "field.string": { "name": "label" } }
20+
]
21+
}
22+
}
23+
]
24+
}
25+
}

server/csharp/MetaObjects/Errors.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ public enum ErrorCode
3939
ERR_MALFORMED_YAML,
4040
ERR_YAML_COERCION,
4141
ERR_RESERVED_ATTR,
42+
// FR-032 (ADR-0032) — a ref-bearing value in canonical JSON used a relative
43+
// form (leading "::" or "..::"). Relative refs are YAML-authoring sugar the
44+
// desugar expands; they must never appear in canonical (interchange) JSON.
45+
ERR_RELATIVE_REF_IN_CANONICAL,
4246
ERR_INVALID_ORIGIN,
4347
// FR-024 (ADR-0029) — origin @via inference + cardinality checks. Vocabulary-only
4448
// here until FR-024 Phase E (the C# loader does not run the inference yet):

server/csharp/MetaObjects/Parser.cs

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,52 @@ private static void ReportProblem(
226226
st.Warnings.Add(msg);
227227
}
228228

229+
// -----------------------------------------------------------------------
230+
// FR-032 (ADR-0032) — relative-ref guard for canonical JSON.
231+
//
232+
// The bare (sigil-free) inline attribute names whose VALUE is a metadata
233+
// reference. In canonical JSON these are @-prefixed (@objectRef, …). Mirrors
234+
// YamlDesugar.RefBearingAttrNames + the Java/TS REF_BEARING_ATTR_NAMES. The
235+
// structural `extends` key is a reference too, but is guarded separately (it
236+
// is the bare RESERVED_KEY_EXTENDS body key, not an @-prefixed attr).
237+
// -----------------------------------------------------------------------
238+
239+
private static readonly HashSet<string> RefBearingAttrNames = new(StringComparer.Ordinal)
240+
{
241+
"objectRef", "references", "from", "of", "via",
242+
"payloadRef", "responseRef", "parameterRef",
243+
};
244+
245+
/// <summary>
246+
/// FR-032 (ADR-0032) — guard a ref-bearing value against relative forms.
247+
/// Canonical JSON is the self-contained interchange form: every ref-bearing
248+
/// attribute MUST be fully qualified. A relative authoring form (leading
249+
/// <c>::</c> or <c>..::</c>) surviving into canonical JSON is
250+
/// <see cref="ErrorCode.ERR_RELATIVE_REF_IN_CANONICAL"/>. The C# canonical
251+
/// parser only ever handles canonical JSON (the YAML desugar expands these
252+
/// forms before the parser sees them), so no format check is needed. Throwing
253+
/// halts the parse so exactly one error is produced, mirroring the
254+
/// <see cref="ErrorCode.ERR_RESERVED_ATTR"/> rejection style.
255+
/// </summary>
256+
private static void GuardRelativeRefInCanonical(string refLabel, string? rawValue, ParseState st)
257+
{
258+
if (rawValue is null) return;
259+
string parentPrefix = PACKAGE_PARENT + PACKAGE_SEPARATOR; // "..::"
260+
if (!rawValue.StartsWith(PACKAGE_SEPARATOR, StringComparison.Ordinal)
261+
&& !rawValue.StartsWith(parentPrefix, StringComparison.Ordinal))
262+
{
263+
return;
264+
}
265+
string path = st.Builder.ToString();
266+
string msg =
267+
$"relative reference '{rawValue}' on {refLabel} at {path} is not allowed in " +
268+
$"canonical JSON — canonical JSON must be fully-qualified. Relative forms " +
269+
$"(leading '{PACKAGE_SEPARATOR}' or '{parentPrefix}') are YAML-authoring sugar " +
270+
$"that the desugar expands.";
271+
throw new ParseException(
272+
msg, ErrorCode.ERR_RELATIVE_REF_IN_CANONICAL, st.Source, path, st.CurrentSource());
273+
}
274+
229275
// -----------------------------------------------------------------------
230276
// splitTypeKey — split a fused wrapper key into (type, subType, explicit).
231277
//
@@ -902,6 +948,8 @@ private static void ApplyReservedKeys(
902948
}
903949
else
904950
{
951+
// FR-032 — reject a relative `extends` ref in canonical JSON.
952+
GuardRelativeRefInCanonical($"\"{RESERVED_KEY_EXTENDS}\"", rawExtends.GetString(), st);
905953
model.SetSuper(rawExtends.GetString()!);
906954
}
907955
}
@@ -1112,6 +1160,15 @@ private static void ApplyInlineAttrsAndUnknownKeys(
11121160
continue;
11131161
}
11141162

1163+
// FR-032 — reject a relative ref value on a ref-bearing inline @-attr
1164+
// (@objectRef/@references/@from/@of/@via/@payloadRef/@responseRef/
1165+
// @parameterRef) in canonical JSON. Only string values can be relative.
1166+
if (RefBearingAttrNames.Contains(attrName)
1167+
&& rawVal.ValueKind == JsonValueKind.String)
1168+
{
1169+
GuardRelativeRefInCanonical($"\"{ATTR_PREFIX}{attrName}\"", rawVal.GetString(), st);
1170+
}
1171+
11151172
AttrSchema? attrSpec = st.Registry
11161173
.AttrsOf(model.Type, model.SubType)
11171174
.FirstOrDefault(a => a.Name == attrName);

server/java/metadata/src/main/java/com/metaobjects/ErrorCode.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,15 @@ public enum ErrorCode {
311311
*/
312312
ERR_REGISTRY_SEALED,
313313

314+
/**
315+
* FR-032 (ADR-0032): a ref-bearing attribute in canonical JSON carries a
316+
* relative reference form (a value starting with {@code ::} or {@code ..::}).
317+
* Canonical JSON is the self-contained interchange form and MUST be fully
318+
* qualified — relative forms are a YAML-authoring affordance the desugar
319+
* expands, so they must never survive into canonical JSON.
320+
*/
321+
ERR_RELATIVE_REF_IN_CANONICAL,
322+
314323
/** An internal loader error with no stable error code. */
315324
ERR_UNKNOWN,
316325
}

server/java/metadata/src/main/java/com/metaobjects/loader/parser/json/CanonicalJsonParser.java

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,24 @@ public class CanonicalJsonParser extends BaseMetaDataParser implements MetaDataF
125125
KEY_IS_ARRAY, KEY_CHILDREN, KEY_VALUE
126126
);
127127

128+
/**
129+
* FR-032 (ADR-0032) — the bare (sigil-free) inline attribute names whose VALUE is a
130+
* metadata reference. In canonical JSON these are {@code @}-prefixed ({@code @objectRef}, …).
131+
* Mirrors {@code YamlDesugar.REF_BEARING_ATTR_NAMES} and the TS {@code REF_BEARING_ATTR_NAMES}.
132+
* The structural {@code extends} key is a reference too, but is guarded separately (it is the
133+
* bare {@link #KEY_EXTENDS} body key, not an {@code @}-prefixed attr).
134+
*/
135+
private static final Set<String> REF_BEARING_ATTR_NAMES = Set.of(
136+
"objectRef", "references", "from", "of", "via",
137+
"payloadRef", "responseRef", "parameterRef"
138+
);
139+
140+
/** FR-032 — package separator ({@code ::}); a value with this leading prefix is root-absolute-relative. */
141+
private static final String FR032_PKG_SEPARATOR = com.metaobjects.util.MetaDataUtil.SEP;
142+
143+
/** FR-032 — parent-relative prefix ({@code ..::}). */
144+
private static final String FR032_PARENT_PREFIX = ".." + FR032_PKG_SEPARATOR;
145+
128146
// -----------------------------------------------------------------------
129147
// Constructor
130148
// -----------------------------------------------------------------------
@@ -227,6 +245,33 @@ private ErrorSource buildCurrentSourceEnvelope() {
227245
return new JsonSource(List.of(getFilename()), path);
228246
}
229247

248+
/**
249+
* FR-032 (ADR-0032) — guard a ref-bearing value against relative forms.
250+
*
251+
* <p>Canonical JSON is the self-contained interchange form: every ref-bearing
252+
* attribute MUST be fully qualified. A relative authoring form (leading
253+
* {@code ::} or {@code ..::}) surviving into canonical JSON is
254+
* {@code ERR_RELATIVE_REF_IN_CANONICAL}. {@link CanonicalJsonParser} only ever
255+
* handles canonical JSON (it is JSON by construction), so no format check is
256+
* needed — the YAML desugar expands these forms before the parser sees them.
257+
* Throwing halts the parse so exactly one error is produced, mirroring the
258+
* {@code ERR_RESERVED_ATTR} rejection style.</p>
259+
*/
260+
private void guardRelativeRefInCanonical(String refLabel, String rawValue) {
261+
if (rawValue == null) return;
262+
if (!rawValue.startsWith(FR032_PKG_SEPARATOR) && !rawValue.startsWith(FR032_PARENT_PREFIX)) {
263+
return;
264+
}
265+
throw new MetaDataException(
266+
ErrorMessageConstants.ERR_RELATIVE_REF_IN_CANONICAL
267+
+ ": relative reference '" + rawValue + "' on " + refLabel
268+
+ " in file [" + getFilename() + "] is not allowed in canonical JSON — "
269+
+ "canonical JSON must be fully-qualified. Relative forms (leading '"
270+
+ FR032_PKG_SEPARATOR + "' or '" + FR032_PARENT_PREFIX + "') are "
271+
+ "YAML-authoring sugar that the desugar expands.",
272+
ErrorCode.ERR_RELATIVE_REF_IN_CANONICAL, currentSourceEnvelope());
273+
}
274+
230275
/**
231276
* FR5a / ADR-0009 — Re-throw a {@link MetaDataException} that escaped a
232277
* registry/base-parser call site without an envelope, stamping it with the
@@ -618,6 +663,8 @@ private void processNode(MetaData parent, String type, String subType,
618663
String pkg = getStringOrNull(body, KEY_PACKAGE);
619664
// "extends" (canonical) → "super" (base parser slot)
620665
String superRef = getStringOrNull(body, KEY_EXTENDS);
666+
// FR-032 — reject a relative `extends` ref in canonical JSON.
667+
guardRelativeRefInCanonical("\"" + KEY_EXTENDS + "\"", superRef);
621668
// "abstract" (canonical) — handled AFTER node creation as an attribute
622669
Boolean isAbstract = getBooleanOrNull(body, KEY_ABSTRACT);
623670
Boolean isOverlay = getBooleanOrNull(body, KEY_OVERLAY);
@@ -1117,6 +1164,16 @@ private void processAttributes(MetaData md, JsonObject body) {
11171164
// Strip the @ prefix
11181165
String attrName = key.substring(JSON_ATTR_PREFIX.length());
11191166

1167+
// FR-032 (ADR-0032) — reject a relative reference value on a ref-bearing
1168+
// inline attr (@objectRef / @references / @from / @of / @via / @payloadRef /
1169+
// @responseRef / @parameterRef). Canonical JSON must be fully-qualified.
1170+
if (REF_BEARING_ATTR_NAMES.contains(attrName)) {
1171+
JsonElement refVal = entry.getValue();
1172+
if (refVal.isJsonPrimitive() && refVal.getAsJsonPrimitive().isString()) {
1173+
guardRelativeRefInCanonical(key, refVal.getAsString());
1174+
}
1175+
}
1176+
11201177
// ADR-0006 §D1: reserved structural keywords must be written bare in canonical JSON.
11211178
// Writing them as @-prefixed attributes (e.g. @isArray, @name) is unconditionally
11221179
// invalid — there is no registry-exception.

server/java/metadata/src/main/java/com/metaobjects/util/ErrorMessageConstants.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,15 @@ private ErrorMessageConstants() {
5656
*/
5757
public static final String ERR_RESERVED_ATTR = "ERR_RESERVED_ATTR";
5858

59+
/**
60+
* FR-032 (ADR-0032): a ref-bearing attribute in canonical JSON carries a
61+
* relative reference form (value starting with {@code ::} or {@code ..::}).
62+
* Canonical JSON must be fully qualified — relative forms are YAML-authoring
63+
* sugar that the desugar expands before lowering to canonical JSON.
64+
* Cross-language contract: {@code ERR_RELATIVE_REF_IN_CANONICAL}.
65+
*/
66+
public static final String ERR_RELATIVE_REF_IN_CANONICAL = "ERR_RELATIVE_REF_IN_CANONICAL";
67+
5968
/**
6069
* Error code emitted when a field origin (passthrough / aggregate / collection)
6170
* declares an invalid path or attribute (e.g. malformed {@code @via} relationship

server/java/metadata/src/test/resources/com/draagon/meta/loader/simple/fruitbasket-metadata.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,14 +75,14 @@
7575
"field.object": {
7676
"name": "apples",
7777
"isArray": true,
78-
"@objectRef": "::Apple"
78+
"@objectRef": "simple::fruitbasket::Apple"
7979
}
8080
},
8181
{
8282
"field.object": {
8383
"name": "oranges",
8484
"isArray": true,
85-
"@objectRef": "::Orange"
85+
"@objectRef": "simple::fruitbasket::Orange"
8686
}
8787
}
8888
]

server/java/metadata/src/test/resources/com/draagon/meta/loader/simple/fruitbasket-proxy-metadata.json

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -85,21 +85,21 @@
8585
"field.object": {
8686
"name": "fruitIds",
8787
"isArray": true,
88-
"@objectRef": "::Apple"
88+
"@objectRef": "simple::fruitbasket::Apple"
8989
}
9090
},
9191
{
9292
"field.object": {
9393
"name": "apples",
9494
"isArray": true,
95-
"@objectRef": "::Apple"
95+
"@objectRef": "simple::fruitbasket::Apple"
9696
}
9797
},
9898
{
9999
"field.object": {
100100
"name": "oranges",
101101
"isArray": true,
102-
"@objectRef": "::Orange"
102+
"@objectRef": "simple::fruitbasket::Orange"
103103
}
104104
}
105105
]
@@ -128,7 +128,7 @@
128128
"@sourceFields": [
129129
"basketId"
130130
],
131-
"@objectRef": "::Basket"
131+
"@objectRef": "simple::fruitbasket::Basket"
132132
}
133133
},
134134
{
@@ -138,7 +138,7 @@
138138
"@sourceFields": [
139139
"fruitId"
140140
],
141-
"@objectRef": "::Fruit"
141+
"@objectRef": "simple::fruitbasket::Fruit"
142142
}
143143
},
144144
{
@@ -179,7 +179,7 @@
179179
"@sourceFields": [
180180
"basketId"
181181
],
182-
"@objectRef": "::Basket"
182+
"@objectRef": "simple::fruitbasket::Basket"
183183
}
184184
},
185185
{

server/java/metadata/src/test/resources/com/metaobjects/loader/simple/fruitbasket-metadata.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,14 +75,14 @@
7575
"field.object": {
7676
"name": "apples",
7777
"isArray": true,
78-
"@objectRef": "::Apple"
78+
"@objectRef": "simple::fruitbasket::Apple"
7979
}
8080
},
8181
{
8282
"field.object": {
8383
"name": "oranges",
8484
"isArray": true,
85-
"@objectRef": "::Orange"
85+
"@objectRef": "simple::fruitbasket::Orange"
8686
}
8787
}
8888
]

0 commit comments

Comments
 (0)