Skip to content

Commit 63a470b

Browse files
dmealingclaude
andcommitted
docs(conformance): rule the unknown-config-key asymmetry INTENDED, and pin both halves
A genuinely unknown top-level key in `.metaobjects/config.json` throws in TypeScript and resolves fine in Java, C# and Python. The corpus README recorded this as a confirmed cross-port divergence and left it as "an open, human-reviewable follow-up". Reviewed: neither side moves. It is not a defect that leaked — it falls out of ownership. The file is TypeScript's, and TypeScript is the only port that models its whole vocabulary, so it is the only one that CAN tell a typo from a key a sibling owns. `.strict()` is what turns that knowledge into a diagnostic, and the hazard it catches is specific and already pinned: a stripped `scopes` (for `scope`) silently means "everything in scope", and a stripped `migrate.scopee` silently governs the whole database. The other three model the neutral subset, for which every other key is indistinguishable from a TS-owned one; they could imitate strictness only by carrying TypeScript's key list in lockstep, and would then REJECT a config a newer `meta` had just written. Tolerance is the only coherent behaviour for a partial reader, strictness the only coherent behaviour for the owner. So this stays off the shared corpus permanently rather than being deferred again. A shared case asserts ONE outcome and the correct outcome differs by port by design, so adding one could only be done by making some port wrong. Each half is now pinned where it belongs instead — TypeScript's in `config.test.ts` beside the `scopes`/`scopee` cases it shares a rationale with, and the tolerant half in the C#, Python and Java resolver tests, none of which previously covered it. The forward-compatibility cost is stated and accepted: a config written by a newer `meta` hard-fails an older one, which is what `schema_version` is for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 82d74f0 commit 63a470b

5 files changed

Lines changed: 155 additions & 8 deletions

File tree

‎fixtures/source-resolution-conformance/README.md‎

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -145,14 +145,33 @@ README.md
145145
`.strict()` at the top level (`config.ts`) — so a key no version of
146146
TypeScript has ever declared throws a `ZodError` and resolution never
147147
reaches the source-listing step at all, while Java/C#/Python all resolve
148-
successfully, silently ignoring it. Not added as a shared `expectFiles`
149-
case here because doing so would need EITHER loosening `ConfigSchema`'s
150-
top-level strictness (a reference-implementation behavior change with a
151-
blast radius well beyond source resolution — every `loadConfig` caller,
152-
not just this corpus) OR asserting a `true`-sentinel `expectError` that
153-
TypeScript alone would satisfy, contradicting the other three ports'
154-
actual success — neither of which this corpus is positioned to decide
155-
unilaterally. Left as an open, human-reviewable follow-up.
148+
successfully, silently ignoring it.
149+
150+
**RULED: the asymmetry is INTENDED, and neither side moves.** It is not a
151+
defect that leaked; it falls out of who owns the file. `.metaobjects/config.json`
152+
is TypeScript's, and TypeScript is the only port that models its whole
153+
vocabulary — so it is the only port that CAN tell a typo from a key a sibling
154+
owns. `.strict()` is what converts that knowledge into a diagnostic, and the
155+
hazard is specific and proven: a stripped `scopes` (for `scope`) silently means
156+
"everything in scope", and a stripped `migrate.scopee` silently governs the whole
157+
database. Java, C# and Python know only `schema_version` + `sources`; for them
158+
every other key is indistinguishable from a TS-owned one, so strictness there
159+
could only be imitated by embedding TypeScript's key list and keeping it in
160+
lockstep forever — at which point any port a release behind would REJECT a
161+
config a newer `meta` had just written. Tolerance is the only coherent behaviour
162+
for a partial reader, and strictness the only coherent behaviour for the owner.
163+
164+
So this stays OFF the shared corpus permanently — not deferred. A shared case
165+
asserts ONE outcome, and the correct outcome here differs by port BY DESIGN;
166+
adding one could only be done by making some port wrong. Each half is pinned in
167+
the port that owns it instead: TypeScript's in `sdk/test/config.test.ts`
168+
(unknown top-level key ⇒ throws, alongside the nested `scopes`/`scopee` cases),
169+
and the tolerant half in `SourceResolverTests.cs`,
170+
`tests/config/test_source_resolver.py` and `SourceResolverTest.java`
171+
(unknown top-level key ⇒ resolves normally). The
172+
forward-compatibility cost is real and accepted: a config written by a NEWER
173+
`meta` hard-fails an OLDER one. That is what `schema_version` is for, and a loud
174+
failure beats a `scope` that silently matched everything.
156175

