Skip to content

Commit 348030d

Browse files
dmealingclaude
andcommitted
fix(codegen): strip fully-qualified @objectref to bare name in C# + TS
Cross-port audit of the two bug patterns fixed for Python in PR #28: BUG A — `@objectRef` emitted as a RAW FQN in a generated type reference / import, where it must be the BARE short name (last `::` segment). BUG B — a naive nearest-ancestor package walk for an entity's header FQN, where it must use the canonical resolution key. Audit result across the four remaining ports: - Java (codegen-spring): both clean — SpringNaming.splitFqn() already strips refs and derives package from the load-time-qualified entity name. - Kotlin (codegen-kotlin): both clean — same JVM splitFqn() strategy. - C#: BUG A present in PayloadCodegen; BUG B clean (PackageBindingResolver.EffectivePackage already uses ResolutionKey). - TS: BUG A present in three value-object/entity emitters; BUG B clean (docs-paths.effectivePackage already uses resolutionKey()). BUG B is therefore not present in any port — every engine derives the entity package from its canonical resolution key / load-time-qualified name. Only BUG A needed fixing, in C# and TS: - C# PayloadCodegen.FieldType / BuildTree: a fully-qualified nested @objectref emitted `IReadOnlyList<acme::ai::Brief>` (invalid C#) AND the nested-record lookup (FindObject matches bare names) missed, dropping the nested record entirely. Now StripPkg() before use, matching every other C# generator. - TS valueObjectFieldType / zod insert-schema / sqlite isArray $type: a fully-qualified ref emitted an invalid identifier + a colon-laden sibling module path. Now stripPackage() before use, matching the existing string form fieldTsTypeString(). Regression tests added at each fixed site (C# PayloadCodegenTests, TS column-mapper / zod-validators / value-object-file). The cross-package behavior-corpus gate (a multi-file/multi-package @objectref roundtrip in persistence-conformance) is deferred to the multi-package conformance work per the standing FR-007 policy that codegen drift is gated through behavior corpora, not a codegen-output corpus. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 53e3507 commit 348030d

8 files changed

Lines changed: 101 additions & 9 deletions

File tree

server/csharp/MetaObjects.Codegen.Tests/PayloadCodegenTests.cs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,38 @@ public void Emits_payload_record_with_scalar_and_nested_array_fields()
5555
Assert.Contains("public required string title { get; init; }", src);
5656
}
5757

