|
| 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