Skip to content

Commit 6223717

Browse files
dmealingclaude
andcommitted
feat(csharp): the fifth port loads the shipped library too, and the changelog stops omitting three fixes
Two gaps found by auditing my own batch rather than by anyone reporting them. **C# port parity.** My #332 commit closed with "C# has the same gap and is not addressed here", which is a follow-up in all but name. Four ports being able to load a shipped package and one not is drift, and the port that cannot is the one nobody would have noticed — C# ships no generator that consumes the library, so nothing would ever have failed loudly. That makes it less urgent than Java's gap and more likely to survive indefinitely. Same shape as Java, deliberately: `MetaObjects.Library.EmbeddedLibrary` (a generated class, not an embedded resource, so no build configuration can drop it), `LibrarySources` (on-disk-first, embedded fallback, unrecognised package contributes nothing), and `FromDirectory(dir, libraries)` overloads on both the default- and registry-aware paths with library sources prepended. `scripts/generate-embedded-library.ts` now emits all three embeds from one walk of the canonical tree — still one script, because two would be two things that can drift from a file whose whole job is to be byte-identical. Gated by the same five: positive, negative-that-names-`LlmCallBase`, unknown-package, sorted-known-packages, and the freshness comparison. The freshness gate was proven by BREAKING it, and that was worth doing here specifically — it skips per-ref when the canonical tree is unreachable, so a passing run and a vacuous run look identical. Appending a line to `library/ai/llm-call.yaml` turns it red; restored after. All five ports now resolve `metaobjects::ai::LlmCallBase`. **Three commits were missing from the 0.24.0 changelog**, found by diffing the section's issue links against the commits in the release window rather than trusting that writing the entries as I went had covered it: - #331, the agent-context `meta verify --db <url>` fix — adopter-visible, since it changes text `meta init` scaffolds into their repo; - the requirements-summary provenance fix, which is the sharpest of the three: an adopter read `76/76 entities claimed` while two of their four metadata trees were not in `sources` at all, so the seven unclaimed templates living there had never been flagged by anything; - #317, the agent-context requirements guidance. C#: 4/4 test projects green (291 + 947 + 53 + 363), 0 failures, no `error CS`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent a048c83 commit 6223717

7 files changed

