Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 78 additions & 13 deletions fixtures/source-resolution-conformance/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand All @@ -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.
Expand 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

Expand Down Expand Up @@ -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

Expand Down
10 changes: 10 additions & 0 deletions fixtures/source-resolution-conformance/cases.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -115,4 +115,73 @@ public void Expand_HonorsExcludeGlobs()
Directory.Delete(dir, recursive: true);
}
}

/// <summary>
/// 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.
/// </summary>
[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<IOException>(() => 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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> Symlinks);
Dictionary<string, string> 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<string> CaseNames()
{
Expand Down Expand Up @@ -78,7 +84,8 @@ private static List<Case> 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;
}
Expand Down Expand Up @@ -127,12 +134,21 @@ public void ResolvesTheSameFileSet(string name)

if (c.ExpectError is not null)
{
var ex = Assert.ThrowsAny<MetaModelException>(() => 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<Exception>(() => SourceResolver.ResolveCollection(invokeDir));
if (!c.ErrorIsNative)
{
Assert.Equal(c.ExpectError.Value.GetString(), ex.Code.ToString());
var coded = Assert.IsAssignableFrom<MetaModelException>(ex);
if (c.ExpectError.Value.ValueKind == JsonValueKind.String)
{
Assert.Equal(c.ExpectError.Value.GetString(), coded.Code.ToString());
}
}
return;
}
Expand Down
41 changes: 41 additions & 0 deletions server/csharp/MetaObjects.Conformance.Tests/SourceResolverTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,45 @@ public void TwoUnresolvablePaths_ReportsTheContentFirstOne()
Directory.Delete(root, recursive: true);
}
}

/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[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);
}
}
}
Loading
Loading