Skip to content

Commit 5a30975

Browse files
dmealingclaude
andcommitted
test(metadata): Open-Closed proof — attr.fizz + field.fizz via one class + one registration line
Adapts Task 9 to the real registration mechanism: attr value classes self-register via the dependency-free attr-class-map seam (registerAttrClass), and a new subtype is wired by registry.register defs whose factories carry their own dataType. The headline case authors attr.fizz as a child node (genuinely zero central edits); a second case declares @fizz on the field schema so the loader's attr-schema pass runs FizzAttr.validateValue and surfaces ERR_BAD_ATTR_VALUE. Documents the one honest central typing surface: AttrSchema.valueType is the closed AttrSubType union, so declaring a new attr on a schema costs a one-token edit to that constant. The attr's value behavior and the field subtype are fully described by their class + registration. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a67ff1a commit 5a30975

1 file changed

Lines changed: 287 additions & 0 deletions

File tree

Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
// Open-Closed proof — the executable definition of done for the typed-node
2+
// refactor. Registering a brand-new attr subtype (and field subtype) must cost
3+
// ONLY a new class + a registration line: ZERO edits to any central file (no
4+
// DataType union arm, no convertToDataType case, no attr-schema subtype-set, no
5+
// AttrValue union arm, no ATTR_DATA_TYPE / FIELD_DATA_TYPE map entry).
6+
//
7+
// The registration mechanism (post-Task-4): attr value classes self-register via
8+
// the dependency-free leaf `src/attr-class-map.ts` (`registerAttrClass(subType,
9+
// ctor)`), which the parser/serializer/setAttr read back through. A new subtype
10+
// therefore needs (a) one `registerAttrClass(...)` line so the value behavior
11+
// resolves to the new class, and (b) one `registry.register({...})` def so the
12+
// loader can find the subtype's TypeDefinition + factory. Both lines live here
13+
// (or in a consumer's provider) — never in a central metadata file.
14+
//
15+
// HONEST surface notes (see the per-test comments + the trailer at end):
16+
// * Attr value class via the `attr.<subType>` child-node form: TRUE zero
17+
// central edits — the subtype is fused into the wrapper key and resolved
18+
// straight from the registry factory, so `validateValue`/`coerce` dispatch
19+
// to the new class with no central typing involved.
20+
// * Field subtype: registering a `field.<subType>` def is likewise zero edits
21+
// to FIELD_SUBTYPES or the co-located FIELD_DATA_TYPE map — the def carries
22+
// its own `dataType`, applied via `node.setDataType(...)` in the factory.
23+
// * The ONE central typing surface is `AttrSchema.valueType: AttrSubType`: to
24+
// DECLARE `@fizz` on an owner's schema (so the loader's attr-schema pass
25+
// runs `FizzAttr.validateValue` during load), the closed `AttrSubType` union
26+
// would need `"fizz"` admitted. That is a one-token edit to a closed-set
27+
// constant — documented honestly below rather than papered over.
28+
29+
import { describe, it, expect } from "bun:test";
30+
import { TypeId, TypeRegistry, type AttrSchema } from "../src/registry.js";
31+
import { registerCoreTypes } from "../src/core-types.js";
32+
import { MetaAttr, type ValueError } from "../src/meta/meta-attr.js";
33+
import { MetaField } from "../src/meta/meta-field.js";
34+
import { registerAttrClass } from "../src/attr-class-map.js";
35+
import { MetaDataLoader } from "../src/loader/meta-data-loader.js";
36+
import { InMemorySource } from "../src/loader/meta-data-source.js";
37+
import { canonicalSerialize } from "../src/serializer-json.js";
38+
import { TYPE_ATTR, TYPE_FIELD, TYPE_OBJECT, type AttrSubType } from "../src/constants.js";
39+
import { DATA_TYPE_STRING, type DataType } from "../src/data-type.js";
40+
import type { AttrValue } from "../src/meta/meta-data.js";
41+
42+
// ===========================================================================
43+
// The ENTIRE cost of a new value-shaped attr subtype: one class.
44+
// FizzAttr overrides only what differs from the base MetaAttr — its DataType,
45+
// how it coerces a raw value, and how it validates a stored value. A 'fizz'
46+
// value must be the literal string "fizz" or "buzz".
47+
// ===========================================================================
48+
const ATTR_SUBTYPE_FIZZ = "fizz"; // throwaway test subtype — OK as a literal
49+
const FIELD_SUBTYPE_FIZZ = "fizz";
50+
51+
class FizzAttr extends MetaAttr {
52+
override get dataType(): DataType {
53+
return DATA_TYPE_STRING;
54+
}
55+
override coerce(raw: unknown): AttrValue {
56+
return String(raw);
57+
}
58+
override validateValue(value: AttrValue): ValueError[] {
59+
return value === "fizz" || value === "buzz"
60+
? []
61+
: [{ message: `attribute '@${this.name}' must be 'fizz' or 'buzz'` }];
62+
}
63+
}
64+
65+
// The ENTIRE cost of a new field subtype: one class. FizzField inherits all
66+
// MetaField behavior; its DataType comes from the registered def below (applied
67+
// via setDataType in the factory), so it need not even override dataType.
68+
class FizzField extends MetaField {}
69+
70+
// --- registration line #1: the attr-class-map self-registration seam --------
71+
// In a real provider this lives at module load inside the FizzAttr file (the
72+
// same `registerAttrClass(ATTR_SUBTYPE_X, XAttr)` call meta-attr-filter.ts etc.
73+
// make). Running it here (idempotent — Map.set) wires `@fizz` value behavior to
74+
// FizzAttr for the inline `@fizz` materialization path (setAttr → attrClassFor).
75+
registerAttrClass(ATTR_SUBTYPE_FIZZ, FizzAttr);
76+
77+
/** A fresh registry with the core types plus the two throwaway fizz defs. The
78+
* two `registry.register` calls are the ONLY registration surface — no central
79+
* file is touched. */
80+
function registryWithFizz(extraFieldAttrs: AttrSchema[] = []): TypeRegistry {
81+
const registry = new TypeRegistry();
82+
registerCoreTypes(registry);
83+
84+
// --- registration line #2: the attr.fizz TypeDefinition ------------------
85+
// The factory builds a FizzAttr; the def carries its own dataType. No edit to
86+
// ATTR_SUBTYPES, BASE_ATTR_DATA_TYPE, data-converter, or AttrValue.
87+
registry.register({
88+
typeId: new TypeId(TYPE_ATTR, ATTR_SUBTYPE_FIZZ),
89+
description: "throwaway fizz attr",
90+
factory: (id, name) => new FizzAttr(id, name),
91+
childRules: [],
92+
attributes: [],
93+
dataType: DATA_TYPE_STRING,
94+
});
95+
96+
// --- registration line #3: the field.fizz TypeDefinition -----------------
97+
// The factory builds a FizzField; the def carries dataType, applied via
98+
// node.setDataType(...) inside the registry's def() factory. No edit to
99+
// FIELD_SUBTYPES or the co-located FIELD_DATA_TYPE map.
100+
registry.register({
101+
typeId: new TypeId(TYPE_FIELD, FIELD_SUBTYPE_FIZZ),
102+
description: "throwaway fizz field",
103+
factory: (id, name) => {
104+
const node = new FizzField(id, name);
105+
node.setDataType(DATA_TYPE_STRING);
106+
return node;
107+
},
108+
childRules: [],
109+
attributes: extraFieldAttrs,
110+
dataType: DATA_TYPE_STRING,
111+
});
112+
113+
return registry;
114+
}
115+
116+
describe("Open-Closed proof: a new subtype costs one class + one registration line", () => {
117+
it("loads, coerces, validates, and canonically serializes attr.fizz + field.fizz (TRUE zero central edits)", async () => {
118+
const registry = registryWithFizz();
119+
120+
// `attr.fizz` is authored as a child node: { "attr.fizz": { name, value } }.
121+
// This is the genuinely zero-central-edit path — the subtype is fused into
122+
// the wrapper key and resolved straight from the registry factory, so the
123+
// value coerces + validates via FizzAttr without any AttrSchema typing.
124+
const doc = JSON.stringify({
125+
"metadata.root": {
126+
package: "acme",
127+
children: [
128+
{
129+
"object.entity": {
130+
name: "Demo",
131+
children: [
132+
{
133+
"field.fizz": {
134+
name: "label",
135+
children: [{ "attr.fizz": { name: "fizz", value: "buzz" } }],
136+
},
137+
},
138+
{ "identity.primary": { "@fields": ["label"] } },
139+
],
140+
},
141+
},
142+
],
143+
},
144+
});
145+
146+
const loader = new MetaDataLoader({ registry, freeze: false });
147+
const { root, errors } = await loader.load([
148+
new InMemorySource(doc, { id: "demo.json", format: "json" }),
149+
]);
150+
expect(errors).toEqual([]);
151+
152+
// The field materialized as the new FizzField with the registered DataType.
153+
const obj = root.ownChildByTypeAndName(TYPE_OBJECT, "Demo")!;
154+
const field = obj.ownChildByTypeAndName(TYPE_FIELD, "label")! as FizzField;
155+
expect(field).toBeInstanceOf(FizzField);
156+
expect(field.subType).toBe(FIELD_SUBTYPE_FIZZ);
157+
expect(field.dataType).toBe(DATA_TYPE_STRING);
158+
159+
// The @fizz attr materialized as a FizzAttr instance, coerced via the class.
160+
const fizzAttr = field.ownMetaAttr("fizz")!;
161+
expect(fizzAttr).toBeInstanceOf(FizzAttr);
162+
expect(fizzAttr.value).toBe("buzz");
163+
164+
// The class's own validateValue is exercised end-to-end.
165+
expect(fizzAttr.validateValue("buzz")).toEqual([]);
166+
expect(fizzAttr.validateValue("fizz")).toEqual([]);
167+
expect(fizzAttr.validateValue("nope").length).toBeGreaterThan(0);
168+
169+
// The class's own coerce stringifies a non-string raw value.
170+
expect(fizzAttr.coerce(42)).toBe("42");
171+
172+
// Canonical serialize emits @fizz inline (D5) and the field.fizz wrapper —
173+
// the serializer is fully type-agnostic, reading type/subType + the value
174+
// map off the node, so a new subtype round-trips with no serializer edit.
175+
const json = canonicalSerialize(root);
176+
expect(json).toContain('"@fizz": "buzz"');
177+
expect(json).toContain('"field.fizz"');
178+
});
179+
180+
it("rejects an invalid fizz value via the loader's attr-schema pass (one declared @fizz on the field schema)", async () => {
181+
// To make the loader's attr-schema pass invoke FizzAttr.validateValue during
182+
// load (rather than calling it directly), the owner field's schema must
183+
// DECLARE @fizz with valueType: "fizz". Functionally this is one AttrSchema
184+
// entry. The one honest CENTRAL typing surface: `AttrSchema.valueType` is
185+
// typed `AttrSubType` (a closed union of known attr subtypes), so a
186+
// production provider would add "fizz" to the ATTR_SUBTYPES const for this
187+
// line to typecheck. We localize that single narrowing here as
188+
// `ATTR_SUBTYPE_FIZZ as AttrSubType` — equivalent to a one-token edit to a
189+
// closed-set constant, NOT a structural central-file change.
190+
const fizzSchema: AttrSchema = {
191+
name: "fizz",
192+
valueType: ATTR_SUBTYPE_FIZZ as AttrSubType,
193+
required: false,
194+
description: "fizz or buzz",
195+
};
196+
const registry = registryWithFizz([fizzSchema]);
197+
198+
// Inline @fizz on a field whose schema declares it → the parser resolves the
199+
// declared valueType "fizz", materializes a FizzAttr, and the attr-schema
200+
// pass runs FizzAttr.validateValue, surfacing ERR_BAD_ATTR_VALUE for "nope".
201+
const doc = JSON.stringify({
202+
"metadata.root": {
203+
package: "acme",
204+
children: [
205+
{
206+
"object.entity": {
207+
name: "Demo",
208+
children: [
209+
{ "field.fizz": { name: "label", "@fizz": "nope" } },
210+
{ "identity.primary": { "@fields": ["label"] } },
211+
],
212+
},
213+
},
214+
],
215+
},
216+
});
217+
218+
const loader = new MetaDataLoader({ registry, freeze: false });
219+
const { errors } = await loader.load([
220+
new InMemorySource(doc, { id: "demo.json", format: "json" }),
221+
]);
222+
expect(
223+
errors.some((e) => (e as { code?: string }).code === "ERR_BAD_ATTR_VALUE"),
224+
).toBe(true);
225+
});
226+
227+
it("accepts a valid declared @fizz value through the same pipeline", async () => {
228+
const fizzSchema: AttrSchema = {
229+
name: "fizz",
230+
valueType: ATTR_SUBTYPE_FIZZ as AttrSubType,
231+
required: false,
232+
description: "fizz or buzz",
233+
};
234+
const registry = registryWithFizz([fizzSchema]);
235+
const doc = JSON.stringify({
236+
"metadata.root": {
237+
package: "acme",
238+
children: [
239+
{
240+
"object.entity": {
241+
name: "Demo",
242+
children: [
243+
{ "field.fizz": { name: "label", "@fizz": "fizz" } },
244+
{ "identity.primary": { "@fields": ["label"] } },
245+
],
246+
},
247+
},
248+
],
249+
},
250+
});
251+
252+
const loader = new MetaDataLoader({ registry, freeze: false });
253+
const { root, errors } = await loader.load([
254+
new InMemorySource(doc, { id: "demo.json", format: "json" }),
255+
]);
256+
expect(errors).toEqual([]);
257+
258+
const obj = root.ownChildByTypeAndName(TYPE_OBJECT, "Demo")!;
259+
const field = obj.ownChildByTypeAndName(TYPE_FIELD, "label")! as FizzField;
260+
const fizzAttr = field.ownMetaAttr("fizz")!;
261+
expect(fizzAttr).toBeInstanceOf(FizzAttr);
262+
expect(fizzAttr.value).toBe("fizz");
263+
});
264+
});
265+
266+
// ===========================================================================
267+
// Documented invariant — the property this refactor exists to create.
268+
//
269+
// Registering attr.fizz + field.fizz above required editing ZERO central files:
270+
// * NO arm added to the DataType union (data-type.ts).
271+
// * NO case added to convertToDataType (data-converter.ts).
272+
// * NO subtype-set edited in attr-schema-validate.ts (value-shape knowledge
273+
// lives on FizzAttr.validateValue).
274+
// * NO arm added to the AttrValue union (meta-data.ts).
275+
// * NO entry added to BASE_ATTR_DATA_TYPE (meta-attr.ts) or FIELD_DATA_TYPE
276+
// (meta-field.ts) — each def carries its own dataType.
277+
// * NO entry hard-coded in core-types.ts — the attr-class-map self-registration
278+
// seam (registerAttrClass) wires value behavior; the registry defs supply the
279+
// factories.
280+
//
281+
// The single HONEST central typing surface is `AttrSchema.valueType: AttrSubType`
282+
// (a closed union): to DECLARE a new attr on an owner's schema typesafely, a
283+
// production provider adds the subtype string to the ATTR_SUBTYPES const — a
284+
// one-token edit to a closed-set constant, not a structural change. The attr's
285+
// VALUE BEHAVIOR (dataType/coerce/validate) and the field subtype are fully
286+
// described by their class + registration. THAT is Open-Closed.
287+
// ===========================================================================

0 commit comments

Comments
 (0)