Skip to content

Commit 7142f7f

Browse files
authored
Merge pull request #316 from metaobjectsdev/fix/cross-port-source-resolution-review
fix: close 15 findings from the cross-port source-resolution review
2 parents 485cc8a + bebc6e4 commit 7142f7f

38 files changed

Lines changed: 1042 additions & 96 deletions

File tree

fixtures/source-resolution-conformance/README.md

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -93,9 +93,36 @@ README.md
9393
loop) reports whichever error comes first in declaration order instead,
9494
diverging on exactly one of the two cases depending on which order it
9595
happens to process first.
96-
- **Unknown top-level config keys are IGNORED.** The file carries
97-
TypeScript-owned keys no other port models. `schema_version` and `sources` are
98-
the neutral subset; each port validates those strictly and ignores the rest.
96+
- **A TypeScript-owned top-level key does not affect source resolution in any
97+
port.** `schema_version` and `sources` are the neutral subset every port
98+
models; `pending_in_git` / `confidence_thresholds` / `extract` / `migrate`
99+
are TypeScript's own, and `typescript-owned-top-level-keys-do-not-affect-
100+
source-resolution` pins that their presence resolves the same file set
101+
everywhere. Read that case name literally — it is narrower than "unknown
102+
keys are ignored" on purpose. Those four keys are UNKNOWN to Java/C#/Python
103+
(which ignore any key outside `schema_version`/`sources`, by design) but
104+
KNOWN to TypeScript's own `ConfigSchema` (`sdk/src/config.ts`), which
105+
recognizes and validates them as part of its own project state. A case
106+
built only from keys TS recognizes cannot tell "TS ignored this because it
107+
doesn't affect resolution" apart from "TS ignored this because it doesn't
108+
affect resolution AND happened to also validate it" — the two are
109+
indistinguishable from the outside, and only the first is what every other
110+
port's "ignore the rest" behavior demonstrates.
111+
**A genuinely unrecognized key (e.g. `"foo": 1`, unknown to all four ports)
112+
is a real, confirmed, cross-port DIVERGENCE, not covered by this corpus.**
113+
Verified empirically: `resolveCollection` (`collection.ts`) calls
114+
`loadConfig`, which parses the WHOLE file through `ConfigSchema.parse`
115+
`.strict()` at the top level (`config.ts`) — so a key no version of
116+
TypeScript has ever declared throws a `ZodError` and resolution never
117+
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.
99126

100127
## Order is deliberately NOT pinned
101128

fixtures/source-resolution-conformance/cases.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@
178178
"expectError": "ERR_COLLECTION_NOT_FOUND"
179179
},
180180
{
181-
"name": "unknown-top-level-keys-are-ignored",
181+
"name": "typescript-owned-top-level-keys-do-not-affect-source-resolution",
182182
"tree": {
183183
"model/meta.a.json": "{\"metadata.root\":{\"children\":[]}}"
184184
},

server/csharp/MetaObjects.Cli.Tests/MetadataDirFallbackTests.cs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,45 @@ public void Gen_with_no_positional_metadataDir_and_a_single_FILE_source_refuses_
131131
Assert.False(Directory.Exists(outDir));
132132
}
133133

134+
[Fact]
135+
public void Gen_with_no_positional_metadataDir_excludes_pending_drafts()
136+
{
137+
// F5 — the ladder path used to discard SourceResolver.ResolveSources's own
138+
// return value (the `_pending`-excluded file list) and hand
139+
// MetaDataLoader.FromDirectory a bare directory instead, whose default
140+
// DirectorySource.Options has ExcludePending = false — so a `_pending/`
141+
// draft that TS/Java/Python all keep invisible to codegen leaked into the
142+
// generated output here. `_pending/` is excluded at ANY depth under the
143+
// declared source, matching the other three ports.
144+
var modelDir = Path.Combine(_tmp, "model");
145+
Directory.CreateDirectory(modelDir);
146+
File.WriteAllText(Path.Combine(modelDir, "meta.acme.json"), Metadata);
147+
var pendingDir = Path.Combine(modelDir, "_pending");
148+
Directory.CreateDirectory(pendingDir);
149+
File.WriteAllText(Path.Combine(pendingDir, "meta.draft.json"), """
150+
{ "metadata.root": { "package": "acme", "children": [
151+
{ "object.entity": { "name": "DraftWidget", "children": [
152+
{ "source.rdb": { "@table": "draft_widgets" } },
153+
{ "field.long": { "name": "id" } },
154+
{ "identity.primary": { "@fields": "id" } }
155+
]}}
156+
]}}
157+
""");
158+
var cfgDir = Path.Combine(_tmp, ".metaobjects");
159+
Directory.CreateDirectory(cfgDir);
160+
File.WriteAllText(
161+
Path.Combine(cfgDir, "config.json"),
162+
"""{ "schema_version": 1, "sources": [ { "path": "model" } ] }""");
163+
164+
var outDir = Path.Combine(_tmp, "generated");
165+
var (exitCode, stdout, stderr) = RunCli(_tmp, "gen", "--out", outDir, "--namespace", "Acme.Generated");
166+
167+
Assert.True(exitCode == 0, $"exit={exitCode}\nstdout={stdout}\nstderr={stderr}");
168+
Assert.True(File.Exists(Path.Combine(outDir, "Subscriber.g.cs")), stdout + stderr);
169+
Assert.False(File.Exists(Path.Combine(outDir, "DraftWidget.g.cs")),
170+
"a _pending/ draft must never reach generated output: " + stdout + stderr);
171+
}
172+
134173
[Fact]
135174
public void Gen_with_an_explicit_positional_metadataDir_is_unaffected()
136175
{

server/csharp/MetaObjects.Cli/DocsCommand.cs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,20 @@ public sealed record Outcome(
4141
public static Outcome Run(
4242
string metadataDir, string outDir, string project, string ns,
4343
string apiSubDir = DefaultApiSubDir, string? modelBaseUrl = null)
44+
=> Run(MetaDataLoader.FromDirectory(metadataDir), outDir, project, ns, apiSubDir, modelBaseUrl);
45+
46+
/// <summary>
47+
/// Same as the <c>metadataDir</c> overload above, but starting from an
48+
/// ALREADY-LOADED <paramref name="load"/> — see the identical overload on
49+
/// <see cref="GenCommand"/> for why (the CLI's config-ladder path resolves +
50+
/// loads once via <c>MetaDataLoader.FromUris</c>, correctly excluding
51+
/// <c>_pending</c> drafts; a second <c>FromDirectory</c> call here would both
52+
/// re-walk the tree and silently lose that exclusion).
53+
/// </summary>
54+
public static Outcome Run(
55+
LoadResult load, string outDir, string project, string ns,
56+
string apiSubDir = DefaultApiSubDir, string? modelBaseUrl = null)
4457
{
45-
var load = MetaDataLoader.FromDirectory(metadataDir);
4658
var loadErrors = load.Errors.Select(e => e.Code.ToString()).ToList();
4759
if (loadErrors.Count > 0)
4860
return new Outcome(loadErrors, []);

server/csharp/MetaObjects.Cli/GenCommand.cs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,24 @@ public static Outcome Run(string metadataDir, string outDir, string ns, bool emi
6868
public static Outcome Run(
6969
string metadataDir, string outDir, string ns, bool emitAbstractShapes,
7070
IReadOnlyList<string>? generatorNames, string? templateRoot, string? templateSpecPath = null)
71+
=> Run(MetaDataLoader.FromDirectory(metadataDir), outDir, ns, emitAbstractShapes,
72+
generatorNames, templateRoot, templateSpecPath);
73+
74+
/// <summary>
75+
/// Same as the <c>metadataDir</c> overload above, but starting from an
76+
/// ALREADY-LOADED <paramref name="load"/> — used by the CLI's
77+
/// <c>.metaobjects/config.json</c> ladder path (<c>Program.cs</c>'s
78+
/// <c>ResolveMetadataDirOrExit</c>), which resolves AND loads the declared
79+
/// source set itself via <see cref="MetaDataLoader.FromUris(System.Collections.Generic.IReadOnlyList{Uri})"/>
80+
/// (honoring the <c>_pending</c>-draft exclusion <c>SourceResolver</c> applies).
81+
/// Calling <see cref="MetaDataLoader.FromDirectory(string, DirectorySource.Options?, bool)"/>
82+
/// again here would re-walk the directory tree a second time AND silently lose
83+
/// that exclusion (<c>FromDirectory</c>'s own default is to include <c>_pending</c>).
84+
/// </summary>
85+
public static Outcome Run(
86+
LoadResult load, string outDir, string ns, bool emitAbstractShapes,
87+
IReadOnlyList<string>? generatorNames, string? templateRoot, string? templateSpecPath = null)
7188
{
72-
var load = MetaDataLoader.FromDirectory(metadataDir);
7389
var loadErrors = load.Errors.Select(e => e.Code.ToString()).ToList();
7490
if (loadErrors.Count > 0)
7591
return new Outcome(loadErrors, null);

server/csharp/MetaObjects.Cli/Program.cs

Lines changed: 61 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ static int RunGen(string[] rest)
8888

8989
// Rung 1 (explicit positional) is honored as-is; an omitted metadataDir
9090
// falls back to the port-neutral .metaobjects/config.json ladder.
91-
metadataDir = ResolveMetadataDirOrExit(metadataDir);
91+
var resolvedMeta = ResolveMetadataDirOrExit(metadataDir);
9292

9393
// Advisory: nudge a re-scaffold if the copied-in agent context predates this build.
9494
// Never throws, never changes the exit code (a missing/corrupt manifest is ignored).
@@ -97,7 +97,16 @@ static int RunGen(string[] rest)
9797
var generatorNames = generatorsCsv
9898
?.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
9999

100-
var outcome = GenCommand.Run(metadataDir, outDir, ns, emitAbstractShapes, generatorNames, templateRoot, templateSpecPath);
100+
// A ladder-resolved (non-null Files) source loads via the already-resolved,
101+
// `_pending`-excluded file list (MetaDataLoader.FromUris) — never a second
102+
// FromDirectory walk of resolvedMeta.Directory, which would both duplicate
103+
// the walk ResolveMetadataDirOrExit already did AND silently include `_pending`.
104+
var outcome = resolvedMeta.Files is { } files
105+
? GenCommand.Run(
106+
MetaObjects.Loader.MetaDataLoader.FromUris(files.Select(f => new Uri(f)).ToList()),
107+
outDir, ns, emitAbstractShapes, generatorNames, templateRoot, templateSpecPath)
108+
: GenCommand.Run(
109+
resolvedMeta.Directory, outDir, ns, emitAbstractShapes, generatorNames, templateRoot, templateSpecPath);
101110
if (!outcome.Ok)
102111
{
103112
foreach (var e in outcome.LoadErrors) Console.Error.WriteLine($" load error: {e}");
@@ -141,13 +150,20 @@ static int RunDocs(string[] rest)
141150

142151
// Rung 1 (explicit positional) is honored as-is; an omitted metadataDir
143152
// falls back to the port-neutral .metaobjects/config.json ladder.
144-
metadataDir = ResolveMetadataDirOrExit(metadataDir);
153+
var resolvedMeta = ResolveMetadataDirOrExit(metadataDir);
145154

146155
// Default the project label to the input directory's leaf name (cosmetic — surfaces
147156
// in the AGENT-API header). Trailing-separator-safe.
148-
project ??= new DirectoryInfo(Path.TrimEndingDirectorySeparator(Path.GetFullPath(metadataDir))).Name;
149-
150-
var outcome = DocsCommand.Run(metadataDir, outDir, project, ns, modelBaseUrl: modelBaseUrl);
157+
project ??= new DirectoryInfo(Path.TrimEndingDirectorySeparator(Path.GetFullPath(resolvedMeta.Directory))).Name;
158+
159+
// See the identical comment in RunGen above: a ladder-resolved source loads
160+
// via its already-resolved, `_pending`-excluded file list, never a second
161+
// (unfiltered) directory walk.
162+
var outcome = resolvedMeta.Files is { } files
163+
? DocsCommand.Run(
164+
MetaObjects.Loader.MetaDataLoader.FromUris(files.Select(f => new Uri(f)).ToList()),
165+
outDir, project, ns, modelBaseUrl: modelBaseUrl)
166+
: DocsCommand.Run(resolvedMeta.Directory, outDir, project, ns, modelBaseUrl: modelBaseUrl);
151167
if (!outcome.Ok)
152168
{
153169
foreach (var e in outcome.LoadErrors) Console.Error.WriteLine($" load error: {e}");
@@ -174,13 +190,27 @@ static int RunDocs(string[] rest)
174190
// docs, verify) so an omitted positional argument is never a hard requirement
175191
// wherever a project's config can name the location instead.
176192
//
177-
// Never returns null: either hands back a real directory, or prints a
178-
// diagnostic and terminates the process — callers may treat the result as
179-
// always-present and keep their existing (now-unreachable-when-omitted)
180-
// null checks for the OTHER positional/option they still require.
181-
static string ResolveMetadataDirOrExit(string? metadataDir)
193+
// The metadata-location ladder's result: always a directory (explicit-arg
194+
// back-compat, and cosmetic labeling even on the ladder path), and — when
195+
// resolution went through the .metaobjects/config.json ladder rather than an
196+
// explicit CLI argument — the ladder's OWN already-resolved, `_pending`-draft-
197+
// excluded file list too. A caller with a non-null Files must load via
198+
// MetaDataLoader.FromUris(Files) rather than FromDirectory(Directory): the
199+
// latter would both re-walk a tree this function already walked once (via
200+
// SourceResolver) AND silently lose the `_pending` exclusion, since
201+
// DirectorySource.Options.ExcludePending defaults to false at the loader
202+
// level (SourceResolver is the one place that turns it on). Declared at file
203+
// scope below the entry point (top-level-statement files require type
204+
// declarations to follow every top-level statement / local function).
205+
206+
// Never exits without a usable result: either hands back a real directory
207+
// (+ file list, when ladder-resolved), or prints a diagnostic and terminates
208+
// the process — callers may treat the result as always-present and keep
209+
// their existing (now-unreachable-when-omitted) null checks for the OTHER
210+
// positional/option they still require.
211+
static ResolvedMetadata ResolveMetadataDirOrExit(string? metadataDir)
182212
{
183-
if (metadataDir is not null) return metadataDir;
213+
if (metadataDir is not null) return new ResolvedMetadata(metadataDir, null);
184214

185215
var cwd = Directory.GetCurrentDirectory();
186216
try
@@ -190,11 +220,13 @@ static string ResolveMetadataDirOrExit(string? metadataDir)
190220

191221
if (specs.Count == 0)
192222
{
193-
// No declared sources — validate + apply the DEFAULT directory through
223+
// No declared sources — resolve + apply the DEFAULT directory through
194224
// the same ladder the shared conformance corpus gates (raises
195-
// ERR_COLLECTION_NOT_FOUND when the default is also absent).
196-
_ = MetaObjects.Config.SourceResolver.ResolveCollection(cwd);
197-
return Path.Combine(cwd, MetaObjects.Config.NeutralConfig.DefaultMetadataDir);
225+
// ERR_COLLECTION_NOT_FOUND when the default is also absent). The
226+
// returned file list IS the load — no second walk needed.
227+
var defaultFiles = MetaObjects.Config.SourceResolver.ResolveCollection(cwd);
228+
return new ResolvedMetadata(
229+
Path.Combine(cwd, MetaObjects.Config.NeutralConfig.DefaultMetadataDir), defaultFiles);
198230
}
199231

200232
if (specs.Count > 1)
@@ -213,9 +245,9 @@ static string ResolveMetadataDirOrExit(string? metadataDir)
213245

214246
// Exactly one declared source. Resolve + validate it through the same
215247
// kind/existence checks ResolveSources applies (ERR_SOURCE_KIND_UNSUPPORTED /
216-
// ERR_SOURCE_UNRESOLVED), then hand the loader that spec's OWN root — never
217-
// the default directory name, which this project may not even have.
218-
MetaObjects.Config.SourceResolver.ResolveSources(cwd, specs);
248+
// ERR_SOURCE_UNRESOLVED) — its return value IS the (already `_pending`-
249+
// excluded) file list to load, not just a validation signal to discard.
250+
var files = MetaObjects.Config.SourceResolver.ResolveSources(cwd, specs);
219251
var rawPath = specs[0]["path"]; // guaranteed present: ResolveSources above
220252
// would already have thrown otherwise.
221253
var resolved = Path.IsPathRooted(rawPath) ? rawPath : Path.GetFullPath(Path.Combine(cwd, rawPath));
@@ -235,7 +267,7 @@ static string ResolveMetadataDirOrExit(string? metadataDir)
235267
throw new InvalidOperationException("unreachable");
236268
}
237269

238-
return resolved;
270+
return new ResolvedMetadata(resolved, files);
239271
}
240272
catch (MetaObjects.MetaModelException e)
241273
{
@@ -306,7 +338,7 @@ static int RunVerify(string[] rest)
306338

307339
// Rung 1 (explicit positional) is honored as-is; an omitted metadataDir
308340
// falls back to the port-neutral .metaobjects/config.json ladder.
309-
metadataDir = ResolveMetadataDirOrExit(metadataDir);
341+
var resolvedMeta = ResolveMetadataDirOrExit(metadataDir);
310342

311343
// The templates gate needs a root. Bare verify (defaults to templates) and an
312344
// explicit --templates both require it; surface a clear usage error if absent.
@@ -328,7 +360,11 @@ static int RunVerify(string[] rest)
328360

329361
var opts = new VerifyCommand.Options
330362
{
331-
MetadataDir = metadataDir,
363+
MetadataDir = resolvedMeta.Directory,
364+
// A ladder-resolved source loads via this already-resolved,
365+
// `_pending`-excluded file list (see VerifyCommand.LoadMetadata) — never a
366+
// second (unfiltered) directory walk of MetadataDir.
367+
MetadataFiles = resolvedMeta.Files,
332368
TemplatesRoot = templatesRoot,
333369
OutDir = outDir,
334370
Namespace = ns,
@@ -390,3 +426,6 @@ static int RunVerify(string[] rest)
390426

391427
return result.ExitCode;
392428
}
429+
430+
// See the doc comment on ResolveMetadataDirOrExit above.
431+
readonly record struct ResolvedMetadata(string Directory, IReadOnlyList<string>? Files);

0 commit comments

Comments
 (0)