157176
## Order is deliberately NOT pinned
158177

‎server/csharp/MetaObjects.Conformance.Tests/SourceResolverTests.cs‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,4 +42,45 @@ public void TwoUnresolvablePaths_ReportsTheContentFirstOne()
4242
Directory.Delete(root, recursive: true);
4343
}
4444
}
45+
46+
/// <summary>
47+
/// A genuinely unknown top-level key resolves normally here, and THROWS in
48+
/// TypeScript. That asymmetry is ruled INTENDED in the source-resolution corpus
49+
/// README: TypeScript owns this file and models its whole vocabulary, so only it
50+
/// can tell a typo from a key a sibling owns. This port models the neutral subset
51+
/// (`schema_version` + `sources`), for which every other key is indistinguishable
52+
/// from a TypeScript-owned one — imitating strictness would mean carrying TS's key
53+
/// list and rejecting a config a newer `meta` had just written.
54+
/// </summary>
55+
/// <remarks>
56+
/// Deliberately NOT a shared corpus case: a shared case asserts one outcome and
57+
/// the correct outcome differs by port, so adding one could only be done by making
58+
/// some port wrong. This is the tolerant half.
59+
/// </remarks>
60+
[Fact]
61+
public void AnUnknownTopLevelConfigKey_IsIgnored_NotRejected()
62+
{
63+
var root = Path.Combine(Path.GetTempPath(), "source-resolver-unknownkey-" + System.Guid.NewGuid().ToString("N"));
64+
Directory.CreateDirectory(root);
65+
try
66+
{
67+
var model = Path.Combine(root, "model");
68+
Directory.CreateDirectory(model);
69+
File.WriteAllText(Path.Combine(model, "meta.a.json"), "{\"metadata.root\":{\"children\":[]}}");
70+
71+
var dotMo = Path.Combine(root, ".metaobjects");
72+
Directory.CreateDirectory(dotMo);
73+
File.WriteAllText(Path.Combine(dotMo, "config.json"),
74+
"{\"schema_version\":1,\"sources\":[{\"path\":\"model\"}],\"foo\":1}");
75+
76+
var resolved = SourceResolver.ResolveCollection(root);
77+
78+
Assert.Single(resolved);
79+
Assert.Equal(Path.Combine(model, "meta.a.json"), resolved[0]);
80+
}
81+
finally
82+
{
83+
Directory.Delete(root, recursive: true);
84+
}
85+
}
4586
}

‎server/java/metadata/src/test/java/com/metaobjects/config/SourceResolverTest.java‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,13 @@
2121
import java.io.IOException;
2222
import java.nio.file.Files;
2323
import java.nio.file.Path;
24+
import java.util.Comparator;
2425
import java.util.LinkedHashMap;
2526
import java.util.List;
2627
import java.util.Map;
28+
import java.util.stream.Stream;
2729

30+
import static org.junit.Assert.assertEquals;
2831
import static org.junit.Assert.assertThrows;
2932
import static org.junit.Assert.assertTrue;
3033

@@ -65,4 +68,50 @@ public void twoUnresolvablePathsReportsTheContentFirstOne() throws IOException {
6568
Files.delete(root);
6669
}
6770
}
71+
72+
/**
73+
* The tolerant half of a ruled asymmetry. A genuinely unknown top-level key
74+
* resolves normally here and THROWS in TypeScript. Intended, and ruled in the
75+
* source-resolution corpus README: TypeScript owns this file and models its whole
76+
* vocabulary, so only it can tell a typo from a key a sibling owns. This port
77+
* models the neutral subset ({@code schema_version} + {@code sources}), for which
78+
* every other key is indistinguishable from a TypeScript-owned one — imitating
79+
* strictness would mean carrying TS's key list and rejecting a config a newer
80+
* {@code meta} had just written.
81+
*
82+
* <p>Deliberately NOT a shared corpus case: a shared case asserts one outcome and
83+
* the correct outcome differs by port, so adding one could only make some port wrong.
84+
*/
85+
@Test
86+
public void anUnknownTopLevelConfigKeyIsIgnoredNotRejected() throws IOException {
87+
Path root = Files.createTempDirectory("source-resolver-unknownkey-");
88+
try {
89+
Path model = Files.createDirectory(root.resolve("model"));
90+
Path file = model.resolve("meta.a.json");
91+
Files.writeString(file, "{\"metadata.root\":{\"children\":[]}}");
92+
93+
Path dotMo = Files.createDirectory(root.resolve(".metaobjects"));
94+
Files.writeString(dotMo.resolve("config.json"),
95+
"{\"schema_version\":1,\"sources\":[{\"path\":\"model\"}],\"foo\":1}");
96+
97+
List<Path> resolved = SourceResolver.resolveCollection(root);
98+
99+
assertEquals(List.of(file.toAbsolutePath().normalize()), resolved);
100+
} finally {
101+
deleteRecursive(root);
102+
}
103+
}
104+
105+
private static void deleteRecursive(Path dir) throws IOException {
106+
if (!Files.exists(dir)) return;
107+
try (Stream<Path> walk = Files.walk(dir)) {
108+
walk.sorted(Comparator.reverseOrder()).forEach(p -> {
109+
try {
110+
Files.delete(p);
111+
} catch (IOException ignored) {
112+
// best-effort temp-dir cleanup
113+
}
114+
});
115+
}
116+
}
68117
}