58+
// Same shape as Model, but the nested @objectRef is authored FULLY-QUALIFIED
59+
// (acme::ai::PostBrief) — the form a cross-package reference takes. The payload
60+
// record type + the nested-record lookup must resolve to the BARE short name.
61+
private const string FqnRefModel = """
62+
{
63+
"metadata.root": {
64+
"package": "acme::ai",
65+
"children": [
66+
{ "object.value": { "name": "PostBrief", "children": [
67+
{ "field.string": { "name": "title" } }
68+
]}},
69+
{ "object.value": { "name": "AuthorBrief", "children": [
70+
{ "field.string": { "name": "displayName" } },
71+
{ "field.object": { "name": "posts", "isArray": true, "@objectRef": "acme::ai::PostBrief",
72+
"children": [ { "origin.collection": { "@via": "Author.posts" } } ] } }
73+
]}}
74+
]
75+
}
76+
}
77+
""";
78+
79+
[Fact]
80+
public void Fully_qualified_objectRef_strips_to_bare_record_type_and_resolves_nested()
81+
{
82+
var root = new MetaDataLoader().Load([new InMemoryStringSource(FqnRefModel, id: "fqn.json")]).Root;
83+
var src = PayloadCodegen.GeneratePayloadRecords(root, "AuthorBrief");
84+
// Regression: the FQN must NOT leak into the generated C# type or record name.
85+
Assert.Contains("public required IReadOnlyList<PostBrief> posts { get; init; }", src);
86+
Assert.Contains("public sealed record PostBrief", src); // nested record DID resolve (FindObject matched the bare name)
87+
Assert.DoesNotContain("acme::ai::", src);
88+
}
89+
5890
[Fact]
5991
public void Emits_render_handle_binding_textRef_and_format()
6092
{

server/csharp/MetaObjects.Codegen/PayloadCodegen.cs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,11 @@ private static (string Type, string? RefVo) FieldType(MetaData owner, MetaData f
5252
if (field.SubType == FIELD_SUBTYPE_OBJECT)
5353
{
5454
var refAttr = field.OwnAttr(FIELD_ATTR_OBJECT_REF);
55-
string refName = refAttr is string s ? s : "object";
56-
string? refVo = refAttr is string r ? r : null;
55+
// @objectRef may be authored fully-qualified (acme::sales::Brief) or bare;
56+
// the generated record type + the nested-record lookup key are the BARE
57+
// short name (FindObject matches bare names — every other caller strips too).
58+
string refName = refAttr is string s ? CSharpNaming.StripPkg(s) : "object";
59+
string? refVo = refAttr is string r ? CSharpNaming.StripPkg(r) : null;
5760
return (IsArrayField(field) ? $"IReadOnlyList<{refName}>" : refName, refVo);
5861
}
5962
// Enum payload field -> the generated nested C# enum type (same scheme entity codegen uses
@@ -155,7 +158,7 @@ private static IReadOnlyList<PayloadField> BuildTree(MetaData root, string voNam
155158
foreach (var f in vo.Children().Where(c => c.Type == TYPE_FIELD))
156159
{
157160
if (f.SubType == FIELD_SUBTYPE_OBJECT && f.OwnAttr(FIELD_ATTR_OBJECT_REF) is string refName)
158-
fields.Add(new PayloadField(f.Name, BuildTree(root, refName, visiting)));
161+
fields.Add(new PayloadField(f.Name, BuildTree(root, CSharpNaming.StripPkg(refName), visiting)));
159162
else
160163
fields.Add(new PayloadField(f.Name));
161164
}

server/typescript/packages/codegen-ts/src/column-mapper.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ import {
3636
} from "@metaobjectsdev/metadata";
3737
import { columnNameFromField } from "./naming.js";
3838
import { enumValues } from "./enum-meta.js";
39-
import { DEFAULT_COLUMN_NAMING_STRATEGY } from "@metaobjectsdev/metadata";
39+
import { DEFAULT_COLUMN_NAMING_STRATEGY, stripPackage } from "@metaobjectsdev/metadata";
4040
import type { Dialect, ColumnNamingStrategy } from "./metaobjects-config.js";
4141

4242
export type { Dialect };
@@ -428,7 +428,10 @@ export function mapColumnType(
428428
if (subType === FIELD_SUBTYPE_OBJECT) {
429429
const ref = field.ownAttr(FIELD_ATTR_OBJECT_REF);
430430
if (typeof ref === "string" && ref.length > 0) {
431-
dollarTypeRef = { kind: "objectRef", name: ref, module: `./${ref}.js` };
431+
// @objectRef may be authored fully-qualified or bare — the $type<E[]>()
432+
// target interface + its sibling module use the BARE short name.
433+
const base = stripPackage(ref);
434+
dollarTypeRef = { kind: "objectRef", name: base, module: `./${base}.js` };
432435
}
433436
} else {
434437
const scalar = sqliteJsonArrayElementTsType(subType);

server/typescript/packages/codegen-ts/src/templates/inferred-types.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,10 @@ function valueObjectFieldType(entity: MetaObject, field: MetaField, ctx?: Render
207207
if (field.subType === FIELD_SUBTYPE_OBJECT) {
208208
const ref = field.ownAttr(FIELD_ATTR_OBJECT_REF);
209209
if (typeof ref === "string" && ref.length > 0) {
210-
const refImp = imp(`${ref}@./${ref}.js`);
210+
// @objectRef may be authored fully-qualified (acme::sales::Brief) or bare; the
211+
// referenced interface + its sibling module are named by the BARE short name.
212+
const base = stripPackage(ref);
213+
const refImp = imp(`${base}@./${base}.js`);
211214
return field.isArray ? code`${refImp}[]` : code`${refImp}`;
212215
}
213216
return field.isArray ? code`unknown[]` : code`unknown`;

server/typescript/packages/codegen-ts/src/templates/zod-validators.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
// downstream (e.g. to LLM tool_use input_schema) lost the nested object shape.
1111

1212
import { code, joinCode, imp, type Code } from "ts-poet";
13-
import { MetaObject, MetaField } from "@metaobjectsdev/metadata";
13+
import { MetaObject, MetaField, stripPackage } from "@metaobjectsdev/metadata";
1414
import {
1515
FIELD_SUBTYPE_STRING, FIELD_SUBTYPE_INT, FIELD_SUBTYPE_LONG, FIELD_SUBTYPE_CURRENCY,
1616
FIELD_SUBTYPE_BOOLEAN, FIELD_SUBTYPE_DOUBLE, FIELD_SUBTYPE_FLOAT,
@@ -328,7 +328,10 @@ function zodFieldExpr(field: MetaField, owner?: MetaObject, ctx?: RenderContext)
328328
if (field.subType === FIELD_SUBTYPE_OBJECT) {
329329
const ref = field.ownAttr(FIELD_ATTR_OBJECT_REF);
330330
if (typeof ref === "string" && ref.length > 0) {
331-
const refImp = imp(`${ref}InsertSchema@./${ref}.js`);
331+
// @objectRef may be authored fully-qualified or bare — the referenced
332+
// <Ref>InsertSchema + its sibling module use the BARE short name.
333+
const refBase = stripPackage(ref);
334+
const refImp = imp(`${refBase}InsertSchema@./${refBase}.js`);
332335
let base: Code = code`${refImp}`;
333336
if (field.isArray) base = code`z.array(${base})`;
334337
return appendValidatorChain(base, field);

server/typescript/packages/codegen-ts/test/column-mapper.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,17 @@ describe("mapColumnType — SQLite", () => {
8888
expect(spec.dollarTypeRef).toEqual({ kind: "objectRef", name: "SourceLens", module: "./SourceLens.js" });
8989
});
9090

91+
test("@isArray field.object with a FULLY-QUALIFIED @objectRef strips to the bare name", () => {
92+
// Regression: a cross-package @objectRef (acme::ai::SourceLens) must resolve to
93+
// the BARE short name for both the $type<E[]>() target and its sibling module —
94+
// emitting the raw FQN produces an invalid identifier + a colon-laden import path.
95+
const f = metaField(FIELD_SUBTYPE_OBJECT, "citations");
96+
f.setAttr("objectRef", "acme::ai::SourceLens");
97+
f.setIsArray(true);
98+
const spec = mapColumnType(f, "sqlite");
99+
expect(spec.dollarTypeRef).toEqual({ kind: "objectRef", name: "SourceLens", module: "./SourceLens.js" });
100+
});
101+
91102
test("@isArray on field.object without objectRef leaves dollarTypeRef unset", () => {
92103
const f = metaField(FIELD_SUBTYPE_OBJECT, "stuff");
93104
f.setIsArray(true);

server/typescript/packages/codegen-ts/test/templates/value-object-file.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,23 @@ describe("renderValueObjectFile", () => {
6767
expect(out).not.toContain("UpdateSchema");
6868
});
6969

70+
test("field.object with a FULLY-QUALIFIED @objectRef strips to the bare type + sibling module", () => {
71+
// Regression: a cross-package @objectRef (acme::ai::SourceLens) must resolve to
72+
// the BARE short name in BOTH the emitted interface field type and the
73+
// <Ref>InsertSchema reference — the raw FQN produces an invalid TS identifier
74+
// (`acme::ai::SourceLens`) and a colon-laden import path.
75+
const wo = metaObject(OBJECT_SUBTYPE_VALUE, "Brief");
76+
const citations = metaField(FIELD_SUBTYPE_OBJECT, "citations");
77+
citations.setAttr("objectRef", "acme::ai::SourceLens");
78+
citations.isArray = true;
79+
wo.addChild(citations);
80+
81+
const out = renderValueObjectFile(wo);
82+
expect(out).toMatch(/citations\?:\s*SourceLens\[\];/);
83+
expect(out).toContain("SourceLensInsertSchema");
84+
expect(out).not.toContain("acme::ai::");
85+
});
86+
7087
test("required-field-only value object emits no question marks", () => {
7188
const sl = metaObject(OBJECT_SUBTYPE_VALUE, "SourceLens");
7289
for (const name of ["title", "url", "snippet"]) {

server/typescript/packages/codegen-ts/test/templates/zod-validators.test.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, test, expect } from "bun:test";
22
import { TypeId, TYPE_IDENTITY, TYPE_VALIDATOR,
3-
FIELD_SUBTYPE_LONG, FIELD_SUBTYPE_STRING, FIELD_SUBTYPE_ENUM,
3+
FIELD_SUBTYPE_LONG, FIELD_SUBTYPE_STRING, FIELD_SUBTYPE_ENUM, FIELD_SUBTYPE_OBJECT,
44
IDENTITY_SUBTYPE_PRIMARY, OBJECT_SUBTYPE_ENTITY,
55
VALIDATOR_SUBTYPE_REGEX,
66
MetaDataLoader, InMemoryStringSource } from "@metaobjectsdev/metadata";
@@ -51,6 +51,26 @@ describe("renderZodValidators", () => {
5151
expect(out).not.toContain("z.string()"); // no fallback string type for enum field
5252
});
5353

54+
test("field.object with a FULLY-QUALIFIED @objectRef references <Bare>InsertSchema, not the FQN", () => {
55+
// Regression: a cross-package @objectRef (acme::ai::SourceLens) must resolve to
56+
// the BARE short name for the <Ref>InsertSchema symbol + its sibling module —
57+
// the raw FQN emits an invalid `acme::ai::SourceLensInsertSchema` identifier.
58+
const brief = metaObject(OBJECT_SUBTYPE_ENTITY, "Brief");
59+
const id = metaField(FIELD_SUBTYPE_LONG, "id");
60+
brief.addChild(id);
61+
const lens = metaField(FIELD_SUBTYPE_OBJECT, "lens");
62+
lens.setAttr("objectRef", "acme::ai::SourceLens");
63+
brief.addChild(lens);
64+
const primary = meta(new TypeId(TYPE_IDENTITY, IDENTITY_SUBTYPE_PRIMARY), "primary");
65+
primary.setAttr("fields", ["id"]);
66+
primary.setAttr("generation", "increment");
67+
brief.addChild(primary);
68+
69+
const out = renderZodValidators(brief).toString();
70+
expect(out).toContain("SourceLensInsertSchema");
71+
expect(out).not.toContain("acme::ai::");
72+
});
73+
5474
test("validator.regex emits .regex(new RegExp(pattern)) — Zod expects a RegExp object, not a string", () => {
5575
const post = metaObject(OBJECT_SUBTYPE_ENTITY, "Post");
5676
const slug = metaField(FIELD_SUBTYPE_STRING, "slug");

0 commit comments

Comments
 (0)