diff --git a/fixtures/source-resolution-conformance/README.md b/fixtures/source-resolution-conformance/README.md
index 20ac2254f..b911f36bd 100644
--- a/fixtures/source-resolution-conformance/README.md
+++ b/fixtures/source-resolution-conformance/README.md
@@ -11,7 +11,7 @@ are read, `scope` filters what is emitted from them.
## Shape
```
-cases.json # { cases: [{ name, tree, symlinks?, config, resolveFrom?, expectFiles?, expectError? }] }
+cases.json # { cases: [{ name, tree, symlinks?, config, resolveFrom?, expectFiles?, expectError?, errorIsNative? }] }
README.md
```
@@ -31,6 +31,36 @@ README.md
`real/…`) — see "Order is deliberately NOT pinned" below for the parallel
point about `expectFiles` being exact strings, not just "the same underlying
file by any name". Every port's runner must honor this key.
+
+ Following symlinks is what makes a symlink CYCLE reachable, so
+ `a-symlink-cycle-is-an-error` gates the other half of the same contract: a
+ directory symlink that revisits a directory already on the current walk
+ branch must RAISE, not be walked. The failure it exists to catch is not a
+ hang — it is silent nonsense. Left unguarded, a walk yields the same real
+ file at ever-deeper phantom paths (`model/loop/model/loop/…/meta.a.json`),
+ and because de-duplication keys on the LEXICAL path those are all distinct,
+ so each is admitted as its own source and the same metadata loads over and
+ over. Which error is raised is deliberately not pinned (hence `expectError:
+ true`) — the ports raise their own native types. What is pinned is that a
+ cycle is loud.
+
+ "Already on the current walk BRANCH" is the precise rule, not "already
+ seen": the ancestor set must be carried down the recursion and never shared
+ between siblings, so a directory legitimately reachable by two different
+ symlinked paths — a diamond, not a cycle — still resolves rather than being
+ falsely rejected.
+
+ This case is a FLOOR, and deliberately so. On Linux the kernel refuses to
+ traverse past its own symlink-resolution depth (ELOOP around 40 levels), so
+ an unguarded walk in some runtimes raises anyway — late, and for the wrong
+ reason, but it raises, and `expectError: true` cannot tell that apart from a
+ real guard. What the case DOES discriminate is a port that swallows that
+ kernel error and reports success: C#'s `Directory.EnumerateFiles(...,
+ AllDirectories)` defaults to `IgnoreInaccessible`, so before this case it
+ completed normally and returned 41 phantom copies of one file. Because the
+ floor cannot pin immediacy, each port additionally owns a unit test that the
+ raise happens on REVISIT rather than at the kernel's limit — `sources.test.ts`,
+ `test_sources.py`, `DirectorySourceTest.java`, `DirectorySourceTests.cs`.
- **`config`** — written verbatim to `.metaobjects/config.json`, under the
directory named by `resolveFrom` (project root when `resolveFrom` is absent).
When `null`, no config file is created at all.
@@ -48,10 +78,26 @@ README.md
`resolveFrom`), compared as an **UNORDERED SET**. See "Order is
deliberately not pinned" below.
- **`expectError`** — either a STRING error code the resolution must fail with
- exactly, or the literal `true` meaning "must raise, but which error/code is
- deliberately not pinned across ports" (see "Also deliberately NOT pinned:
- the malformed-config error code" below for why the latter form exists).
- Exactly one of `expectFiles` / `expectError` is present per case.
+ exactly, or the literal `true` meaning "must raise the port's own coded
+ metadata error, but WHICH code is deliberately not pinned across ports" (see
+ "Also deliberately NOT pinned: the malformed-config error code" below for why
+ the latter form exists). Exactly one of `expectFiles` / `expectError` is
+ present per case. Note what `true` still pins in a port that HAS a coded error
+ type: the type. Java, C# and Python all require `MetaDataException` /
+ `MetaModelException` / `ParseError` on this arm, so a malformed config that
+ crashes with a raw `NullPointerException` fails the case rather than passing
+ it. (TypeScript alone cannot: it propagates the raw parser error, which is the
+ whole reason the code is unpinned.)
+- **`errorIsNative`** — OPTIONAL boolean, only meaningful beside
+ `expectError: true`. Means the failure surfaces as a PLATFORM-native error
+ rather than the port's coded metadata error, so the type requirement above is
+ lifted for this case alone. Exactly one case sets it:
+ `a-symlink-cycle-is-an-error`, where the raise comes from the filesystem walk
+ (`IOException` in C#, `FileSystemLoopException` in Java, `SymlinkLoopError` in
+ Python) and never passes through a coded-error constructor. It exists so that
+ admitting that one case did not silently relax the other six `true` cases from
+ "a coded parse failure" to "anything at all" — which is precisely what a blanket
+ widening of the runners does, and did.
## Semantics pinned here
@@ -115,14 +161,33 @@ README.md
`.strict()` at the top level (`config.ts`) — so a key no version of
TypeScript has ever declared throws a `ZodError` and resolution never
reaches the source-listing step at all, while Java/C#/Python all resolve
- successfully, silently ignoring it. Not added as a shared `expectFiles`
- case here because doing so would need EITHER loosening `ConfigSchema`'s
- top-level strictness (a reference-implementation behavior change with a
- blast radius well beyond source resolution — every `loadConfig` caller,
- not just this corpus) OR asserting a `true`-sentinel `expectError` that
- TypeScript alone would satisfy, contradicting the other three ports'
- actual success — neither of which this corpus is positioned to decide
- unilaterally. Left as an open, human-reviewable follow-up.
+ successfully, silently ignoring it.
+
+ **RULED: the asymmetry is INTENDED, and neither side moves.** It is not a
+ defect that leaked; it falls out of who owns the file. `.metaobjects/config.json`
+ is TypeScript's, and TypeScript is the only port that models its whole
+ vocabulary — so it is the only port that CAN tell a typo from a key a sibling
+ owns. `.strict()` is what converts that knowledge into a diagnostic, and the
+ hazard is specific and proven: a stripped `scopes` (for `scope`) silently means
+ "everything in scope", and a stripped `migrate.scopee` silently governs the whole
+ database. Java, C# and Python know only `schema_version` + `sources`; for them
+ every other key is indistinguishable from a TS-owned one, so strictness there
+ could only be imitated by embedding TypeScript's key list and keeping it in
+ lockstep forever — at which point any port a release behind would REJECT a
+ config a newer `meta` had just written. Tolerance is the only coherent behaviour
+ for a partial reader, and strictness the only coherent behaviour for the owner.
+
+ So this stays OFF the shared corpus permanently — not deferred. A shared case
+ asserts ONE outcome, and the correct outcome here differs by port BY DESIGN;
+ adding one could only be done by making some port wrong. Each half is pinned in
+ the port that owns it instead: TypeScript's in `sdk/test/config.test.ts`
+ (unknown top-level key ⇒ throws, alongside the nested `scopes`/`scopee` cases),
+ and the tolerant half in `SourceResolverTests.cs`,
+ `tests/config/test_source_resolver.py` and `SourceResolverTest.java`
+ (unknown top-level key ⇒ resolves normally). The
+ forward-compatibility cost is real and accepted: a config written by a NEWER
+ `meta` hard-fails an OLDER one. That is what `schema_version` is for, and a loud
+ failure beats a `scope` that silently matched everything.
## Order is deliberately NOT pinned
diff --git a/fixtures/source-resolution-conformance/cases.json b/fixtures/source-resolution-conformance/cases.json
index aa535f582..e367db2f7 100644
--- a/fixtures/source-resolution-conformance/cases.json
+++ b/fixtures/source-resolution-conformance/cases.json
@@ -264,6 +264,16 @@
"symlinks": { "model/linked": "external" },
"config": { "schema_version": 1, "sources": [{ "path": "model" }] },
"expectFiles": ["model/meta.top.json", "model/linked/meta.linked.json"]
+ },
+ {
+ "name": "a-symlink-cycle-is-an-error",
+ "tree": {
+ "model/meta.a.json": "{\"metadata.root\":{\"children\":[]}}"
+ },
+ "symlinks": { "model/loop": "model" },
+ "config": { "schema_version": 1, "sources": [{ "path": "model" }] },
+ "expectError": true,
+ "errorIsNative": true
}
]
}
diff --git a/server/csharp/MetaObjects.Conformance.Tests/DirectorySourceTests.cs b/server/csharp/MetaObjects.Conformance.Tests/DirectorySourceTests.cs
index 0dee418dd..0e0f0f4cc 100644
--- a/server/csharp/MetaObjects.Conformance.Tests/DirectorySourceTests.cs
+++ b/server/csharp/MetaObjects.Conformance.Tests/DirectorySourceTests.cs
@@ -115,4 +115,73 @@ public void Expand_HonorsExcludeGlobs()
Directory.Delete(dir, recursive: true);
}
}
+
+ ///
+ /// The shared corpus (`a-symlink-cycle-is-an-error`) can only pin THAT a cycle
+ /// raises, because on Linux the kernel's own ELOOP makes an unguarded walk raise
+ /// eventually too. These two pin what it cannot: that the raise happens on REVISIT,
+ /// and that it distinguishes a cycle from a diamond.
+ ///
+ [Fact]
+ public void Expand_RaisesOnRevisit_NotAtTheKernelsSymlinkDepthLimit()
+ {
+ string dir = Path.Combine(Path.GetTempPath(), "ds_" + Path.GetRandomFileName());
+ Directory.CreateDirectory(dir);
+ try
+ {
+ File.WriteAllText(Path.Combine(dir, "meta.a.json"), "{}");
+ Directory.CreateSymbolicLink(Path.Combine(dir, "loop"), dir);
+
+ var ex = Assert.Throws(() => new DirectorySource(dir).Expand().ToList());
+ Assert.Contains("symlink loop detected", ex.Message);
+
+ // Immediacy: the message names the FIRST revisit. Before the guard,
+ // Directory.EnumerateFiles(..., AllDirectories) swallowed the kernel's ELOOP
+ // (EnumerationOptions.IgnoreInaccessible) and returned ~40 phantom copies of
+ // meta.a.json with no error at all — so asserting only "it threw" would have
+ // been satisfied by a walk that had already descended 40 levels.
+ Assert.Contains(Path.Combine(dir, "loop"), ex.Message);
+ Assert.DoesNotContain(Path.Combine("loop", "loop"), ex.Message);
+ }
+ finally
+ {
+ Directory.Delete(dir, recursive: true);
+ }
+ }
+
+ [Fact]
+ public void Expand_FollowsADiamond_WhichIsNotACycle()
+ {
+ string dir = Path.Combine(Path.GetTempPath(), "ds_" + Path.GetRandomFileName());
+ Directory.CreateDirectory(dir);
+ try
+ {
+ // shared/ is reachable twice — via one/ and via two/ — but never from
+ // itself. The ancestor set must therefore be per-BRANCH: a set shared
+ // across siblings would see the second arrival as a revisit and reject a
+ // perfectly valid tree.
+ var shared = Path.Combine(dir, "shared");
+ Directory.CreateDirectory(shared);
+ File.WriteAllText(Path.Combine(shared, "meta.shared.json"), "{}");
+ Directory.CreateDirectory(Path.Combine(dir, "one"));
+ Directory.CreateDirectory(Path.Combine(dir, "two"));
+ Directory.CreateSymbolicLink(Path.Combine(dir, "one", "link"), shared);
+ Directory.CreateSymbolicLink(Path.Combine(dir, "two", "link"), shared);
+
+ // One real file, reached by three distinct paths. Compared on FilePath, not
+ // Id — Id is the bare filename, so all three share it and a distinctness
+ // check there would pass on a single result just as happily.
+ var paths = new DirectorySource(dir).Expand().Select(f => f.FilePath).ToList();
+
+ Assert.Equal(3, paths.Count);
+ Assert.Equal(3, paths.Distinct().Count());
+ Assert.Contains(Path.Combine(dir, "one", "link", "meta.shared.json"), paths);
+ Assert.Contains(Path.Combine(dir, "two", "link", "meta.shared.json"), paths);
+ Assert.Contains(Path.Combine(shared, "meta.shared.json"), paths);
+ }
+ finally
+ {
+ Directory.Delete(dir, recursive: true);
+ }
+ }
}
diff --git a/server/csharp/MetaObjects.Conformance.Tests/SourceResolutionConformanceTests.cs b/server/csharp/MetaObjects.Conformance.Tests/SourceResolutionConformanceTests.cs
index c79347340..9973a9adf 100644
--- a/server/csharp/MetaObjects.Conformance.Tests/SourceResolutionConformanceTests.cs
+++ b/server/csharp/MetaObjects.Conformance.Tests/SourceResolutionConformanceTests.cs
@@ -27,14 +27,20 @@ private sealed record Case(
// count silently goes stale; the structural description above does not.)
string ResolveFrom,
string[]? ExpectFiles,
- // A JSON string pins the exact error code raised; JSON `true` pins only
- // that resolution RAISES — the malformed-config error code is
- // deliberately not pinned cross-port (see the corpus README).
+ // A JSON string pins the exact error code raised; JSON `true` leaves the
+ // CODE unpinned but still requires the port's coded MetaModelException —
+ // the malformed-config error code is deliberately not pinned cross-port
+ // (see the corpus README), the TYPE is.
JsonElement? ExpectError,
// Optional: linkPath -> targetPath, both project-root-relative, materialized
// AFTER `Tree` (I1 — a symlinked source root, or a symlinked subdirectory
// inside a walked tree).
- Dictionary Symlinks);
+ Dictionary Symlinks,
+ // Optional: this case's failure surfaces as a PLATFORM-native error rather
+ // than a coded MetaModelException, so the type requirement above is lifted
+ // for it alone (the symlink-cycle case, whose raise comes from the
+ // filesystem walk and never reaches a coded-error constructor).
+ bool ErrorIsNative);
public static TheoryData CaseNames()
{
@@ -78,7 +84,8 @@ private static List LoadCases()
foreach (var p in sl.EnumerateObject()) symlinks[p.Name] = p.Value.GetString()!;
}
- cases.Add(new Case(el.GetProperty("name").GetString()!, tree, cfg, resolveFrom, expectFiles, expectError, symlinks));
+ bool errorIsNative = el.TryGetProperty("errorIsNative", out var ein) && ein.GetBoolean();
+ cases.Add(new Case(el.GetProperty("name").GetString()!, tree, cfg, resolveFrom, expectFiles, expectError, symlinks, errorIsNative));
}
return cases;
}
@@ -127,12 +134,21 @@ public void ResolvesTheSameFileSet(string name)
if (c.ExpectError is not null)
{
- var ex = Assert.ThrowsAny(() => SourceResolver.ResolveCollection(invokeDir));
- // A string pins the exact code; `true` only pins that it raises —
- // see the ExpectError field doc above.
- if (c.ExpectError.Value.ValueKind == JsonValueKind.String)
+ // A string pins the exact code. `true` leaves the CODE unpinned but
+ // still requires MetaModelException — a malformed config that crashes
+ // with a raw NullReferenceException must FAIL this case, not pass it.
+ // `ErrorIsNative` lifts only the type requirement, for the one case
+ // whose raise comes from the filesystem walk (IOException) and never
+ // reaches a coded-error constructor; widening the whole arm instead
+ // would quietly relax the six malformed-config cases to "anything".
+ var ex = Assert.ThrowsAny(() => SourceResolver.ResolveCollection(invokeDir));
+ if (!c.ErrorIsNative)
{
- Assert.Equal(c.ExpectError.Value.GetString(), ex.Code.ToString());
+ var coded = Assert.IsAssignableFrom(ex);
+ if (c.ExpectError.Value.ValueKind == JsonValueKind.String)
+ {
+ Assert.Equal(c.ExpectError.Value.GetString(), coded.Code.ToString());
+ }
}
return;
}
diff --git a/server/csharp/MetaObjects.Conformance.Tests/SourceResolverTests.cs b/server/csharp/MetaObjects.Conformance.Tests/SourceResolverTests.cs
index 99b131623..19ccd427b 100644
--- a/server/csharp/MetaObjects.Conformance.Tests/SourceResolverTests.cs
+++ b/server/csharp/MetaObjects.Conformance.Tests/SourceResolverTests.cs
@@ -42,4 +42,45 @@ public void TwoUnresolvablePaths_ReportsTheContentFirstOne()
Directory.Delete(root, recursive: true);
}
}
+
+ ///
+ /// A genuinely unknown top-level key resolves normally here, and THROWS in
+ /// TypeScript. That asymmetry is ruled INTENDED in the source-resolution corpus
+ /// README: TypeScript owns this file and models its whole vocabulary, so only it
+ /// can tell a typo from a key a sibling owns. This port models the neutral subset
+ /// (`schema_version` + `sources`), for which every other key is indistinguishable
+ /// from a TypeScript-owned one — imitating strictness would mean carrying TS's key
+ /// list and rejecting a config a newer `meta` had just written.
+ ///
+ ///
+ /// Deliberately NOT a shared corpus case: a shared case asserts one outcome and
+ /// the correct outcome differs by port, so adding one could only be done by making
+ /// some port wrong. This is the tolerant half.
+ ///
+ [Fact]
+ public void AnUnknownTopLevelConfigKey_IsIgnored_NotRejected()
+ {
+ var root = Path.Combine(Path.GetTempPath(), "source-resolver-unknownkey-" + System.Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(root);
+ try
+ {
+ var model = Path.Combine(root, "model");
+ Directory.CreateDirectory(model);
+ File.WriteAllText(Path.Combine(model, "meta.a.json"), "{\"metadata.root\":{\"children\":[]}}");
+
+ var dotMo = Path.Combine(root, ".metaobjects");
+ Directory.CreateDirectory(dotMo);
+ File.WriteAllText(Path.Combine(dotMo, "config.json"),
+ "{\"schema_version\":1,\"sources\":[{\"path\":\"model\"}],\"foo\":1}");
+
+ var resolved = SourceResolver.ResolveCollection(root);
+
+ Assert.Single(resolved);
+ Assert.Equal(Path.Combine(model, "meta.a.json"), resolved[0]);
+ }
+ finally
+ {
+ Directory.Delete(root, recursive: true);
+ }
+ }
}
diff --git a/server/csharp/MetaObjects/Loader/DirectorySource.cs b/server/csharp/MetaObjects/Loader/DirectorySource.cs
index df06a62a5..496200f11 100644
--- a/server/csharp/MetaObjects/Loader/DirectorySource.cs
+++ b/server/csharp/MetaObjects/Loader/DirectorySource.cs
@@ -58,6 +58,24 @@ public DirectorySource(string directory, Options? opts = null)
Opts = opts ?? new Options();
}
+ ///
+ /// How two resolved directory paths are compared when deciding "is this an
+ /// ancestor I have already walked".
+ ///
+ ///
+ /// Ordinal on Linux, where the filesystem genuinely distinguishes Model from
+ /// model and case-folding would REJECT a valid tree. Case-insensitive
+ /// elsewhere, because necessarily mixes spellings — a
+ /// non-link segment keeps whatever casing the caller wrote (via
+ /// ) while a resolved link comes back in the
+ /// filesystem's own canonical casing — so on a case-insensitive volume an ordinal
+ /// compare lets MODEL/loop -> model slip past the guard. Slipping past is not
+ /// benign here: this is the one port whose enumeration SWALLOWS the kernel's ELOOP,
+ /// so there is no backstop underneath it.
+ ///
+ private static readonly StringComparer PathComparer =
+ OperatingSystem.IsLinux() ? StringComparer.Ordinal : StringComparer.OrdinalIgnoreCase;
+
///
/// Enumerate the matched files as instances, sorted
/// by full path (ordinal). The sort happens on the full path so that nested
@@ -65,11 +83,7 @@ public DirectorySource(string directory, Options? opts = null)
///
public IEnumerable Expand()
{
- SearchOption search = Opts.Recurse
- ? SearchOption.AllDirectories
- : SearchOption.TopDirectoryOnly;
-
- IEnumerable files = System.IO.Directory.EnumerateFiles(Directory, "*", search)
+ IEnumerable files = Collect(Directory, new HashSet(PathComparer))
.Where(p => _supportedExtensions.Contains(Path.GetExtension(p)));
if (Opts.ExcludePending)
@@ -91,6 +105,110 @@ public IEnumerable Expand()
.Select(p => new FileSource(p));
}
+ ///
+ /// The recursive walk behind , carrying the REAL
+ /// (symlink-resolved) ancestor directories already on this walk branch.
+ ///
+ ///
+ ///
+ /// This replaces Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories),
+ /// which cannot express the contract. That overload follows directory symlinks —
+ /// which is correct and is the cross-port contract — but has no loop guard, and
+ /// its EnumerationOptions default of IgnoreInaccessible then SWALLOWS
+ /// the kernel's own ELOOP refusal. So a self-referential symlink did not hang and
+ /// did not throw: it completed normally, yielding the same real file ~40 times over
+ /// at ever-deeper phantom paths. Nothing downstream could recover from that, because
+ /// de-duplicates on the LEXICAL full
+ /// path and every phantom is lexically distinct — so each one was admitted as its own
+ /// source and the same metadata loaded once per level. TypeScript, Java and Python all
+ /// raise here; C# was the only port that reported success.
+ ///
+ ///
+ /// Paths are built by lexical join throughout, exactly as the old enumeration built
+ /// them — a symlinked directory's OWN name survives in the reported path; only the
+ /// WALK resolves the link. is extended only on the
+ /// recursive call and never mutated in place, so it describes the current branch
+ /// rather than siblings already visited at the same level: a directory legitimately
+ /// reachable through two different symlinks (a diamond, not a cycle) still resolves.
+ ///
+ ///
+ /// A directory symlink revisits a directory already on this branch.
+ private IEnumerable Collect(string directory, HashSet ancestors)
+ {
+ var real = RealPath(directory);
+
+ if (ancestors.Contains(real))
+ throw new IOException(
+ $"symlink loop detected while expanding metadata directory: {directory} revisits {real}");
+
+ var nextAncestors = new HashSet(ancestors, PathComparer) { real };
+
+ // Sorted so traversal is deterministic across filesystems, matching the
+ // full-path ordinal sort Expand() applies to the result.
+ var entries = System.IO.Directory.GetFileSystemEntries(directory);
+ Array.Sort(entries, StringComparer.Ordinal);
+
+ foreach (var entry in entries)
+ {
+ // Directory.Exists follows symlinks, so a symlinked subdirectory is
+ // traversed rather than reported as a file — the behaviour the old
+ // AllDirectories enumeration had, and the cross-port contract.
+ if (System.IO.Directory.Exists(entry))
+ {
+ if (!Opts.Recurse) continue;
+ foreach (var f in Collect(entry, nextAncestors)) yield return f;
+ }
+ else
+ {
+ yield return entry;
+ }
+ }
+ }
+
+ ///
+ /// The fully symlink-resolved form of — .NET's
+ /// realpath(3) stand-in, since only
+ /// normalizes ./.. and resolves no links at all.
+ ///
+ ///
+ /// Resolution is component-by-component from the root because
+ /// Directory.ResolveLinkTarget canonicalizes ONLY the final segment. Using it
+ /// alone would leave a symlinked ANCESTOR unresolved, and the cycle guard would then
+ /// compare a half-resolved path against a real one and miss the loop — recursing
+ /// forever on exactly the input it exists to catch. Falls back to the lexical path if
+ /// the filesystem refuses an answer (the directory vanished mid-walk, or a permission
+ /// error): that is the enumeration's problem to report, not this guard's.
+ ///
+ private static string RealPath(string path)
+ {
+ var full = Path.GetFullPath(path);
+ var root = Path.GetPathRoot(full);
+ if (string.IsNullOrEmpty(root)) return full;
+
+ var current = root;
+ foreach (var segment in full.Substring(root.Length)
+ .Split(new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar },
+ StringSplitOptions.RemoveEmptyEntries))
+ {
+ current = Path.Combine(current, segment);
+ try
+ {
+ // returnFinalTarget walks a chain of links (relative or absolute
+ // targets, either) in one call and hands back an already
+ // fully-qualified target. Every path this loop resolves is a
+ // directory — Collect only ever calls RealPath on one it has
+ // already confirmed exists — so Directory.ResolveLinkTarget alone
+ // is authoritative; a link cycle here surfaces as an IOException
+ // we fall back on.
+ var target = System.IO.Directory.ResolveLinkTarget(current, returnFinalTarget: true);
+ if (target is not null) current = target.FullName;
+ }
+ catch (IOException) { /* unresolvable — keep the lexical form for this segment */ }
+ catch (UnauthorizedAccessException) { /* ditto */ }
+ }
+ return current;
+ }
+
/// True when any ancestor path component between and
/// (i.e. excluding the file's own name) is
/// exactly .
diff --git a/server/java/metadata/src/test/java/com/metaobjects/config/SourceResolutionConformanceTest.java b/server/java/metadata/src/test/java/com/metaobjects/config/SourceResolutionConformanceTest.java
index d24765fbf..ceef14eb5 100644
--- a/server/java/metadata/src/test/java/com/metaobjects/config/SourceResolutionConformanceTest.java
+++ b/server/java/metadata/src/test/java/com/metaobjects/config/SourceResolutionConformanceTest.java
@@ -44,6 +44,7 @@
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
/**
@@ -72,12 +73,18 @@ public class SourceResolutionConformanceTest {
*/
/**
* {@code expectError}: a JSON string pins the exact error code raised; JSON
- * {@code true} pins only that resolution RAISES — the malformed-config error
- * code is deliberately not pinned cross-port (see the corpus README).
+ * {@code true} leaves the CODE unpinned but still requires the port's coded
+ * {@link MetaDataException} — the malformed-config error code is deliberately
+ * not pinned cross-port (see the corpus README), the TYPE is.
+ *
+ *
{@code errorIsNative}: this case's failure surfaces as a PLATFORM-native
+ * error rather than a coded exception, lifting the type requirement for it
+ * alone — the symlink-cycle case, whose raise comes from the filesystem walk
+ * ({@code FileSystemLoopException}) and never reaches a coded-error constructor.
*/
private record Case(String name, Map tree, JsonObject config,
String resolveFrom, List expectFiles, JsonElement expectError,
- Map symlinks) {}
+ Map symlinks, boolean errorIsNative) {}
/** Package-private (not private): shared with {@link SourceResolutionCorpusNotEmptyTest},
* which needs to locate the same committed corpus file without a second definition
@@ -142,7 +149,9 @@ public static Collection