‎server/python/tests/config/test_source_resolver.py‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,30 @@ def test_collection_with_no_config_and_no_default_raises(tmp_path: Path) -> None
9292
assert e.value.code == ErrorCode.ERR_COLLECTION_NOT_FOUND
9393

9494

95+
def test_an_unknown_top_level_config_key_is_ignored_not_rejected(tmp_path: Path) -> None:
96+
"""The tolerant half of a ruled asymmetry.
97+
98+
A genuinely unknown top-level key resolves normally here and THROWS in
99+
TypeScript. Intended, and ruled in the source-resolution corpus README:
100+
TypeScript owns this file and models its whole vocabulary, so only it can tell
101+
a typo from a key a sibling owns. This port models the neutral subset
102+
(``schema_version`` + ``sources``), for which every other key is
103+
indistinguishable from a TypeScript-owned one — imitating strictness would mean
104+
carrying TS's key list and rejecting a config a newer ``meta`` had just written.
105+
106+
Deliberately NOT a shared corpus case: a shared case asserts one outcome and the
107+
correct outcome differs by port, so adding one could only make some port wrong.
108+
"""
109+
(tmp_path / ".metaobjects").mkdir()
110+
(tmp_path / ".metaobjects" / "config.json").write_text(
111+
json.dumps({"schema_version": 1, "sources": [{"path": "model"}], "foo": 1})
112+
)
113+
(tmp_path / "model").mkdir()
114+
(tmp_path / "model" / "meta.a.json").write_text("{}")
115+
got = resolve_collection(tmp_path)
116+
assert _rel(tmp_path, got) == {"model/meta.a.json"}
117+
118+
95119
def test_declared_sources_replace_the_default(tmp_path: Path) -> None:
96120
(tmp_path / ".metaobjects").mkdir()
97121
(tmp_path / ".metaobjects" / "config.json").write_text(

‎server/typescript/packages/sdk/test/config.test.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,20 @@ describe("ConfigSchema — phase-1 source resolution", () => {
160160
ConfigSchema.parse({ schema_version: 1, migrate: { d1: { bindingg: "DB" } } }),
161161
).toThrow();
162162
});
163+
test("rejects a genuinely unknown TOP-LEVEL key — TypeScript's half of a ruled asymmetry", () => {
164+
// Java, C# and Python model only `schema_version` + `sources`, so they resolve
165+
// this config successfully, ignoring `foo`. That divergence is INTENDED and is
166+
// ruled in fixtures/source-resolution-conformance/README.md: TypeScript owns
167+
// this file and is the only port that knows its whole vocabulary, so it is the
168+
// only one that can tell a typo from a key a sibling owns. A partial reader
169+
// could only imitate strictness by embedding TS's key list, and would then
170+
// reject a config a newer `meta` had just written.
171+
//
172+
// Deliberately kept OFF the shared corpus: a shared case asserts one outcome,
173+
// and the correct outcome here differs by port. This is TS's half; the tolerant
174+
// half is pinned in each of the other three ports.
175+
expect(() => ConfigSchema.parse({ schema_version: 1, foo: 1 })).toThrow();
176+
});
163177
test("an existing config with no new keys still parses (back-compat)", () => {
164178
const p = ConfigSchema.parse({
165179
schema_version: 1, pending_in_git: true,

0 commit comments

Comments
 (0)