@@ -11,7 +11,7 @@ are read, `scope` filters what is emitted from them.
1111## Shape
1212
1313```
14- cases.json # { cases: [{ name, tree, symlinks?, config, resolveFrom?, expectFiles?, expectError? }] }
14+ cases.json # { cases: [{ name, tree, symlinks?, config, resolveFrom?, expectFiles?, expectError?, errorIsNative? }] }
1515README.md
1616```
1717
@@ -31,6 +31,36 @@ README.md
3131 ` real/… ` ) — see "Order is deliberately NOT pinned" below for the parallel
3232 point about ` expectFiles ` being exact strings, not just "the same underlying
3333 file by any name". Every port's runner must honor this key.
34+
35+ Following symlinks is what makes a symlink CYCLE reachable, so
36+ ` a-symlink-cycle-is-an-error ` gates the other half of the same contract: a
37+ directory symlink that revisits a directory already on the current walk
38+ branch must RAISE, not be walked. The failure it exists to catch is not a
39+ hang — it is silent nonsense. Left unguarded, a walk yields the same real
40+ file at ever-deeper phantom paths (` model/loop/model/loop/…/meta.a.json ` ),
41+ and because de-duplication keys on the LEXICAL path those are all distinct,
42+ so each is admitted as its own source and the same metadata loads over and
43+ over. Which error is raised is deliberately not pinned (hence `expectError:
44+ true`) — the ports raise their own native types. What is pinned is that a
45+ cycle is loud.
46+
47+ "Already on the current walk BRANCH" is the precise rule, not "already
48+ seen": the ancestor set must be carried down the recursion and never shared
49+ between siblings, so a directory legitimately reachable by two different
50+ symlinked paths — a diamond, not a cycle — still resolves rather than being
51+ falsely rejected.
52+
53+ This case is a FLOOR, and deliberately so. On Linux the kernel refuses to
54+ traverse past its own symlink-resolution depth (ELOOP around 40 levels), so
55+ an unguarded walk in some runtimes raises anyway — late, and for the wrong
56+ reason, but it raises, and ` expectError: true ` cannot tell that apart from a
57+ real guard. What the case DOES discriminate is a port that swallows that
58+ kernel error and reports success: C#'s `Directory.EnumerateFiles(...,
59+ AllDirectories)` defaults to ` IgnoreInaccessible`, so before this case it
60+ completed normally and returned 41 phantom copies of one file. Because the
61+ floor cannot pin immediacy, each port additionally owns a unit test that the
62+ raise happens on REVISIT rather than at the kernel's limit — ` sources.test.ts ` ,
63+ ` test_sources.py ` , ` DirectorySourceTest.java ` , ` DirectorySourceTests.cs ` .
3464- ** ` config ` ** — written verbatim to ` .metaobjects/config.json ` , under the
3565 directory named by ` resolveFrom ` (project root when ` resolveFrom ` is absent).
3666 When ` null ` , no config file is created at all.
@@ -48,10 +78,26 @@ README.md
4878 ` resolveFrom ` ), compared as an ** UNORDERED SET** . See "Order is
4979 deliberately not pinned" below.
5080- ** ` expectError ` ** — either a STRING error code the resolution must fail with
51- exactly, or the literal ` true ` meaning "must raise, but which error/code is
52- deliberately not pinned across ports" (see "Also deliberately NOT pinned:
53- the malformed-config error code" below for why the latter form exists).
54- Exactly one of ` expectFiles ` / ` expectError ` is present per case.
81+ exactly, or the literal ` true ` meaning "must raise the port's own coded
82+ metadata error, but WHICH code is deliberately not pinned across ports" (see
83+ "Also deliberately NOT pinned: the malformed-config error code" below for why
84+ the latter form exists). Exactly one of ` expectFiles ` / ` expectError ` is
85+ present per case. Note what ` true ` still pins in a port that HAS a coded error
86+ type: the type. Java, C# and Python all require ` MetaDataException ` /
87+ ` MetaModelException ` / ` ParseError ` on this arm, so a malformed config that
88+ crashes with a raw ` NullPointerException ` fails the case rather than passing
89+ it. (TypeScript alone cannot: it propagates the raw parser error, which is the
90+ whole reason the code is unpinned.)
91+ - ** ` errorIsNative ` ** — OPTIONAL boolean, only meaningful beside
92+ ` expectError: true ` . Means the failure surfaces as a PLATFORM-native error
93+ rather than the port's coded metadata error, so the type requirement above is
94+ lifted for this case alone. Exactly one case sets it:
95+ ` a-symlink-cycle-is-an-error ` , where the raise comes from the filesystem walk
96+ (` IOException ` in C#, ` FileSystemLoopException ` in Java, ` SymlinkLoopError ` in
97+ Python) and never passes through a coded-error constructor. It exists so that
98+ admitting that one case did not silently relax the other six ` true ` cases from
99+ "a coded parse failure" to "anything at all" — which is precisely what a blanket
100+ widening of the runners does, and did.
55101
56102## Semantics pinned here
57103
@@ -115,14 +161,33 @@ README.md
115161 ` .strict() ` at the top level (` config.ts ` ) — so a key no version of
116162 TypeScript has ever declared throws a ` ZodError ` and resolution never
117163 reaches the source-listing step at all, while Java/C#/Python all resolve
118- successfully, silently ignoring it. Not added as a shared ` expectFiles `
119- case here because doing so would need EITHER loosening ` ConfigSchema ` 's
120- top-level strictness (a reference-implementation behavior change with a
121- blast radius well beyond source resolution — every ` loadConfig ` caller,
122- not just this corpus) OR asserting a ` true ` -sentinel ` expectError ` that
123- TypeScript alone would satisfy, contradicting the other three ports'
124- actual success — neither of which this corpus is positioned to decide
125- unilaterally. Left as an open, human-reviewable follow-up.
164+ successfully, silently ignoring it.
165+
166+ ** RULED: the asymmetry is INTENDED, and neither side moves.** It is not a
167+ defect that leaked; it falls out of who owns the file. ` .metaobjects/config.json `
168+ is TypeScript's, and TypeScript is the only port that models its whole
169+ vocabulary — so it is the only port that CAN tell a typo from a key a sibling
170+ owns. ` .strict() ` is what converts that knowledge into a diagnostic, and the
171+ hazard is specific and proven: a stripped ` scopes ` (for ` scope ` ) silently means
172+ "everything in scope", and a stripped ` migrate.scopee ` silently governs the whole
173+ database. Java, C# and Python know only ` schema_version ` + ` sources ` ; for them
174+ every other key is indistinguishable from a TS-owned one, so strictness there
175+ could only be imitated by embedding TypeScript's key list and keeping it in
176+ lockstep forever — at which point any port a release behind would REJECT a
177+ config a newer ` meta ` had just written. Tolerance is the only coherent behaviour
178+ for a partial reader, and strictness the only coherent behaviour for the owner.
179+
180+ So this stays OFF the shared corpus permanently — not deferred. A shared case
181+ asserts ONE outcome, and the correct outcome here differs by port BY DESIGN;
182+ adding one could only be done by making some port wrong. Each half is pinned in
183+ the port that owns it instead: TypeScript's in ` sdk/test/config.test.ts `
184+ (unknown top-level key ⇒ throws, alongside the nested ` scopes ` /` scopee ` cases),
185+ and the tolerant half in ` SourceResolverTests.cs ` ,
186+ ` tests/config/test_source_resolver.py ` and ` SourceResolverTest.java `
187+ (unknown top-level key ⇒ resolves normally). The
188+ forward-compatibility cost is real and accepted: a config written by a NEWER
189+ ` meta ` hard-fails an OLDER one. That is what ` schema_version ` is for, and a loud
190+ failure beats a ` scope ` that silently matched everything.
126191
127192## Order is deliberately NOT pinned
128193
0 commit comments