Skip to content

Commit 75c00db

Browse files
dmealingclaude
andcommitted
feat(java): FR5a — thread source through parser + validation + MetaData.source
Wire the ErrorSource foundation (commit 1) through the canonical JSON parse pipeline and the per-node validation phase, per ADR-0009. MetaData base class: - Adds `private ErrorSource source = CodeSource.DEFAULT` so every node always has a populated provenance (never null per ADR-0009 §Decision). - `getSource()` / `setSource(ErrorSource)` API. - `freezeSource()` / `isSourceFrozen()` freeze guard — once frozen the setter throws IllegalStateException (mirrors the C# private-set + Freeze() pattern). - `setSource(null)` throws NullPointerException with a CodeSource.DEFAULT hint. MetaDataException: - New optional `envelope: ErrorSource` field, exposed via `getEnvelope(): Optional<ErrorSource>`. - New convenience constructor `(message, ErrorCode, ErrorSource)` for loader sites, plus a full 7-arg internal constructor. - All existing constructors remain back-compat (envelope defaults to null when not provided). CanonicalJsonParser: - Maintains a `JsonPath.Builder` field; pushes/pops keys + indices as the tree walk descends. The wrapper key, `children`, the array index, and the fused `<type>.<subType>` key are all pushed in order so the canonical JSONPath matches the C# / TS oracle byte-identically. - `tagNodeWithJsonSource(node)` overwrites `CodeSource.DEFAULT` with a fresh JsonSource on every parser-constructed node (root, structural children, attr children). Skips if the node already carries a JsonSource (overlay path) — the merge phase owns transitions to MergedSource (FR5c slot). - `currentJsonSource()` builds an envelope at the active JSONPath for parse-time errors. Wired into the ERR_UNKNOWN_TYPE root check, the ERR_RESERVED_ATTR @-on-reserved-key throw, and the ERR_BAD_ATTR_VALUE filter-as-string throw. ValidationPhase (representative migration; full sweep is incremental): - field.enum validators now pass `node.getSource()` into the envelope constructor (ERR_BAD_ATTR_VALUE for malformed @values, ERR_MISSING_REQUIRED_ATTR when neither own nor inherited @values present). Pattern for other ValidationPhase sites that have a node in scope is identical. Canonical JSON serializer: no changes required. The serializer reads fields explicitly (name, package, extends, @-attrs, children), so the new `source` field is naturally omitted from output. Round-trip parity preserved. Tests: - SourceOnNodeTest (9 tests) covers programmatic default (CodeSource.DEFAULT), setSource() + freeze guard, the JsonSource length-1 invariant, parser-populated JsonSource at every depth (root + entity + field), canonical-serializer omission, and parse-error envelope propagation (ERR_RESERVED_ATTR). Java metadata suite: 647/647 baseline → 680/680 after FR5a (+33 new tests across JsonPath/SemanticDiff/SourceOnNode). No regressions. Refs: ADR-0009, docs/superpowers/specs/2026-05-25-fr5a-json-shape-loader-errors.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 80bf081 commit 75c00db

5 files changed

Lines changed: 553 additions & 72 deletions

File tree

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

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
import com.metaobjects.cache.CacheStrategy;
2323
import com.metaobjects.cache.HybridCache;
2424
import com.metaobjects.collections.IndexedMetaDataCollection;
25+
import com.metaobjects.source.CodeSource;
26+
import com.metaobjects.source.ErrorSource;
2527
import org.slf4j.Logger;
2628
import org.slf4j.LoggerFactory;
2729

@@ -278,6 +280,17 @@ public static void registerTypes() {
278280
private MetaDataLoader loader = null;
279281
private ClassLoader metaDataClassLoader=null;
280282

283+
// FR5a / ADR-0009 — Loader error envelope + source-on-node.
284+
//
285+
// Every metadata node carries its own provenance. `source` is always populated;
286+
// the default for any node not built by a loader phase is CodeSource.DEFAULT
287+
// (format == "code"). The loader's parser overwrites this with a JsonSource /
288+
// YamlSource / MergedSource / ResolvedSource / DatabaseSource as it builds the
289+
// tree. Mirrors C# MetaData.Source ({ get; private set; }) — settable through
290+
// setSource(), frozen after freezeSource() is called.
291+
private ErrorSource source = CodeSource.DEFAULT;
292+
private boolean sourceFrozen = false;
293+
281294
/**
282295
* Constructs a MetaData object with enhanced type system integration.
283296
*
@@ -896,6 +909,71 @@ public boolean hasSuperData() {
896909
return superData != null;
897910
}
898911

912+
////////////////////////////////////////////////////
913+
// FR5a / ADR-0009 — Source-on-node provenance
914+
915+
/**
916+
* Returns the provenance envelope describing where this node was constructed.
917+
*
918+
* <p>Never returns {@code null}: nodes built programmatically (via the public
919+
* Java API without a loader) default to {@link CodeSource#DEFAULT}. Nodes built
920+
* by the loader pipeline carry a {@link com.metaobjects.source.JsonSource}
921+
* (canonical-JSON parse), and post-load phases may overwrite this with a
922+
* {@link com.metaobjects.source.MergedSource} or
923+
* {@link com.metaobjects.source.ResolvedSource}.</p>
924+
*
925+
* @return the provenance envelope; never {@code null}
926+
* @since FR5a / ADR-0009
927+
*/
928+
public ErrorSource getSource() {
929+
return source;
930+
}
931+
932+
/**
933+
* Sets the provenance envelope for this node.
934+
*
935+
* <p>Honors the freeze guard installed by {@link #freezeSource()}: once frozen,
936+
* any further mutation throws {@link IllegalStateException}. The loader
937+
* pipeline freezes after construction; this lets phases mutate source during
938+
* load while preventing accidental mutation by runtime consumers.</p>
939+
*
940+
* @param source the provenance envelope; must not be {@code null}
941+
* @throws NullPointerException if {@code source} is {@code null}
942+
* @throws IllegalStateException if this node's source is already frozen
943+
* @since FR5a / ADR-0009
944+
*/
945+
public void setSource(ErrorSource source) {
946+
if (source == null) {
947+
throw new NullPointerException("MetaData source must not be null — use CodeSource.DEFAULT for the no-loader case");
948+
}
949+
if (sourceFrozen) {
950+
throw new IllegalStateException("MetaData source is frozen and cannot be modified: " + this);
951+
}
952+
this.source = source;
953+
}
954+
955+
/**
956+
* Marks this node's {@code source} as frozen — subsequent {@link #setSource}
957+
* calls throw {@link IllegalStateException}.
958+
*
959+
* <p>Idempotent: calling on an already-frozen node is a no-op.</p>
960+
*
961+
* @since FR5a / ADR-0009
962+
*/
963+
public void freezeSource() {
964+
this.sourceFrozen = true;
965+
}
966+
967+
/**
968+
* Returns whether this node's source is currently frozen.
969+
*
970+
* @return {@code true} if frozen, {@code false} otherwise
971+
* @since FR5a / ADR-0009
972+
*/
973+
public boolean isSourceFrozen() {
974+
return sourceFrozen;
975+
}
976+
899977
////////////////////////////////////////////////////
900978
// ATTRIBUTE METHODS
901979

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

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
package com.metaobjects;
99

10+
import com.metaobjects.source.ErrorSource;
1011
import com.metaobjects.util.MetaDataPath;
1112

1213
import java.time.Instant;
@@ -49,6 +50,17 @@ public class MetaDataException extends RuntimeException {
4950
*/
5051
private final ErrorCode code;
5152

53+
/**
54+
* FR5a / ADR-0009 — Loader error envelope provenance.
55+
*
56+
* <p>Populated when the exception is constructed via the envelope-aware
57+
* constructor. Loader phases that have a node in scope pass
58+
* {@code node.getSource()} here so consumers can report file path + JSONPath
59+
* for the offending location. {@code null} when the exception was raised
60+
* outside a loader context (legacy / runtime callers).</p>
61+
*/
62+
private final ErrorSource envelope;
63+
5264
/**
5365
* Creates a MetaDataException with a simple message.
5466
* Backward compatible constructor.
@@ -121,9 +133,31 @@ public MetaDataException(String message, MetaData source, String operation,
121133
public MetaDataException(String message, MetaData source, String operation,
122134
Throwable cause, Map<String, Object> additionalContext,
123135
ErrorCode code) {
136+
this(message, source, operation, cause, additionalContext, code, null);
137+
}
138+
139+
/**
140+
* FR5a / ADR-0009 — Full internal constructor with the loader error envelope.
141+
*
142+
* <p>When a loader phase has a node in scope, pass {@code node.getSource()} as
143+
* {@code envelope} so consumers can report file path + JSONPath for the
144+
* offending location.</p>
145+
*
146+
* @param message the error message
147+
* @param source the MetaData object where the error occurred (may be null)
148+
* @param operation the operation being performed when the error occurred (may be null)
149+
* @param cause the underlying cause (may be null)
150+
* @param additionalContext additional context information (may be empty)
151+
* @param code the structured {@link ErrorCode}; {@code null} if not applicable
152+
* @param envelope the loader error envelope provenance; {@code null} if not applicable
153+
*/
154+
public MetaDataException(String message, MetaData source, String operation,
155+
Throwable cause, Map<String, Object> additionalContext,
156+
ErrorCode code, ErrorSource envelope) {
124157
super(buildEnhancedMessage(message, source, operation, additionalContext), cause);
125158

126159
this.code = code;
160+
this.envelope = envelope;
127161
this.metaDataPath = source != null ? MetaDataPath.buildPath(source) : null;
128162
this.operation = operation;
129163
this.context = new LinkedHashMap<>(additionalContext != null ? additionalContext : Collections.emptyMap());
@@ -139,6 +173,19 @@ public MetaDataException(String message, MetaData source, String operation,
139173
}
140174
}
141175

176+
/**
177+
* FR5a / ADR-0009 — Convenience constructor for loader sites: message + code +
178+
* envelope.
179+
*
180+
* @param message the error message
181+
* @param code the structured {@link ErrorCode}
182+
* @param envelope the loader error envelope provenance; {@code null} permitted
183+
* but defeats the purpose
184+
*/
185+
public MetaDataException(String message, ErrorCode code, ErrorSource envelope) {
186+
this(message, null, null, null, Collections.emptyMap(), code, envelope);
187+
}
188+
142189
/**
143190
* Builds an enhanced error message with context information.
144191
*
@@ -191,6 +238,19 @@ public Optional<ErrorCode> getCode() {
191238
return Optional.ofNullable(code);
192239
}
193240

241+
/**
242+
* FR5a / ADR-0009 — Returns the loader error envelope provenance, if any.
243+
*
244+
* <p>{@code Optional.empty()} for exceptions raised outside a loader context
245+
* (legacy / runtime callers); a populated value for envelope-aware loader
246+
* sites.</p>
247+
*
248+
* @return Optional containing the {@link ErrorSource} envelope, or empty if none
249+
*/
250+
public Optional<ErrorSource> getEnvelope() {
251+
return Optional.ofNullable(envelope);
252+
}
253+
194254
/**
195255
* Returns the hierarchical path to the MetaData object where this error occurred.
196256
*

server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -186,12 +186,13 @@ private static void validateEnumNode(MetaData node) {
186186
if (node.hasMetaAttr(EnumField.ATTR_VALUES, false)) {
187187
MetaAttribute<?> valuesAttr = node.getMetaAttr(EnumField.ATTR_VALUES, false);
188188
if (!EnumField.validateEnumValues(valuesAttr.getValue())) {
189+
// FR5a — envelope carries the offending node's provenance.
189190
throw new MetaDataException(
190191
ErrorMessageConstants.ERR_BAD_ATTR_VALUE
191192
+ ": field.enum '" + node.getName()
192193
+ "' @values must be a non-empty list of identifier-safe, unique members"
193194
+ " (e.g. [\"DRAFT\",\"PUBLISHED\"])",
194-
ErrorCode.ERR_BAD_ATTR_VALUE);
195+
ErrorCode.ERR_BAD_ATTR_VALUE, node.getSource());
195196
}
196197
// Own @values present and valid — required check not needed.
197198
return;
@@ -200,11 +201,12 @@ private static void validateEnumNode(MetaData node) {
200201
// --- Required check ---
201202
// No own @values. Valid only if there is a super reference (inheriting @values).
202203
if (node.getSuperData() == null) {
204+
// FR5a — envelope carries the offending node's provenance.
203205
throw new MetaDataException(
204206
ErrorMessageConstants.ERR_MISSING_REQUIRED_ATTR
205207
+ ": field.enum '" + node.getName()
206208
+ "' is missing required @values attribute",
207-
ErrorCode.ERR_MISSING_REQUIRED_ATTR);
209+
ErrorCode.ERR_MISSING_REQUIRED_ATTR, node.getSource());
208210
}
209211
// Has a super — inherits @values from the super, which is validated on its own node.
210212
}

0 commit comments

Comments
 (0)