Lines changed: 413 additions & 3 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -943,7 +943,14 @@ first draft of that assertion passed on the envelope. Plus `TraceHelperOnShipped
943943
which runs the generator against the **shipped** base with ADR-0024 FIX #1 asserted both
944944
directions. The freshness gate was proven by breaking it, not by its silence.
945945

946-
C# has the same gap and is not addressed here.
946+
**C#** closes the same gap in the same shape — `MetaObjects.Library.EmbeddedLibrary` (generated
947+
by the same script, now emitting three ports from one walk), `LibrarySources`, and
948+
`MetaDataLoader.FromDirectory(dir, libraries)` overloads on both the default- and
949+
registry-aware paths. Its gap was less acute than Java's, since C# ships no generator that
950+
consumes the library — but four ports being able to load a shipped package and one not is
951+
drift, and the port that cannot is the one nobody would have noticed.
952+
953+
All five ports now resolve `metaobjects::ai::LlmCallBase`.
947954

948955
### Fixed — two files, two questions: a sub-project's config governs its own codegen ([#326](https://github.com/metaobjectsdev/metaobjects/issues/326), [#327](https://github.com/metaobjectsdev/metaobjects/issues/327))
949956

@@ -992,6 +999,43 @@ counts them. An explicit positional now pins the collection (`resolveCollection`
992999
`explicitDir`); a bare `meta docs` still discovers. Both help blocks say so, since the old text
9931000
described the argument as a root without saying what it does to the source set.
9941001

1002+
### Fixed — the agent context shipped an instruction that cannot be run ([#331](https://github.com/metaobjectsdev/metaobjects/issues/331))
1003+
1004+
Every project scaffolded by `meta init` carried a cross-port command table telling agents to
1005+
run `meta verify --db`. That form takes no URL, so it always exits 2. The table now reads
1006+
`meta verify --db <url>`, and the `agent-context-conformance` expected fixtures moved with it.
1007+
1008+
Scoped to the **imperative** use only. `meta verify --db` stays bare where the surrounding
1009+
text is *referential* — naming the subverb while explaining which port owns schema drift —
1010+
because there the string is the name of a capability, not a command anyone is being told to
1011+
type. A blanket rename would have been the easier edit and the wrong one.
1012+
1013+
### Fixed — the requirements summary published the denominator it counted over
1014+
1015+
`meta verify`'s object-coverage fraction is computed over whatever LOADED, so a `sources`
1016+
declaration covering half an estate reports the covered half as fully claimed. An adopter read
1017+
**`76/76` entities claimed while two of their four metadata trees were not in `sources` at
1018+
all** — which is why the seven unclaimed templates living in those trees had never been
1019+
flagged by anything.
1020+
1021+
No check can detect a tree it was never pointed at; the missing input is invisible by
1022+
construction. So the fix is provenance rather than a new check: the summary now says
1023+
`counted over N metadata file(s)`, and a number far below what the author expects is the
1024+
signal. That converts a silently-wrong denominator into a visibly-wrong one, which is the most
1025+
the tool can honestly offer here.
1026+
1027+
It also pins the summary line itself, which had **no test at all** — not the new clause, the
1028+
whole line. It is printed on every run precisely so a gate that passes is distinguishable from
1029+
a gate that checked nothing (0.23.0), and nothing asserted it printed. That is the same shape
1030+
of hole the summary exists to close.
1031+
1032+
### Fixed — the agent context's requirements guidance was thinner than the check enforcing it ([#317](https://github.com/metaobjectsdev/metaobjects/issues/317))
1033+
1034+
The scaffolded audit never asked about `requirement.*` nodes at all, and the L5 member-grain
1035+
rule — `ERR_REQUIREMENT_L5_NOT_MEMBER` is an ERROR — was undocumented in the context an agent
1036+
actually reads. An agent following the shipped guidance could author a ledger that fails
1037+
`verify` on a rule it was never told about.
1038+
9951039
### Fixed — the private-host check asks where packages come from, not what strings a build file holds ([#334](https://github.com/metaobjectsdev/metaobjects/issues/334))
9961040

9971041
`tools/prerelease/detect-prerelease-pins.sh` flagged any RFC1918, loopback or link-local host

‎docs/features/cli.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,11 @@ consume, and its tests stayed green only by declaring a bespoke `LlmCallBase` in
199199
a different package — the bypass ADR-0024 already named, and the reason a port can ship a
200200
generator it cannot feed without anyone noticing.
201201

202+
On C# the opt-in is loader-only — `MetaDataLoader.FromDirectory(dir, new[] { "ai" })` — because
203+
the `dotnet meta` CLI has no project-config file to carry a key. All five ports resolve
204+
`metaobjects::ai::LlmCallBase`; three of them (Node `meta`, Python `metaobjects`, Maven) expose
205+
it declaratively.
206+
202207
## `meta gen` / `meta verify` run an advisory anti-pattern pass (Node `meta`)
203208

204209
Both `meta verify` and a real `meta gen` write run (not `--dry-run`) end with a

‎scripts/generate-embedded-library.ts‎

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,14 @@ const javaOutFile = join(
6565
"library",
6666
"EmbeddedLibrary.java",
6767
);
68+
const csharpOutFile = join(
69+
repoRoot,
70+
"server",
71+
"csharp",
72+
"MetaObjects",
73+
"Library",
74+
"EmbeddedLibrary.cs",
75+
);
6876

6977
function collect(dir: string): string[] {
7078
const out: string[] = [];
@@ -166,7 +174,44 @@ ${javaEntries}
166174

167175
writeFileSync(javaOutFile, javaSource, "utf-8");
168176

177+
// C#: same reasoning as Java — a generated class, not an embedded resource, so no build
178+
// configuration can drop it. C# verbatim strings would need `""` doubling and would still
179+
// re-wrap; a regular literal from `JSON.stringify` needs neither, because every escape it
180+
// emits (\" \\ \b \f \n \r \t \uXXXX) means the same thing in C# as in JSON.
181+
const csharpEntries = entries
182+
.map((e) => ` [${JSON.stringify(e.ref)}] = ${JSON.stringify(e.content)},`)
183+
.join("\n");
184+
185+
const csharpSource = `namespace MetaObjects.Library;
186+
187+
/// <summary>
188+
/// AUTO-GENERATED by scripts/generate-embedded-library.ts — DO NOT EDIT.
189+
///
190+
/// <para>Canonical source: the repo-root <c>library/</c> tree.
191+
/// Regenerate with <c>bun run scripts/generate-embedded-library.ts</c>.</para>
192+
///
193+
/// <para>Embeds the canonical library files as string constants so they resolve wherever the
194+
/// on-disk <c>library/</c> directory is unavailable — which is every consumer of the published
195+
/// NuGet package. Keys are refs: the path under <c>library/</c> minus the <c>.yaml</c>
196+
/// extension (e.g. <c>"ai/llm-call"</c>).</para>
197+
///
198+
/// <para><c>EmbeddedLibraryTests</c> gates this file byte-for-byte against the canonical tree,
199+
/// so a stale embed cannot ship.</para>
200+
/// </summary>
201+
public static class EmbeddedLibrary
202+
{
203+
/// <summary>Ref to exact file contents, insertion-ordered by ref.</summary>
204+
public static readonly IReadOnlyDictionary<string, string> Content =
205+
new Dictionary<string, string>
206+
{
207+
${csharpEntries}
208+
};
209+
}
210+
`;
211+
212+
writeFileSync(csharpOutFile, csharpSource, "utf-8");
213+
169214
console.log(
170-
`wrote ${entries.length} embedded library file(s) to ${relative(repoRoot, outFile)} ` +
171-
`and ${relative(repoRoot, javaOutFile)}`,
215+
`wrote ${entries.length} embedded library file(s) to ${relative(repoRoot, outFile)}, ` +
216+
`${relative(repoRoot, javaOutFile)} and ${relative(repoRoot, csharpOutFile)}`,
172217
);
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
using MetaObjects.Library;
2+
using MetaObjects.Loader;
3+
using MetaObjects.Meta;
4+
using Xunit;
5+
6+
namespace MetaObjects.Conformance.Tests;
7+
8+
/// <summary>
9+
/// The C# port can load the MetaObjects-shipped library packages, so
10+
/// <c>extends: "metaobjects::ai::LlmCallBase"</c> resolves — port parity with TypeScript,
11+
/// Python and Java (#332 / #333).
12+
///
13+
/// <para>The NEGATIVE arm is the half that proves the opt-in is doing the work: without it the
14+
/// same model must still fail. A positive-only test keeps passing if the library is quietly
15+
/// made unconditional, which would put its top-level nodes into the model — and the generated
16+
/// output, and the docs — of every project that never asked for one.</para>
17+
/// </summary>
18+
public class LibraryLoadTests
19+
{
20+
private const string Model = """
21+
{
22+
"metadata.root": {
23+
"package": "acme::trace",
24+
"children": [
25+
{ "object.entity": {
26+
"name": "AgentCall",
27+
"extends": "metaobjects::ai::LlmCallBase",
28+
"children": [
29+
{ "source.rdb": { "@table": "agent_call" } },
30+
{ "identity.primary": { "name": "pk", "@fields": ["spanId"] } }
31+
]
32+
} }
33+
]
34+
}
35+
}
36+
""";
37+
38+
private static string WriteModel()
39+
{
40+
var dir = Path.Combine(Path.GetTempPath(), "mo-library-load-" + Guid.NewGuid().ToString("N"));
41+
Directory.CreateDirectory(dir);
42+
File.WriteAllText(Path.Combine(dir, "trace.json"), Model);
43+
return dir;
44+
}
45+
46+
[Fact]
47+
public void LibrariesOptInMakesTheShippedBaseResolvable()
48+
{
49+
var dir = WriteModel();
50+
try
51+
{
52+
var result = MetaDataLoader.FromDirectory(dir, new[] { "ai" });
53+
54+
Assert.Empty(result.Errors);
55+
// The library's own nodes are in the model...
56+
Assert.NotNull(result.Root.Children()
57+
.FirstOrDefault(c => c.ResolutionKey() == "metaobjects::ai::LlmCallBase"));
58+
// ...and the project's entity resolves its super against them, inheriting the fields.
59+
var agentCall = result.Root.Children()
60+
.FirstOrDefault(c => c.ResolutionKey() == "acme::trace::AgentCall");
61+
Assert.NotNull(agentCall);
62+
// ADR-0039: the RESOLVING accessor — traceId is inherited, never declared here.
63+
Assert.Contains(agentCall!.Children(), c => c.Name == "traceId");
64+
}
65+
finally
66+
{
67+
Directory.Delete(dir, recursive: true);
68+
}
69+
}
70+
71+
[Fact]
72+
public void WithoutTheOptInTheSameModelDoesNotResolve()
73+
{
74+
var dir = WriteModel();
75+
try
76+
{
77+
var result = MetaDataLoader.FromDirectory(dir);
78+
79+
Assert.Null(result.Root.Children()
80+
.FirstOrDefault(c => c.ResolutionKey() == "metaobjects::ai::LlmCallBase"));
81+
// Named, not merely non-empty: a bare "it failed" assertion would pass if the model
82+
// failed for any unrelated reason, which would prove nothing about whether the
83+
// opt-in is what supplies the base.
84+
Assert.Contains(result.Errors, e => e.Message.Contains("LlmCallBase"));
85+
}
86+
finally
87+
{
88+
Directory.Delete(dir, recursive: true);
89+
}
90+
}
91+
92+
[Fact]
93+
public void AnUnknownPackageContributesNoSourcesAndIsNotAnError()
94+
{
95+
// The cross-port contract for the PROGRAMMATIC door. A caller asking for a package this
96+
// version does not ship must still be able to load its own metadata; the callers that
97+
// read the name from a human validate first.
98+
Assert.Empty(LibrarySources.Resolve(new[] { "nosuchpackage" }));
99+
Assert.Empty(LibrarySources.Resolve(null));
100+
}
101+
102+
[Fact]
103+
public void KnownPackagesNamesWhatThisBuildShips()
104+
{
105+
var known = LibrarySources.KnownPackages();
106+
Assert.Contains("ai", known);
107+
Assert.Equal(known.OrderBy(k => k, StringComparer.Ordinal).ToList(), known);
108+
}
109+
110+
/// <summary>
111+
/// The freshness gate: the embed must equal the canonical tree byte for byte.
112+
///
113+
/// <para>Skipped per-ref when the repo-root <c>library/</c> tree is unreachable — the
114+
/// published-package case, where there is nothing to compare against. In a checkout, which
115+
/// is where this test actually runs, the comparison is live.</para>
116+
/// </summary>
117+
[Fact]
118+
public void TheEmbedIsByteIdenticalToTheCanonicalTree()
119+
{
120+
foreach (var (reference, embedded) in EmbeddedLibrary.Content)
121+
{
122+
var onDisk = LibrarySources.OnDiskContent(reference);
123+
if (onDisk is null) continue; // not a checkout — nothing to compare
124+
Assert.True(onDisk == embedded,
125+
$"EmbeddedLibrary is stale for ref \"{reference}\" — "
126+
+ "run: bun run scripts/generate-embedded-library.ts");
127+
}
128+
}
129+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
namespace MetaObjects.Library;
2+
3+
/// <summary>
4+
/// AUTO-GENERATED by scripts/generate-embedded-library.ts — DO NOT EDIT.
5+
///
6+
/// <para>Canonical source: the repo-root <c>library/</c> tree.
7+
/// Regenerate with <c>bun run scripts/generate-embedded-library.ts</c>.</para>
8+
///
9+
/// <para>Embeds the canonical library files as string constants so they resolve wherever the
10+
/// on-disk <c>library/</c> directory is unavailable — which is every consumer of the published
11+
/// NuGet package. Keys are refs: the path under <c>library/</c> minus the <c>.yaml</c>
12+
/// extension (e.g. <c>"ai/llm-call"</c>).</para>
13+
///
14+
/// <para><c>EmbeddedLibraryTests</c> gates this file byte-for-byte against the canonical tree,
15+
/// so a stale embed cannot ship.</para>
16+
/// </summary>
17+
public static class EmbeddedLibrary
18+
{
19+
/// <summary>Ref to exact file contents, insertion-ordered by ref.</summary>
20+
public static readonly IReadOnlyDictionary<string, string> Content =
21+
new Dictionary<string, string>
22+
{
23+
["ai/llm-call"] = "# library/ai/llm-call.yaml\n# MetaObjects-shipped standard metadata. Adopters opt in via the loader's\n# `libraries: [\"ai\"]` option, then `extends: \"metaobjects::ai::LlmCallBase\"`.\nmetadata:\n package: metaobjects::ai\n children:\n - object.entity:\n name: LlmCallBase\n abstract: true\n children:\n - field.uuid: { name: traceId }\n - field.uuid: { name: spanId }\n - field.uuid: { name: parentSpanId }\n - field.string: { name: sessionId }\n - field.string: { name: callType }\n - field.string: { name: system }\n - field.string: { name: requestModel }\n - field.string: { name: responseModel }\n - field.int: { name: inputTokens }\n - field.int: { name: outputTokens }\n - field.currency: { name: costMinor, currency: USD }\n - field.int: { name: latencyMs }\n - field.string: { name: finishReason }\n - field.string: { name: status }\n - field.string: { name: errorDetail }\n - field.timestamp: { name: startedAt }\n - field.string: { name: llmRequest, dbColumnType: jsonb } # generic jsonb (no objectRef)\n - field.string: { name: llmResponse, dbColumnType: jsonb }\n - object.entity:\n name: LlmCall\n extends: metaobjects::ai::LlmCallBase\n children:\n - source.rdb: { table: llm_call, role: primary }\n - identity.primary: { name: id, fields: [\"spanId\"] }\n",
24+
};
25+
}

0 commit comments

Comments
 (0)