Skip to content

Commit 38f4e38

Browse files
dmealingclaude
andcommitted
fix(metadata-ts): resolve cross-PACKAGE cross-file extends by parse-time file-default package
Super-resolution previously matched a no-own-package node by an effective package derived from the post-merge tree walk. When files declared DIFFERENT file-default packages, a base node (e.g. acme::common::BaseTenantEntity) was no longer matchable as its file-default-qualified name after merge into the shared root — so a concrete-first cross-package extends failed with "the SuperClass ... does not exist". Mirror Java's BaseMetaDataParser: capture each node's file-default package (the file's top-level metadata.package) at PARSE time as a resolution-only field (MetaData.fileDefaultPackage), expose MetaData.resolutionKey() (<pkg>::<name>), and match against it in findInTree. Resolution is now independent of load order and merge context. Object fqn() stays BARE (FR5d cross-port referrer-envelope contract unchanged). Adds permanent cross-PACKAGE regression tests (both load orders), genericized to acme::common / acme::lms. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent aa89d74 commit 38f4e38

4 files changed

Lines changed: 309 additions & 15 deletions

File tree

server/typescript/packages/metadata/src/parser-core.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -489,6 +489,17 @@ function parseNodeFresh(
489489
// is the only caller during loading; freeze runs in the loader after).
490490
populateNodeSource(model);
491491

492+
// --- Capture the file-default package at PARSE time (resolution-only) ---
493+
// `inheritedContextPkg` is the package threaded down from the file's root
494+
// `metadata.package` (the nearest packaged ancestor). Recording it here —
495+
// when the declaring file's package is known — lets super-resolution match
496+
// this node by its effective qualified key regardless of post-merge tree
497+
// shape or load order. This does NOT touch the node's `package` or `fqn()`
498+
// (objects stay bare per FR5d). Mirrors Java's parse-time getDefaultPackageName().
499+
if (inheritedContextPkg !== "") {
500+
model.setFileDefaultPackage(inheritedContextPkg);
501+
}
502+
492503
// --- Apply reserved keys (package, extends, abstract, isArray) ---
493504
applyReservedKeys(model, nodeData, strict, source, path, warnings, inheritedContextPkg);
494505

server/typescript/packages/metadata/src/shared/meta-data.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,16 @@ export abstract class MetaData {
2424

2525
// Identity / packaging
2626
package?: string;
27+
// Resolution-only: the file-default package (the top-level `metadata.package`)
28+
// of the FILE in which this node was declared, captured at PARSE time. This is
29+
// NOT folded into fqn() (object fqn() stays BARE for the FR5d cross-port
30+
// referrer-envelope contract) — it exists solely so super-resolution can match
31+
// a node by its EFFECTIVE qualified key `<fileDefaultPackage>::<name>` even
32+
// when the node carries no own `package`. Capturing it at parse time (when the
33+
// declaring file's package is known) makes resolution independent of load
34+
// order and post-merge tree shape. Mirrors Java's BaseMetaDataParser, which
35+
// folds getDefaultPackageName() into the registered name at parse time.
36+
fileDefaultPackage?: string;
2737
superRef?: string; // raw super reference string, pre-resolution
2838
private _superData?: MetaData; // post-resolution pointer; set by setSuperResolved() after parsing
2939
isAbstract: boolean = false;
@@ -132,6 +142,31 @@ export abstract class MetaData {
132142
this.package = pkg;
133143
}
134144

145+
/**
146+
* Loader-internal: record the file-default package captured at parse time.
147+
* See the `fileDefaultPackage` field doc. Honors the frozen-guard.
148+
* @internal
149+
*/
150+
setFileDefaultPackage(pkg: string): void {
151+
this._assertNotFrozen();
152+
this.fileDefaultPackage = pkg;
153+
}
154+
155+
/**
156+
* The node's EFFECTIVE qualified key for super-resolution matching:
157+
* `<pkg>::<name>` where pkg is the node's own package if set, else the
158+
* file-default package captured at parse time. Returns the bare name when
159+
* neither is available. Used by super-resolution ONLY — distinct from fqn(),
160+
* which stays bare for objects per the FR5d cross-port contract.
161+
*/
162+
resolutionKey(): string {
163+
const pkg = this.package ?? this.fileDefaultPackage;
164+
if (pkg !== undefined && pkg !== "") {
165+
return `${pkg}${PACKAGE_SEPARATOR}${this.name}`;
166+
}
167+
return this.name;
168+
}
169+
135170
// ---------------------------------------------------------------------------
136171
// Super
137172
// ---------------------------------------------------------------------------

server/typescript/packages/metadata/src/super-resolve.ts

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,28 @@ import { PACKAGE_SEPARATOR, PACKAGE_PARENT } from "./shared/structural.js";
1919
// ---------------------------------------------------------------------------
2020

2121
/**
22-
* Recursively searches the tree for a node whose fqn() matches.
23-
* Returns the first match, or undefined.
22+
* Recursively searches the tree for a node whose qualified name matches `fqn`.
23+
*
24+
* A node is matched by its own `fqn()` (covers the explicit-package and
25+
* no-package-at-all cases) OR by its EFFECTIVE qualified key
26+
* `<fileDefaultPackage>::<name>` (`MetaData.resolutionKey()`) — the
27+
* file-default package captured at PARSE time. This mirrors Java, where
28+
* `BaseMetaDataParser` folds the file-default package into the registered name
29+
* at parse time (so an object `BaseEntity` declared under `package: acme` is
30+
* registered as `acme::BaseEntity` and a fully-qualified `extends:
31+
* acme::BaseEntity` resolves) — even though TS keeps object `fqn()` bare for
32+
* the FR5d referrer-envelope cross-port contract.
33+
*
34+
* Because the resolution key is captured at parse time (not derived from the
35+
* post-merge tree), this is independent of load order AND of whether the base
36+
* node's file-default package differs from the referrer's — the cross-PACKAGE
37+
* cross-file case. No package is threaded down the walk anymore.
2438
*/
2539
function findInTree(root: MetaData, fqn: string): MetaData | undefined {
2640
if (root.fqn() === fqn) return root;
41+
if (root.package === undefined && root.name !== "" && root.resolutionKey() === fqn) {
42+
return root;
43+
}
2744
for (const child of root.ownChildren()) {
2845
const found = findInTree(child, fqn);
2946
if (found !== undefined) return found;
@@ -110,18 +127,19 @@ export interface DeferredSuperFailure {
110127
* have been parsed with deferSuperResolution: true. Unresolved refs are
111128
* collected and returned — caller decides whether to throw or warn.
112129
*
113-
* Tracks an inherited context package while walking so children whose own
114-
* `package` is unset (e.g., fields inside an object) still resolve against
115-
* the parent's package — matching the parser's eager-resolution semantics.
130+
* The referrer's context package (for bare/same-package shorthand refs) comes
131+
* from the node's own `package`, else the file-default package captured at
132+
* PARSE time (`fileDefaultPackage`). Both are order- and merge-independent, so
133+
* resolution does not depend on the post-merge tree shape.
116134
*
117135
* Idempotent: nodes that already have superResolved set are skipped.
118136
*/
119137
export function resolveDeferredSupers(root: MetaData): DeferredSuperFailure[] {
120138
const failures: DeferredSuperFailure[] = [];
121-
walk(root, "", (node, ctxPkg) => {
139+
walk(root, (node) => {
122140
if (node.superRef === undefined) return;
123141
if (node.superResolved !== undefined) return;
124-
const effectivePkg = node.package ?? ctxPkg;
142+
const effectivePkg = node.package ?? node.fileDefaultPackage ?? "";
125143
const target = resolveSuperRef(node.superRef, effectivePkg, root);
126144
if (target !== undefined) {
127145
try {
@@ -136,14 +154,9 @@ export function resolveDeferredSupers(root: MetaData): DeferredSuperFailure[] {
136154
return failures;
137155
}
138156

139-
function walk(
140-
node: MetaData,
141-
ctxPkg: string,
142-
visit: (n: MetaData, ctx: string) => void,
143-
): void {
144-
visit(node, ctxPkg);
145-
const nextCtx = node.package ?? ctxPkg;
157+
function walk(node: MetaData, visit: (n: MetaData) => void): void {
158+
visit(node);
146159
for (const child of node.ownChildren()) {
147-
walk(child, nextCtx, visit);
160+
walk(child, visit);
148161
}
149162
}
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
// file-default-package.test.ts
2+
//
3+
// Regression: a fully-qualified `extends:` that names a supertype by its
4+
// file-default package (the top-level `metadata.package`) must RESOLVE, even
5+
// though the supertype's own node carries no explicit `package:` key.
6+
//
7+
// Java's BaseMetaDataParser folds the file-default package into the registered
8+
// NAME (so an object `BaseEntity` under `package: acme::common` is
9+
// registered as `acme::common::BaseEntity`, and `extends:
10+
// acme::common::BaseEntity` resolves). The TS parser keeps object `fqn()`
11+
// BARE on purpose — the FR5d referrer-envelope cross-port contract relies on it
12+
// (see Fr5dReferenceResolutionTest: "TS's MetaData.fqn() does NOT propagate the
13+
// root package; Java uses getShortName() to match"). So the bug is NOT that the
14+
// object FQN should gain the package — it is that SUPER RESOLUTION must match a
15+
// node by its EFFECTIVE qualified name (inheritedPkg::name) even when the node's
16+
// fqn() is bare. Without that, the cross-ref silently fails to resolve and
17+
// downstream `meta gen` breaks.
18+
19+
import { test, expect } from "bun:test";
20+
import { TypeRegistry } from "../src/registry.js";
21+
import { registerCoreTypes } from "../src/core-types.js";
22+
import { parseYaml } from "../src/core/parser-yaml.js";
23+
import { TYPE_OBJECT } from "../src/shared/base-types.js";
24+
import { MetaDataLoader } from "../src/loader/meta-data-loader.js";
25+
import { InMemoryStringSource } from "../src/loader/meta-data-source.js";
26+
27+
function coreRegistry(): TypeRegistry {
28+
const registry = new TypeRegistry();
29+
registerCoreTypes(registry);
30+
return registry;
31+
}
32+
33+
// ---------------------------------------------------------------------------
34+
// 1) Same-file extends by fully-qualified (file-default) name resolves.
35+
// ---------------------------------------------------------------------------
36+
37+
test("same-file extends by file-default-qualified name resolves", () => {
38+
const yaml = `
39+
metadata:
40+
package: acme::lms
41+
children:
42+
- object.entity:
43+
name: BaseEntity
44+
abstract: true
45+
children:
46+
- field.string: { name: id }
47+
- object.entity:
48+
name: Course
49+
extends: acme::lms::BaseEntity
50+
children:
51+
- field.string: { name: title }
52+
`;
53+
const result = parseYaml(yaml, { registry: coreRegistry() });
54+
expect(result.errors).toEqual([]);
55+
56+
const base = result.root.childByTypeAndName(TYPE_OBJECT, "BaseEntity")!;
57+
const course = result.root.childByTypeAndName(TYPE_OBJECT, "Course")!;
58+
59+
// Cross-port contract: object fqn() stays BARE (TS does not propagate the
60+
// file-default package into the node's fqn — Java mirrors this via
61+
// getShortName() for FR5d envelopes).
62+
expect(base.fqn()).toBe("BaseEntity");
63+
expect(course.fqn()).toBe("Course");
64+
65+
// The super must RESOLVE despite the fully-qualified ref — this is the fix.
66+
expect(course.superData).toBeDefined();
67+
expect(course.superData).toBe(base);
68+
});
69+
70+
// ---------------------------------------------------------------------------
71+
// 2) Fields within objects keep simple names (unchanged behavior).
72+
// ---------------------------------------------------------------------------
73+
74+
test("fields within objects keep simple names", () => {
75+
const yaml = `
76+
metadata:
77+
package: acme::lms
78+
children:
79+
- object.entity:
80+
name: Course
81+
children:
82+
- field.string: { name: id }
83+
`;
84+
const result = parseYaml(yaml, { registry: coreRegistry() });
85+
expect(result.errors).toEqual([]);
86+
87+
const course = result.root.childByTypeAndName(TYPE_OBJECT, "Course")!;
88+
const id = course.ownChildren().find((c) => c.name === "id");
89+
expect(id).toBeDefined();
90+
expect(id!.package).toBeUndefined();
91+
expect(id!.fqn()).toBe("id");
92+
});
93+
94+
// ---------------------------------------------------------------------------
95+
// 3) Cross-file extends by fully-qualified (file-default) name resolves.
96+
// The base entity declares no explicit package; the fully-qualified ref
97+
// `acme::common::BaseEntity` must still resolve through the loader.
98+
// ---------------------------------------------------------------------------
99+
100+
test("cross-file extends by file-default-qualified name resolves", async () => {
101+
const baseYaml = `
102+
metadata:
103+
package: acme::common
104+
children:
105+
- object.entity:
106+
name: BaseEntity
107+
abstract: true
108+
children:
109+
- field.string: { name: id }
110+
`;
111+
const courseYaml = `
112+
metadata:
113+
package: acme::common
114+
children:
115+
- object.entity:
116+
name: Course
117+
extends: acme::common::BaseEntity
118+
children:
119+
- field.string: { name: title }
120+
`;
121+
122+
const loader = new MetaDataLoader({ registry: coreRegistry() });
123+
const result = await loader.load([
124+
new InMemoryStringSource(baseYaml, { id: "base.yaml", format: "yaml" }),
125+
new InMemoryStringSource(courseYaml, { id: "course.yaml", format: "yaml" }),
126+
]);
127+
expect(result.errors).toEqual([]);
128+
129+
const base = loader.root.childByTypeAndName(TYPE_OBJECT, "BaseEntity")!;
130+
const course = loader.root.childByTypeAndName(TYPE_OBJECT, "Course")!;
131+
132+
// Bare fqn() — cross-port contract.
133+
expect(base.fqn()).toBe("BaseEntity");
134+
expect(course.fqn()).toBe("Course");
135+
136+
// Super resolves across files via the file-default-qualified ref.
137+
expect(course.superData).toBeDefined();
138+
expect(course.superData).toBe(base);
139+
});
140+
141+
// ---------------------------------------------------------------------------
142+
// 4) Cross-PACKAGE cross-file extends by fully-qualified (file-default) name.
143+
// The base entity lives in a DIFFERENT file-default package (acme::common)
144+
// from the concrete entity (acme::lms). Loaded concrete-FIRST, so the base
145+
// node does not yet exist when Course is parsed — deferred resolution must
146+
// match it by its file-default-qualified key (acme::common::BaseTenantEntity)
147+
// regardless of merge order. This is the real downstream shape.
148+
// ---------------------------------------------------------------------------
149+
150+
test("cross-PACKAGE cross-file extends resolves (concrete loaded first)", async () => {
151+
const baseYaml = `
152+
metadata:
153+
package: acme::common
154+
children:
155+
- object.entity:
156+
name: BaseTenantEntity
157+
abstract: true
158+
children:
159+
- field.string: { name: id }
160+
`;
161+
const courseYaml = `
162+
metadata:
163+
package: acme::lms
164+
children:
165+
- object.entity:
166+
name: Course
167+
extends: acme::common::BaseTenantEntity
168+
children:
169+
- field.string: { name: title }
170+
`;
171+
172+
const loader = new MetaDataLoader({ registry: coreRegistry() });
173+
const result = await loader.load([
174+
// concrete FIRST — base not yet present when Course's super is parsed.
175+
new InMemoryStringSource(courseYaml, { id: "lms.yaml", format: "yaml" }),
176+
new InMemoryStringSource(baseYaml, { id: "common.yaml", format: "yaml" }),
177+
]);
178+
expect(result.errors).toEqual([]);
179+
180+
const base = loader.root.childByTypeAndName(TYPE_OBJECT, "BaseTenantEntity")!;
181+
const course = loader.root.childByTypeAndName(TYPE_OBJECT, "Course")!;
182+
183+
// Bare fqn() — cross-port contract (FR5d).
184+
expect(base.fqn()).toBe("BaseTenantEntity");
185+
expect(course.fqn()).toBe("Course");
186+
187+
// Super resolves across packages + files via the file-default-qualified ref.
188+
expect(course.superData).toBeDefined();
189+
expect(course.superData).toBe(base);
190+
});
191+
192+
// ---------------------------------------------------------------------------
193+
// 5) Same as #4 but load-order REVERSED (base first). Resolution must be
194+
// order-independent: the file-default-package key is captured at PARSE time,
195+
// not derived from the post-merge tree, so either order resolves.
196+
// ---------------------------------------------------------------------------
197+
198+
test("cross-PACKAGE cross-file extends resolves (base loaded first)", async () => {
199+
const baseYaml = `
200+
metadata:
201+
package: acme::common
202+
children:
203+
- object.entity:
204+
name: BaseTenantEntity
205+
abstract: true
206+
children:
207+
- field.string: { name: id }
208+
`;
209+
const courseYaml = `
210+
metadata:
211+
package: acme::lms
212+
children:
213+
- object.entity:
214+
name: Course
215+
extends: acme::common::BaseTenantEntity
216+
children:
217+
- field.string: { name: title }
218+
`;
219+
220+
const loader = new MetaDataLoader({ registry: coreRegistry() });
221+
const result = await loader.load([
222+
new InMemoryStringSource(baseYaml, { id: "common.yaml", format: "yaml" }),
223+
new InMemoryStringSource(courseYaml, { id: "lms.yaml", format: "yaml" }),
224+
]);
225+
expect(result.errors).toEqual([]);
226+
227+
const base = loader.root.childByTypeAndName(TYPE_OBJECT, "BaseTenantEntity")!;
228+
const course = loader.root.childByTypeAndName(TYPE_OBJECT, "Course")!;
229+
230+
expect(base.fqn()).toBe("BaseTenantEntity");
231+
expect(course.fqn()).toBe("Course");
232+
233+
expect(course.superData).toBeDefined();
234+
expect(course.superData).toBe(base);
235+
});

0 commit comments

Comments
 (0)