From 3eb80963ea2e267d6295b7208ff35550b66a1305 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Tue, 19 May 2026 12:55:16 -0400 Subject: [PATCH 1/3] add stub type test to mutator engine --- .../src/mutation/alternate-type.test.ts | 576 ++++++++++++++++++ .../src/mutation/mutation-engine.ts | 79 ++- 2 files changed, 654 insertions(+), 1 deletion(-) create mode 100644 packages/mutator-framework/src/mutation/alternate-type.test.ts diff --git a/packages/mutator-framework/src/mutation/alternate-type.test.ts b/packages/mutator-framework/src/mutation/alternate-type.test.ts new file mode 100644 index 00000000000..2c2470839f5 --- /dev/null +++ b/packages/mutator-framework/src/mutation/alternate-type.test.ts @@ -0,0 +1,576 @@ +import type { MemberType, Model, ModelProperty, Operation, Type, UnionVariant } from "@typespec/compiler"; +import { expectTypeEquals, t } from "@typespec/compiler/testing"; +import { $ } from "@typespec/compiler/typekit"; +import type { Typekit } from "@typespec/compiler/typekit"; +import { describe, expect, it } from "vitest"; +import { Tester } from "../../test/test-host.js"; +import type { MutationHalfEdge, MutationTraits } from "./mutation-engine.js"; +import type { MutationInfo } from "./mutation.js"; +import { + SimpleModelPropertyMutation, + SimpleMutationEngine, + SimpleMutationOptions, + SimpleOperationMutation, + SimpleUnionVariantMutation, +} from "./simple-mutation-engine.js"; + +// --- Helpers --- + +async function compile(code: ReturnType) { + const runner = await Tester.createInstance(); + const result = await runner.compile(code); + return { ...result, tk: $(result.program) }; +} + +function createPropertyEngine( + tk: Typekit, + MutationClass: typeof SimpleModelPropertyMutation, +) { + return new SimpleMutationEngine<{ ModelProperty: typeof MutationClass }>(tk, { + ModelProperty: MutationClass, + } as any); +} + +function createOperationEngine( + tk: Typekit, + MutationClass: typeof SimpleOperationMutation, +) { + return new SimpleMutationEngine<{ Operation: typeof MutationClass }>(tk, { + Operation: MutationClass, + } as any); +} + +function createVariantEngine( + tk: Typekit, + MutationClass: typeof SimpleUnionVariantMutation, +) { + return new SimpleMutationEngine<{ UnionVariant: typeof MutationClass }>(tk, { + UnionVariant: MutationClass, + } as any); +} + +/** Creates a ModelProperty mutation class that replaces the property type using replaceAndMutateReference */ +function createReplacePropertyMutation(getAlternate: (prop: ModelProperty) => Type | undefined) { + return class extends SimpleModelPropertyMutation { + mutate() { + const alternate = getAlternate(this.sourceType); + if (alternate) { + this.mutationNode.mutate((prop) => { + prop.type = alternate; + }); + this.type = this.engine.replaceAndMutateReference( + this.sourceType, + alternate, + this.options, + this.startTypeEdge(), + ); + } else { + super.mutate(); + } + } + }; +} + +/** Creates a UnionVariant mutation class that replaces the variant type using replaceAndMutateReference */ +function createReplaceVariantMutation(getAlternate: (variant: UnionVariant) => Type | undefined) { + return class extends SimpleUnionVariantMutation { + mutate() { + const alternate = getAlternate(this.sourceType); + if (alternate) { + this.mutationNode.mutate((variant) => { + variant.type = alternate; + }); + this.type = this.engine.replaceAndMutateReference( + this.sourceType, + alternate, + this.options, + this.startTypeEdge(), + ); + } else { + super.mutate(); + } + } + }; +} + +function expectModelType(type: Type, name: string) { + expect(type.kind).toBe("Model"); + expect((type as Model).name).toBe(name); +} + +/** + * These tests replicate the behavior of a downstream library's `@alternateType` decorator + * which replaces the type of a property/returnType/variant with a different type during mutation. + * The pattern is general: it's not specific to model properties but applies to any type reference + * (Operation.returnType, UnionVariant.type, etc.) + * + * Tests cover two approaches: + * 1. Using the mutation node's mutate + replaceAndMutateReference (full control) + * 2. Simply mutating the node's type (simpler approach that should also work) + */ +describe("type replacement (@alternateType pattern)", () => { + describe("ModelProperty.type", () => { + it("replaces property type with a model type via mutationNode.mutate", async () => { + const { Foo, Bar, tk } = await compile(t.code` + model ${t.model("Foo")} { + prop: string; + } + + model ${t.model("Bar")} { + name: string; + } + `); + + const Mutation = createReplacePropertyMutation(() => Bar); + const engine = createPropertyEngine(tk, Mutation); + + const fooMutation = engine.mutate(Foo); + const propMutation = fooMutation.properties.get("prop")!; + + expectModelType(propMutation.mutatedType.type, "Bar"); + expect(Foo.properties.get("prop")!.type.kind).toBe("Scalar"); + }); + + it("replaces property type without replaceAndMutateReference (stub type on node)", async () => { + const { Foo, Bar, tk } = await compile(t.code` + model ${t.model("Foo")} { + prop: string; + } + + model ${t.model("Bar")} { + name: string; + } + `); + + // Simpler approach: just mutate the node's type directly without replaceAndMutateReference + class SimpleAlternateTypeProperty extends SimpleModelPropertyMutation { + mutate() { + this.mutationNode.mutate((prop) => { + prop.type = Bar; + }); + this.type = this.engine.mutate(Bar, this.options, this.startTypeEdge()); + } + } + + const engine = createPropertyEngine(tk, SimpleAlternateTypeProperty); + + const fooMutation = engine.mutate(Foo); + const propMutation = fooMutation.properties.get("prop")!; + + expectModelType(propMutation.mutatedType.type, "Bar"); + expect(Foo.properties.get("prop")!.type.kind).toBe("Scalar"); + }); + + it("replacement type that is also mutated in the graph shares the same instance", async () => { + const { Foo, program, tk } = await compile(t.code` + model ${t.model("Foo")} { + prop1: Bar; + prop2: string; + } + + model ${t.model("Bar")} { + value: int32; + } + `); + + const Bar = program.getGlobalNamespaceType().models.get("Bar")!; + const Mutation = createReplacePropertyMutation((prop) => + prop.name === "prop2" ? Bar : undefined, + ); + const engine = createPropertyEngine(tk, Mutation); + + const fooMutation = engine.mutate(Foo); + const prop1 = fooMutation.properties.get("prop1")!; + const prop2 = fooMutation.properties.get("prop2")!; + + expectModelType(prop1.mutatedType.type, "Bar"); + expectModelType(prop2.mutatedType.type, "Bar"); + expectTypeEquals(prop1.mutatedType.type as Model, prop2.mutatedType.type as Model); + }); + + it("replaces property type with an external type not in the original subgraph", async () => { + const { Foo, External, tk } = await compile(t.code` + model ${t.model("Foo")} { + prop: string; + } + + model ${t.model("External")} { + id: int32; + } + `); + + const Mutation = createReplacePropertyMutation(() => External); + const engine = createPropertyEngine(tk, Mutation); + + const fooMutation = engine.mutate(Foo); + const propMutation = fooMutation.properties.get("prop")!; + + expectModelType(propMutation.mutatedType.type, "External"); + expect(Foo.properties.get("prop")!.type.kind).toBe("Scalar"); + }); + + it("recursively processes the replacement type's properties through the mutator", async () => { + const { Foo, program, tk } = await compile(t.code` + model ${t.model("Foo")} { + prop: string; + } + + model ${t.model("Bar")} { + inner: int32; + } + + model ${t.model("Baz")} { + value: string; + } + `); + + const Bar = program.getGlobalNamespaceType().models.get("Bar")!; + const Baz = program.getGlobalNamespaceType().models.get("Baz")!; + + const alternateTypes = new Map([ + ["prop", Bar], + ["inner", Baz], + ]); + + const Mutation = createReplacePropertyMutation((prop) => alternateTypes.get(prop.name)); + const engine = createPropertyEngine(tk, Mutation); + + const fooMutation = engine.mutate(Foo); + const propMutation = fooMutation.properties.get("prop")!; + + expectModelType(propMutation.mutatedType.type, "Bar"); + + // Bar.inner should be recursively replaced with Baz + const barMutation = propMutation.type; + expect(barMutation.kind).toBe("Model"); + const innerPropMutation = (barMutation as any).properties.get("inner")!; + expectModelType(innerPropMutation.mutatedType.type, "Baz"); + + // Originals should be unchanged + expect(Foo.properties.get("prop")!.type.kind).toBe("Scalar"); + expect(Bar.properties.get("inner")!.type.kind).toBe("Scalar"); + }); + + it("recursively processes replacement type using engine.mutate (without replaceAndMutateReference)", async () => { + const { Foo, program, tk } = await compile(t.code` + model ${t.model("Foo")} { + prop: string; + } + + model ${t.model("Bar")} { + inner: int32; + } + + model ${t.model("Baz")} { + value: string; + } + `); + + const Bar = program.getGlobalNamespaceType().models.get("Bar")!; + const Baz = program.getGlobalNamespaceType().models.get("Baz")!; + + const alternateTypes = new Map([ + ["prop", Bar], + ["inner", Baz], + ]); + + // This version uses engine.mutate directly instead of replaceAndMutateReference + class SimpleRecursiveAlternateType extends SimpleModelPropertyMutation { + mutate() { + const alternate = alternateTypes.get(this.sourceType.name); + if (alternate) { + this.mutationNode.mutate((prop) => { + prop.type = alternate; + }); + this.type = this.engine.mutate(alternate, this.options, this.startTypeEdge()); + } else { + super.mutate(); + } + } + } + + const engine = createPropertyEngine(tk, SimpleRecursiveAlternateType); + + const fooMutation = engine.mutate(Foo); + const propMutation = fooMutation.properties.get("prop")!; + + expectModelType(propMutation.mutatedType.type, "Bar"); + + const barMutation = propMutation.type; + expect(barMutation.kind).toBe("Model"); + const innerPropMutation = (barMutation as any).properties.get("inner")!; + expectModelType(innerPropMutation.mutatedType.type, "Baz"); + + expect(Foo.properties.get("prop")!.type.kind).toBe("Scalar"); + expect(Bar.properties.get("inner")!.type.kind).toBe("Scalar"); + }); + + // This test exposes a bug: when `replaceAndMutateReference` is called from + // `mutationInfo` of a ModelProperty mutation, the parent Model's property edge + // expects a ModelPropertyMutationNode as its tail (to call `connectModel`), but + // the replacement creates a mutation for the alternate type (e.g., a ModelMutation) + // which doesn't satisfy that interface. The framework should handle this case + // so that `@alternateType`-style interceptors can work at the ModelProperty level. + it("replacement type resolved via mutationInfo has its properties processed by the mutator", async () => { + const { Foo, program, tk } = await compile(t.code` + model ${t.model("Foo")} { + prop: OriginalType; + } + + model ${t.model("OriginalType")} { + value: string; + } + + model ${t.model("AlternateModel")} { + inner: InnerOriginal; + } + + model ${t.model("InnerAlternate")} { + deep: string; + } + + model ${t.model("InnerOriginal")} { + x: int32; + } + `); + + const OriginalType = program.getGlobalNamespaceType().models.get("OriginalType")!; + const AlternateModel = program.getGlobalNamespaceType().models.get("AlternateModel")!; + const InnerOriginal = program.getGlobalNamespaceType().models.get("InnerOriginal")!; + const InnerAlternate = program.getGlobalNamespaceType().models.get("InnerAlternate")!; + + const alternateTypeMap = new Map([ + [OriginalType, AlternateModel], + [InnerOriginal, InnerAlternate], + ]); + + class AlternateTypePropertyViaInfo extends SimpleModelPropertyMutation { + static mutationInfo( + engine: SimpleMutationEngine<{ ModelProperty: AlternateTypePropertyViaInfo }>, + sourceType: ModelProperty, + referenceTypes: MemberType[], + options: SimpleMutationOptions, + halfEdge?: MutationHalfEdge, + traits?: MutationTraits, + ): MutationInfo | AlternateTypePropertyViaInfo { + let referencedType = sourceType.type; + while (referencedType.kind === "ModelProperty" || referencedType.kind === "UnionVariant") { + referencedType = (referencedType as any).type; + } + + const alternate = alternateTypeMap.get(referencedType as Model); + if (alternate) { + return engine.replaceAndMutateReference( + sourceType, + alternate, + options, + halfEdge, + ) as any; + } + + return super.mutationInfo( + engine, + sourceType, + referenceTypes, + options, + halfEdge, + traits, + ) as MutationInfo; + } + } + + const engine = createPropertyEngine(tk, AlternateTypePropertyViaInfo); + + const fooMutation = engine.mutate(Foo); + const propMutation = fooMutation.properties.get("prop")!; + + expectModelType(propMutation.mutatedType.type, "AlternateModel"); + + // AlternateModel.inner should also be replaced with InnerAlternate + const alternateMutation = propMutation.type; + expect(alternateMutation.kind).toBe("Model"); + const innerPropMutation = (alternateMutation as any).properties.get("inner")!; + expectModelType(innerPropMutation.mutatedType.type, "InnerAlternate"); + + // Original types should be unchanged + expectModelType(Foo.properties.get("prop")!.type, "OriginalType"); + }); + }); + + describe("Operation.returnType", () => { + it("replaces return type with a model type", async () => { + const { myOp, Bar, tk } = await compile(t.code` + op ${t.op("myOp")}(): string; + + model ${t.model("Bar")} { + name: string; + } + `); + + class AlternateReturnType extends SimpleOperationMutation { + protected mutateReturnType() { + this.mutationNode.mutate((op) => { + op.returnType = Bar; + }); + this.returnType = this.engine.mutate(Bar, this.options, this.startReturnTypeEdge()); + } + } + + const engine = createOperationEngine(tk, AlternateReturnType); + + const opMutation = engine.mutate(myOp as Operation); + expectModelType(opMutation.mutatedType.returnType, "Bar"); + expect((myOp as Operation).returnType.kind).toBe("Scalar"); + }); + + it("replaces return type with a type also used in parameters (shared instance)", async () => { + const { myOp, program, tk } = await compile(t.code` + model ${t.model("Bar")} { + name: string; + } + + op ${t.op("myOp")}(param: Bar): string; + `); + + const Bar = program.getGlobalNamespaceType().models.get("Bar")!; + + class AlternateReturnType extends SimpleOperationMutation { + protected mutateReturnType() { + this.mutationNode.mutate((op) => { + op.returnType = Bar; + }); + this.returnType = this.engine.mutate(Bar, this.options, this.startReturnTypeEdge()); + } + } + + const engine = createOperationEngine(tk, AlternateReturnType); + + const opMutation = engine.mutate(myOp as Operation); + + expectModelType(opMutation.mutatedType.returnType, "Bar"); + + const paramProp = opMutation.parameters.properties.get("param")!; + expectModelType(paramProp.mutatedType.type, "Bar"); + + // Both should reference the same mutated Bar + expectTypeEquals( + opMutation.mutatedType.returnType as Model, + paramProp.mutatedType.type as Model, + ); + + expect((myOp as Operation).returnType.kind).toBe("Scalar"); + }); + }); + + describe("UnionVariant.type", () => { + it("replaces variant type with a model type", async () => { + const { MyUnion, Bar, tk } = await compile(t.code` + union ${t.union("MyUnion")} { + a: string; + } + + model ${t.model("Bar")} { + name: string; + } + `); + + const Mutation = createReplaceVariantMutation(() => Bar); + const engine = createVariantEngine(tk, Mutation); + + const unionMutation = engine.mutate(MyUnion); + const variantMutation = unionMutation.variants.get("a")!; + + expectModelType(variantMutation.mutatedType.type, "Bar"); + expect(MyUnion.variants.get("a")!.type.kind).toBe("Scalar"); + }); + + it("replaces variant type with an external type (shared across variants)", async () => { + const { MyUnion, External, tk } = await compile(t.code` + union ${t.union("MyUnion")} { + a: string; + b: int32; + } + + model ${t.model("External")} { + id: int32; + } + `); + + const Mutation = createReplaceVariantMutation(() => External); + const engine = createVariantEngine(tk, Mutation); + + const unionMutation = engine.mutate(MyUnion); + const variantA = unionMutation.variants.get("a")!; + const variantB = unionMutation.variants.get("b")!; + + expectModelType(variantA.mutatedType.type, "External"); + expectModelType(variantB.mutatedType.type, "External"); + expectTypeEquals(variantA.mutatedType.type as Model, variantB.mutatedType.type as Model); + + expect(MyUnion.variants.get("a")!.type.kind).toBe("Scalar"); + expect(MyUnion.variants.get("b")!.type.kind).toBe("Scalar"); + }); + + it("replacement type resolved via mutationInfo has its variants processed by the mutator", async () => { + const { MyUnion, program, tk } = await compile(t.code` + model ${t.model("AlternateModel")} { + value: string; + } + + union ${t.union("MyUnion")} { + a: OriginalType; + } + + scalar ${t.scalar("OriginalType")}; + `); + + const OriginalType = program.getGlobalNamespaceType().scalars.get("OriginalType")!; + const AlternateModel = program.getGlobalNamespaceType().models.get("AlternateModel")!; + + const alternateTypeMap = new Map([[OriginalType, AlternateModel]]); + + class AlternateTypeVariantViaInfo extends SimpleUnionVariantMutation { + static mutationInfo( + engine: SimpleMutationEngine<{ UnionVariant: AlternateTypeVariantViaInfo }>, + sourceType: UnionVariant, + referenceTypes: MemberType[], + options: SimpleMutationOptions, + halfEdge?: MutationHalfEdge, + traits?: MutationTraits, + ): MutationInfo | AlternateTypeVariantViaInfo { + let referencedType: Type = sourceType.type; + while (referencedType.kind === "ModelProperty" || referencedType.kind === "UnionVariant") { + referencedType = (referencedType as any).type; + } + + const alternate = alternateTypeMap.get(referencedType); + if (alternate) { + return engine.replaceAndMutateReference( + sourceType, + alternate, + options, + halfEdge, + ) as any; + } + + return super.mutationInfo( + engine, + sourceType, + referenceTypes, + options, + halfEdge, + traits, + ) as MutationInfo; + } + } + + const engine = createVariantEngine(tk, AlternateTypeVariantViaInfo); + + const unionMutation = engine.mutate(MyUnion); + const variantA = unionMutation.variants.get("a")!; + + expectModelType(variantA.mutatedType.type, "AlternateModel"); + expect(MyUnion.variants.get("a")!.type.kind).toBe("Scalar"); + }); + }); +}); diff --git a/packages/mutator-framework/src/mutation/mutation-engine.ts b/packages/mutator-framework/src/mutation/mutation-engine.ts index d2b420d736c..15b6496780c 100644 --- a/packages/mutator-framework/src/mutation/mutation-engine.ts +++ b/packages/mutator-framework/src/mutation/mutation-engine.ts @@ -9,7 +9,7 @@ import { IntrinsicMutation } from "./intrinsic.js"; import { LiteralMutation } from "./literal.js"; import { ModelPropertyMutation } from "./model-property.js"; import { ModelMutation } from "./model.js"; -import { Mutation } from "./mutation.js"; +import { Mutation, type MutationInfo } from "./mutation.js"; import { OperationMutation } from "./operation.js"; import { ScalarMutation } from "./scalar.js"; @@ -179,12 +179,80 @@ export class MutationEngine { halfEdge?: MutationHalfEdge, ) { const { references } = resolveReference(reference); + + // When the halfEdge expects a member type mutation (property/variant) but the + // replacement type is a different kind, we need to create a stub member mutation + // that wraps the replacement. This happens when e.g. a ModelProperty's type is + // being replaced via mutationInfo and the halfEdge comes from the parent Model's + // property edge which expects a ModelPropertyMutation as its tail. + if (halfEdge && isMemberEdgeKind(halfEdge.kind) && reference.kind !== newType.kind) { + // Create the type mutation independently (without the halfEdge) + const typeMut = this.mutateWorker(newType, references, options, undefined, { + isSynthetic: true, + }) as unknown as MutationFor; + + // Create a stub member mutation that wraps the replacement type + return this.#createMemberStub(reference, typeMut, options, halfEdge); + } + const mut = this.mutateWorker(newType, references, options, halfEdge, { isSynthetic: true, }); return mut; } + /** + * Creates a stub member mutation (ModelProperty/UnionVariant) that wraps a type replacement. + * The stub is properly connected to the halfEdge and has its type edge wired to + * the replacement mutation. + */ + #createMemberStub( + reference: MemberType, + typeMut: MutationFor, + options: MutationOptions, + halfEdge: MutationHalfEdge, + ): MutationFor { + const mutatorClass = this.#mutatorClasses[reference.kind]; + const info: MutationInfo = { mutationKey: options.mutationKey, isSynthetic: true }; + + // Initialize cache for this reference + if (!this.#mutationCache.has(reference)) { + this.#mutationCache.set( + reference, + new Map>(), + ); + } + const byType = this.#mutationCache.get(reference)!; + + if (byType.has(info.mutationKey)) { + const existing = byType.get(info.mutationKey)! as any; + halfEdge.setTail(existing); + return existing; + } + + // Create the member mutation (e.g., SimpleModelPropertyMutation) + const stub = new (mutatorClass as any)(this, reference, [], options, info); + byType.set(info.mutationKey, stub); + stub.isMutated = true; + + // Connect to the halfEdge (property/variant edge from parent). + // This must happen BEFORE wiring the type edge below, because the type edge + // connection triggers tailMutated() which calls mutationNode.mutate() to clone + // the member. The parent connection (connectModel/connectUnion) must already + // exist so the clone has the correct parent reference. + halfEdge.setTail(stub); + + // Wire the replacement type: set the member's type field and connect the type edge + // so that mutations propagate correctly through the graph + stub.type = typeMut; + const startTypeEdge = (stub as any).startTypeEdge; + if (typeof startTypeEdge === "function") { + startTypeEdge.call(stub).setTail(typeMut); + } + + return stub; + } + /** * Internal worker that creates or retrieves mutations with caching. */ @@ -276,6 +344,15 @@ export class MutationEngine { } } +/** + * Returns true if the half edge kind expects a specific member type mutation + * (ModelProperty, UnionVariant, or Operation) as its tail, meaning the tail + * must be of that exact kind and cannot be an arbitrary type replacement. + */ +function isMemberEdgeKind(kind: string): boolean { + return kind === "property" || kind === "variant" || kind === "operation"; +} + function resolveReference(reference: MemberType) { const references: MemberType[] = []; let referencedType: Type = reference; From 8f6930f0309a11efaf189754c4d1b50ea86f9912 Mon Sep 17 00:00:00 2001 From: Qiaoqiao Zhang <55688292+qiaozha@users.noreply.github.com> Date: Tue, 26 May 2026 20:27:39 +0800 Subject: [PATCH 2/3] Fix/mutator property stub type ts errors (#28) * fix: resolve TypeScript errors in mutator-framework and http-canonicalization - Make compile() helper generic to preserve t.model()/t.union()/etc marker types - Fix createPropertyEngine/createOperationEngine/createVariantEngine to use instance types instead of typeof constructor in SimpleMutationEngine generic - Cast replaceAndMutateReference return in http-canonicalization model.ts to match narrower mutationInfo return type * test(mutator-framework): add regression tests for alternate type dual-edge mutation --- ...y-stub-type-ts-errors-2026-4-26-10-32-5.md | 7 + packages/http-canonicalization/src/model.ts | 9 +- .../src/mutation/alternate-type.test.ts | 203 ++++++++++++++++-- 3 files changed, 205 insertions(+), 14 deletions(-) create mode 100644 .chronus/changes/fix-mutator-property-stub-type-ts-errors-2026-4-26-10-32-5.md diff --git a/.chronus/changes/fix-mutator-property-stub-type-ts-errors-2026-4-26-10-32-5.md b/.chronus/changes/fix-mutator-property-stub-type-ts-errors-2026-4-26-10-32-5.md new file mode 100644 index 00000000000..681d27a7b0e --- /dev/null +++ b/.chronus/changes/fix-mutator-property-stub-type-ts-errors-2026-4-26-10-32-5.md @@ -0,0 +1,7 @@ +--- +changeKind: internal +packages: + - "@typespec/mutator-framework" +--- + +Add regression tests for alternate type dual-edge mutation (ARM + client paths) in mutator-framework \ No newline at end of file diff --git a/packages/http-canonicalization/src/model.ts b/packages/http-canonicalization/src/model.ts index f1da99c2fec..8494c37a588 100644 --- a/packages/http-canonicalization/src/model.ts +++ b/packages/http-canonicalization/src/model.ts @@ -127,7 +127,12 @@ export class ModelHttpCanonicalization if (referenceTypes.length === 0) { return engine.mutate(union, options, halfEdge, { isSynthetic: true }); } else { - return engine.replaceAndMutateReference(referenceTypes[0], union, options, halfEdge); + return engine.replaceAndMutateReference( + referenceTypes[0], + union, + options, + halfEdge, + ) as any as UnionHttpCanonicalization; } } @@ -142,7 +147,7 @@ export class ModelHttpCanonicalization effectiveModel, options, halfEdge, - ); + ) as any as ModelHttpCanonicalization; } } diff --git a/packages/mutator-framework/src/mutation/alternate-type.test.ts b/packages/mutator-framework/src/mutation/alternate-type.test.ts index 2c2470839f5..d4f918c5235 100644 --- a/packages/mutator-framework/src/mutation/alternate-type.test.ts +++ b/packages/mutator-framework/src/mutation/alternate-type.test.ts @@ -1,10 +1,18 @@ -import type { MemberType, Model, ModelProperty, Operation, Type, UnionVariant } from "@typespec/compiler"; -import { expectTypeEquals, t } from "@typespec/compiler/testing"; -import { $ } from "@typespec/compiler/typekit"; +import type { + Entity, + MemberType, + Model, + ModelProperty, + Operation, + Type, + UnionVariant, +} from "@typespec/compiler"; +import { expectTypeEquals, t, type TemplateWithMarkers } from "@typespec/compiler/testing"; import type { Typekit } from "@typespec/compiler/typekit"; +import { $ } from "@typespec/compiler/typekit"; import { describe, expect, it } from "vitest"; import { Tester } from "../../test/test-host.js"; -import type { MutationHalfEdge, MutationTraits } from "./mutation-engine.js"; +import { MutationHalfEdge, type MutationTraits } from "./mutation-engine.js"; import type { MutationInfo } from "./mutation.js"; import { SimpleModelPropertyMutation, @@ -16,7 +24,7 @@ import { // --- Helpers --- -async function compile(code: ReturnType) { +async function compile>(code: TemplateWithMarkers) { const runner = await Tester.createInstance(); const result = await runner.compile(code); return { ...result, tk: $(result.program) }; @@ -26,7 +34,9 @@ function createPropertyEngine( tk: Typekit, MutationClass: typeof SimpleModelPropertyMutation, ) { - return new SimpleMutationEngine<{ ModelProperty: typeof MutationClass }>(tk, { + return new SimpleMutationEngine<{ + ModelProperty: SimpleModelPropertyMutation; + }>(tk, { ModelProperty: MutationClass, } as any); } @@ -35,16 +45,21 @@ function createOperationEngine( tk: Typekit, MutationClass: typeof SimpleOperationMutation, ) { - return new SimpleMutationEngine<{ Operation: typeof MutationClass }>(tk, { - Operation: MutationClass, - } as any); + return new SimpleMutationEngine<{ Operation: SimpleOperationMutation }>( + tk, + { + Operation: MutationClass, + } as any, + ); } function createVariantEngine( tk: Typekit, MutationClass: typeof SimpleUnionVariantMutation, ) { - return new SimpleMutationEngine<{ UnionVariant: typeof MutationClass }>(tk, { + return new SimpleMutationEngine<{ + UnionVariant: SimpleUnionVariantMutation; + }>(tk, { UnionVariant: MutationClass, } as any); } @@ -354,7 +369,10 @@ describe("type replacement (@alternateType pattern)", () => { traits?: MutationTraits, ): MutationInfo | AlternateTypePropertyViaInfo { let referencedType = sourceType.type; - while (referencedType.kind === "ModelProperty" || referencedType.kind === "UnionVariant") { + while ( + referencedType.kind === "ModelProperty" || + referencedType.kind === "UnionVariant" + ) { referencedType = (referencedType as any).type; } @@ -539,7 +557,10 @@ describe("type replacement (@alternateType pattern)", () => { traits?: MutationTraits, ): MutationInfo | AlternateTypeVariantViaInfo { let referencedType: Type = sourceType.type; - while (referencedType.kind === "ModelProperty" || referencedType.kind === "UnionVariant") { + while ( + referencedType.kind === "ModelProperty" || + referencedType.kind === "UnionVariant" + ) { referencedType = (referencedType as any).type; } @@ -574,3 +595,161 @@ describe("type replacement (@alternateType pattern)", () => { }); }); }); + +/** + * Regression tests for the "properties missing" bug when using alternate types. + * + * The bug scenario: a downstream library (e.g. CDK) routes ARM and client type + * edges independently — the ARM edge follows the original source reference while + * the client edge should follow an alternate type (e.g. from @alternateType). + * + * When only a single type edge is wired to the original model, the alternate + * model is never traversed by the mutation engine, so its properties are never + * processed and are "missing" from the mutation graph. + * + * The fix is to use separate half-edges: one for the ARM path (original type) + * and one for the client path (alternate type via replaceAndMutateReference with + * a non-member half-edge kind such as "type-client"). + */ +describe("alternate type properties missing regression", () => { + it("alternate model properties are accessible when using dual-edge mutation (ARM + client paths)", async () => { + const { Foo, program, tk } = await compile(t.code` + model ${t.model("Foo")} { + prop: OriginalModel; + } + + model ${t.model("OriginalModel")} { + x: int32; + } + + model ${t.model("AlternateModel")} { + y: string; + } + `); + + const AlternateModel = program.getGlobalNamespaceType().models.get("AlternateModel")!; + + // Tracks the alternate type mutation captured from the client half-edge callback. + let capturedAlternateMutation: any | undefined; + + /** + * Simulates the CDK's ArmPropertyCanonicalization pattern: + * - ARM edge: always follows the original source reference (wire-level graph). + * - Client edge: follows the alternate type via replaceAndMutateReference, + * using a non-member half-edge kind ("type-client") so the engine routes + * directly to the alternate model instead of creating a stub member. + */ + class DualEdgePropMutation extends SimpleModelPropertyMutation { + mutate() { + // ARM edge: traverse original source reference. + this.type = this.engine.mutateReference( + this.sourceType, + this.options, + this.startTypeEdge(), + ) as any; + + // Client edge: traverse the alternate type. + // "type-client" is not a "property"/"variant"/"operation" member-edge + // kind, so replaceAndMutateReference routes directly to AlternateModel. + const clientHalfEdge = new MutationHalfEdge("type-client", this, (tail) => { + capturedAlternateMutation = tail; + }); + this.engine.replaceAndMutateReference( + this.sourceType, + AlternateModel, + this.options, + clientHalfEdge, + ); + } + } + + const engine = createPropertyEngine(tk, DualEdgePropMutation); + const fooMutation = engine.mutate(Foo); + const propMutation = fooMutation.properties.get("prop")!; + + // ARM path: property type is OriginalModel. + expectModelType(propMutation.mutatedType.type, "OriginalModel"); + + // Client path: AlternateModel's mutation must have been traversed. + expect(capturedAlternateMutation).toBeDefined(); + expect(capturedAlternateMutation!.kind).toBe("Model"); + expectModelType((capturedAlternateMutation as any).mutatedType, "AlternateModel"); + + // AlternateModel's properties must be present in its mutation. + const altProps = (capturedAlternateMutation as any).mutatedType.properties as Map< + string, + unknown + >; + expect(altProps.has("y")).toBe(true); + expect(altProps.has("x")).toBe(false); // x belongs to OriginalModel, not AlternateModel + }); + + it("properties of alternate model are missing when single type edge routes only to original model", async () => { + // This test documents the BUG scenario: when the property's type edge is + // connected only to the original model (as super.mutate() does), the alternate + // model is never traversed by the mutation engine. Callers that expect the + // alternate model's mutation to exist will not find its properties. + const { Foo, program, tk } = await compile(t.code` + model ${t.model("Foo")} { + prop: OriginalModel; + } + + model ${t.model("OriginalModel")} { + x: int32; + } + + model ${t.model("AlternateModel")} { + y: string; + } + `); + + const AlternateModel = program.getGlobalNamespaceType().models.get("AlternateModel")!; + + // Buggy mutation: only traverses the original source type; AlternateModel + // is never passed to the engine so no mutation is created for it. + class SingleEdgePropMutation extends SimpleModelPropertyMutation { + mutate() { + // Only the ARM (original) type is traversed — AlternateModel is ignored. + super.mutate(); + } + } + + const engine = createPropertyEngine(tk, SingleEdgePropMutation); + engine.mutate(Foo); + + // AlternateModel was never passed to the engine, so no mutation exists for it. + // Attempting to retrieve its mutation returns undefined (cache miss). + // The engine's internal cache should not contain a mutation for AlternateModel + // because the single-edge approach never traversed it. + // We verify this by checking the engine never produced a mutation for AlternateModel + // via engine.mutate directly. + const directMutation = (() => { + try { + // If AlternateModel had been traversed, it would already be cached. + // Calling engine.mutate now would create a NEW mutation (not the same one + // a dual-edge approach would produce at property-mutation time). + return engine.mutate(AlternateModel); + } catch { + return undefined; + } + })(); + + // After calling engine.mutate(AlternateModel) post-hoc, we get a mutation, + // but its properties are those of a fresh (uncustomized) traversal — not one + // that was produced in context alongside the property mutation. This shows + // that any context-sensitive processing of AlternateModel was skipped. + expect(directMutation).toBeDefined(); + expect((directMutation as any).mutatedType.properties.has("y")).toBe(true); + + // The key observation: the property's mutated type is still OriginalModel + // (not AlternateModel), confirming the alternate was never wired in. + const fooMutation = engine.mutate(Foo); + const propMutation = fooMutation.properties.get("prop")!; + expectModelType(propMutation.mutatedType.type, "OriginalModel"); + + // AlternateModel's properties are inaccessible through the property's type + // edge — the property still points to OriginalModel which has "x", not "y". + expect((propMutation.mutatedType.type as Model).properties.has("x")).toBe(true); + expect((propMutation.mutatedType.type as Model).properties.has("y")).toBe(false); + }); +}); From a6599b952ceed9cf5c055f5c76935d1d996d8101 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Tue, 26 May 2026 09:38:18 -0400 Subject: [PATCH 3/3] cleanup tests --- .../src/mutation-node/mutation-node.test.ts | 15 + .../src/mutation/alternate-type.test.ts | 411 ++++++++---------- 2 files changed, 191 insertions(+), 235 deletions(-) diff --git a/packages/mutator-framework/src/mutation-node/mutation-node.test.ts b/packages/mutator-framework/src/mutation-node/mutation-node.test.ts index 8dda9ffcbfc..9d15ff0ced6 100644 --- a/packages/mutator-framework/src/mutation-node/mutation-node.test.ts +++ b/packages/mutator-framework/src/mutation-node/mutation-node.test.ts @@ -109,3 +109,18 @@ it("clones synthetic mutation nodes", async () => { expect(propNode.mutatedType.type === model).toBe(false); expect(propNode.mutatedType.type === typeNode.mutatedType).toBe(true); }); + +it("creates a literal mutation node for StringTemplate types", async () => { + const { Foo, program } = await runner.compile(t.code` + model ${t.model("Foo")} { + prop: "Start \${123} end"; + } + `); + const engine = getEngine(program); + const prop = Foo.properties.get("prop")!; + + expect(prop.type.kind).toBe("StringTemplate"); + const node = engine.getMutationNode(prop.type); + expect(node).toBeDefined(); + expect(node.sourceType).toBe(prop.type); +}); diff --git a/packages/mutator-framework/src/mutation/alternate-type.test.ts b/packages/mutator-framework/src/mutation/alternate-type.test.ts index d4f918c5235..a21b05689e8 100644 --- a/packages/mutator-framework/src/mutation/alternate-type.test.ts +++ b/packages/mutator-framework/src/mutation/alternate-type.test.ts @@ -4,7 +4,9 @@ import type { Model, ModelProperty, Operation, + Scalar, Type, + Union, UnionVariant, } from "@typespec/compiler"; import { expectTypeEquals, t, type TemplateWithMarkers } from "@typespec/compiler/testing"; @@ -108,24 +110,95 @@ function createReplaceVariantMutation(getAlternate: (variant: UnionVariant) => T }; } +/** + * Creates a ModelProperty mutation class that replaces the property type from mutationInfo, + * using a type map to resolve alternates. This tests the interceptor pattern where + * replacement happens before the mutation is constructed. + */ +function createMutationInfoPropertyReplacement(alternateTypeMap: Map) { + return class AlternateTypePropertyViaInfo extends SimpleModelPropertyMutation { + static mutationInfo( + engine: SimpleMutationEngine<{ ModelProperty: AlternateTypePropertyViaInfo }>, + sourceType: ModelProperty, + referenceTypes: MemberType[], + options: SimpleMutationOptions, + halfEdge?: MutationHalfEdge, + traits?: MutationTraits, + ): MutationInfo | AlternateTypePropertyViaInfo { + let referencedType: Type = sourceType.type; + while (referencedType.kind === "ModelProperty" || referencedType.kind === "UnionVariant") { + referencedType = (referencedType as any).type; + } + + const alternate = alternateTypeMap.get(referencedType); + if (alternate) { + return engine.replaceAndMutateReference(sourceType, alternate, options, halfEdge) as any; + } + + return super.mutationInfo( + engine, + sourceType, + referenceTypes, + options, + halfEdge, + traits, + ) as MutationInfo; + } + }; +} + +/** + * Creates a UnionVariant mutation class that replaces the variant type from mutationInfo, + * using a type map to resolve alternates. + */ +function createMutationInfoVariantReplacement(alternateTypeMap: Map) { + return class AlternateTypeVariantViaInfo extends SimpleUnionVariantMutation { + static mutationInfo( + engine: SimpleMutationEngine<{ UnionVariant: AlternateTypeVariantViaInfo }>, + sourceType: UnionVariant, + referenceTypes: MemberType[], + options: SimpleMutationOptions, + halfEdge?: MutationHalfEdge, + traits?: MutationTraits, + ): MutationInfo | AlternateTypeVariantViaInfo { + let referencedType: Type = sourceType.type; + while (referencedType.kind === "ModelProperty" || referencedType.kind === "UnionVariant") { + referencedType = (referencedType as any).type; + } + + const alternate = alternateTypeMap.get(referencedType); + if (alternate) { + return engine.replaceAndMutateReference(sourceType, alternate, options, halfEdge) as any; + } + + return super.mutationInfo( + engine, + sourceType, + referenceTypes, + options, + halfEdge, + traits, + ) as MutationInfo; + } + }; +} + function expectModelType(type: Type, name: string) { expect(type.kind).toBe("Model"); expect((type as Model).name).toBe(name); } /** - * These tests replicate the behavior of a downstream library's `@alternateType` decorator - * which replaces the type of a property/returnType/variant with a different type during mutation. - * The pattern is general: it's not specific to model properties but applies to any type reference - * (Operation.returnType, UnionVariant.type, etc.) + * Tests for the `@alternateType` pattern where a property/returnType/variant type is + * replaced with a different type during mutation. * - * Tests cover two approaches: - * 1. Using the mutation node's mutate + replaceAndMutateReference (full control) - * 2. Simply mutating the node's type (simpler approach that should also work) + * The `replaceAndMutateReference` method handles the case where the half-edge expects + * a member type (ModelProperty/UnionVariant) but the replacement is a different kind + * (e.g., Model). It creates a "stub member mutation" that wraps the replacement. */ describe("type replacement (@alternateType pattern)", () => { - describe("ModelProperty.type", () => { - it("replaces property type with a model type via mutationNode.mutate", async () => { + describe("ModelProperty.type via replaceAndMutateReference", () => { + it("replaces property type with a model type", async () => { const { Foo, Bar, tk } = await compile(t.code` model ${t.model("Foo")} { prop: string; @@ -146,37 +219,7 @@ describe("type replacement (@alternateType pattern)", () => { expect(Foo.properties.get("prop")!.type.kind).toBe("Scalar"); }); - it("replaces property type without replaceAndMutateReference (stub type on node)", async () => { - const { Foo, Bar, tk } = await compile(t.code` - model ${t.model("Foo")} { - prop: string; - } - - model ${t.model("Bar")} { - name: string; - } - `); - - // Simpler approach: just mutate the node's type directly without replaceAndMutateReference - class SimpleAlternateTypeProperty extends SimpleModelPropertyMutation { - mutate() { - this.mutationNode.mutate((prop) => { - prop.type = Bar; - }); - this.type = this.engine.mutate(Bar, this.options, this.startTypeEdge()); - } - } - - const engine = createPropertyEngine(tk, SimpleAlternateTypeProperty); - - const fooMutation = engine.mutate(Foo); - const propMutation = fooMutation.properties.get("prop")!; - - expectModelType(propMutation.mutatedType.type, "Bar"); - expect(Foo.properties.get("prop")!.type.kind).toBe("Scalar"); - }); - - it("replacement type that is also mutated in the graph shares the same instance", async () => { + it("replacement type referenced by multiple properties shares the same mutation instance", async () => { const { Foo, program, tk } = await compile(t.code` model ${t.model("Foo")} { prop1: Bar; @@ -203,27 +246,6 @@ describe("type replacement (@alternateType pattern)", () => { expectTypeEquals(prop1.mutatedType.type as Model, prop2.mutatedType.type as Model); }); - it("replaces property type with an external type not in the original subgraph", async () => { - const { Foo, External, tk } = await compile(t.code` - model ${t.model("Foo")} { - prop: string; - } - - model ${t.model("External")} { - id: int32; - } - `); - - const Mutation = createReplacePropertyMutation(() => External); - const engine = createPropertyEngine(tk, Mutation); - - const fooMutation = engine.mutate(Foo); - const propMutation = fooMutation.properties.get("prop")!; - - expectModelType(propMutation.mutatedType.type, "External"); - expect(Foo.properties.get("prop")!.type.kind).toBe("Scalar"); - }); - it("recursively processes the replacement type's properties through the mutator", async () => { const { Foo, program, tk } = await compile(t.code` model ${t.model("Foo")} { @@ -266,7 +288,81 @@ describe("type replacement (@alternateType pattern)", () => { expect(Bar.properties.get("inner")!.type.kind).toBe("Scalar"); }); - it("recursively processes replacement type using engine.mutate (without replaceAndMutateReference)", async () => { + it("replaces property type with a union type", async () => { + const { Foo, MyUnion, tk } = await compile(t.code` + model ${t.model("Foo")} { + prop: string; + } + + union ${t.union("MyUnion")} { + a: string; + b: int32; + } + `); + + const Mutation = createReplacePropertyMutation(() => MyUnion); + const engine = createPropertyEngine(tk, Mutation); + + const fooMutation = engine.mutate(Foo); + const propMutation = fooMutation.properties.get("prop")!; + + expect(propMutation.mutatedType.type.kind).toBe("Union"); + expect((propMutation.mutatedType.type as Union).name).toBe("MyUnion"); + expect(Foo.properties.get("prop")!.type.kind).toBe("Scalar"); + }); + + it("replaces property type with a scalar type", async () => { + const { Foo, program, tk } = await compile(t.code` + model ${t.model("Foo")} { + prop: string; + } + + scalar ${t.scalar("MyScalar")}; + `); + + const MyScalar = program.getGlobalNamespaceType().scalars.get("MyScalar")!; + const Mutation = createReplacePropertyMutation(() => MyScalar); + const engine = createPropertyEngine(tk, Mutation); + + const fooMutation = engine.mutate(Foo); + const propMutation = fooMutation.properties.get("prop")!; + + expect(propMutation.mutatedType.type.kind).toBe("Scalar"); + expect((propMutation.mutatedType.type as Scalar).name).toBe("MyScalar"); + }); + }); + + describe("ModelProperty.type via engine.mutate (direct)", () => { + it("replaces property type directly via engine.mutate", async () => { + const { Foo, Bar, tk } = await compile(t.code` + model ${t.model("Foo")} { + prop: string; + } + + model ${t.model("Bar")} { + name: string; + } + `); + + class DirectMutateProperty extends SimpleModelPropertyMutation { + mutate() { + this.mutationNode.mutate((prop) => { + prop.type = Bar; + }); + this.type = this.engine.mutate(Bar, this.options, this.startTypeEdge()); + } + } + + const engine = createPropertyEngine(tk, DirectMutateProperty); + + const fooMutation = engine.mutate(Foo); + const propMutation = fooMutation.properties.get("prop")!; + + expectModelType(propMutation.mutatedType.type, "Bar"); + expect(Foo.properties.get("prop")!.type.kind).toBe("Scalar"); + }); + + it("recursively processes replacement type's properties", async () => { const { Foo, program, tk } = await compile(t.code` model ${t.model("Foo")} { prop: string; @@ -289,8 +385,7 @@ describe("type replacement (@alternateType pattern)", () => { ["inner", Baz], ]); - // This version uses engine.mutate directly instead of replaceAndMutateReference - class SimpleRecursiveAlternateType extends SimpleModelPropertyMutation { + class DirectRecursiveMutateProperty extends SimpleModelPropertyMutation { mutate() { const alternate = alternateTypes.get(this.sourceType.name); if (alternate) { @@ -304,7 +399,7 @@ describe("type replacement (@alternateType pattern)", () => { } } - const engine = createPropertyEngine(tk, SimpleRecursiveAlternateType); + const engine = createPropertyEngine(tk, DirectRecursiveMutateProperty); const fooMutation = engine.mutate(Foo); const propMutation = fooMutation.properties.get("prop")!; @@ -319,13 +414,9 @@ describe("type replacement (@alternateType pattern)", () => { expect(Foo.properties.get("prop")!.type.kind).toBe("Scalar"); expect(Bar.properties.get("inner")!.type.kind).toBe("Scalar"); }); + }); - // This test exposes a bug: when `replaceAndMutateReference` is called from - // `mutationInfo` of a ModelProperty mutation, the parent Model's property edge - // expects a ModelPropertyMutationNode as its tail (to call `connectModel`), but - // the replacement creates a mutation for the alternate type (e.g., a ModelMutation) - // which doesn't satisfy that interface. The framework should handle this case - // so that `@alternateType`-style interceptors can work at the ModelProperty level. + describe("ModelProperty.type via mutationInfo interceptor", () => { it("replacement type resolved via mutationInfo has its properties processed by the mutator", async () => { const { Foo, program, tk } = await compile(t.code` model ${t.model("Foo")} { @@ -354,50 +445,13 @@ describe("type replacement (@alternateType pattern)", () => { const InnerOriginal = program.getGlobalNamespaceType().models.get("InnerOriginal")!; const InnerAlternate = program.getGlobalNamespaceType().models.get("InnerAlternate")!; - const alternateTypeMap = new Map([ + const alternateTypeMap = new Map([ [OriginalType, AlternateModel], [InnerOriginal, InnerAlternate], ]); - class AlternateTypePropertyViaInfo extends SimpleModelPropertyMutation { - static mutationInfo( - engine: SimpleMutationEngine<{ ModelProperty: AlternateTypePropertyViaInfo }>, - sourceType: ModelProperty, - referenceTypes: MemberType[], - options: SimpleMutationOptions, - halfEdge?: MutationHalfEdge, - traits?: MutationTraits, - ): MutationInfo | AlternateTypePropertyViaInfo { - let referencedType = sourceType.type; - while ( - referencedType.kind === "ModelProperty" || - referencedType.kind === "UnionVariant" - ) { - referencedType = (referencedType as any).type; - } - - const alternate = alternateTypeMap.get(referencedType as Model); - if (alternate) { - return engine.replaceAndMutateReference( - sourceType, - alternate, - options, - halfEdge, - ) as any; - } - - return super.mutationInfo( - engine, - sourceType, - referenceTypes, - options, - halfEdge, - traits, - ) as MutationInfo; - } - } - - const engine = createPropertyEngine(tk, AlternateTypePropertyViaInfo); + const Mutation = createMutationInfoPropertyReplacement(alternateTypeMap); + const engine = createPropertyEngine(tk, Mutation); const fooMutation = engine.mutate(Foo); const propMutation = fooMutation.properties.get("prop")!; @@ -502,7 +556,7 @@ describe("type replacement (@alternateType pattern)", () => { expect(MyUnion.variants.get("a")!.type.kind).toBe("Scalar"); }); - it("replaces variant type with an external type (shared across variants)", async () => { + it("replacement type shared across multiple variants uses same mutation instance", async () => { const { MyUnion, External, tk } = await compile(t.code` union ${t.union("MyUnion")} { a: string; @@ -524,9 +578,6 @@ describe("type replacement (@alternateType pattern)", () => { expectModelType(variantA.mutatedType.type, "External"); expectModelType(variantB.mutatedType.type, "External"); expectTypeEquals(variantA.mutatedType.type as Model, variantB.mutatedType.type as Model); - - expect(MyUnion.variants.get("a")!.type.kind).toBe("Scalar"); - expect(MyUnion.variants.get("b")!.type.kind).toBe("Scalar"); }); it("replacement type resolved via mutationInfo has its variants processed by the mutator", async () => { @@ -547,45 +598,8 @@ describe("type replacement (@alternateType pattern)", () => { const alternateTypeMap = new Map([[OriginalType, AlternateModel]]); - class AlternateTypeVariantViaInfo extends SimpleUnionVariantMutation { - static mutationInfo( - engine: SimpleMutationEngine<{ UnionVariant: AlternateTypeVariantViaInfo }>, - sourceType: UnionVariant, - referenceTypes: MemberType[], - options: SimpleMutationOptions, - halfEdge?: MutationHalfEdge, - traits?: MutationTraits, - ): MutationInfo | AlternateTypeVariantViaInfo { - let referencedType: Type = sourceType.type; - while ( - referencedType.kind === "ModelProperty" || - referencedType.kind === "UnionVariant" - ) { - referencedType = (referencedType as any).type; - } - - const alternate = alternateTypeMap.get(referencedType); - if (alternate) { - return engine.replaceAndMutateReference( - sourceType, - alternate, - options, - halfEdge, - ) as any; - } - - return super.mutationInfo( - engine, - sourceType, - referenceTypes, - options, - halfEdge, - traits, - ) as MutationInfo; - } - } - - const engine = createVariantEngine(tk, AlternateTypeVariantViaInfo); + const Mutation = createMutationInfoVariantReplacement(alternateTypeMap); + const engine = createVariantEngine(tk, Mutation); const unionMutation = engine.mutate(MyUnion); const variantA = unionMutation.variants.get("a")!; @@ -597,7 +611,7 @@ describe("type replacement (@alternateType pattern)", () => { }); /** - * Regression tests for the "properties missing" bug when using alternate types. + * Regression test for the "properties missing" bug when using alternate types. * * The bug scenario: a downstream library (e.g. CDK) routes ARM and client type * edges independently — the ARM edge follows the original source reference while @@ -611,8 +625,8 @@ describe("type replacement (@alternateType pattern)", () => { * and one for the client path (alternate type via replaceAndMutateReference with * a non-member half-edge kind such as "type-client"). */ -describe("alternate type properties missing regression", () => { - it("alternate model properties are accessible when using dual-edge mutation (ARM + client paths)", async () => { +describe("dual-edge mutation (ARM + client paths) regression", () => { + it("alternate model properties are accessible when using separate client half-edge", async () => { const { Foo, program, tk } = await compile(t.code` model ${t.model("Foo")} { prop: OriginalModel; @@ -629,13 +643,12 @@ describe("alternate type properties missing regression", () => { const AlternateModel = program.getGlobalNamespaceType().models.get("AlternateModel")!; - // Tracks the alternate type mutation captured from the client half-edge callback. let capturedAlternateMutation: any | undefined; /** * Simulates the CDK's ArmPropertyCanonicalization pattern: - * - ARM edge: always follows the original source reference (wire-level graph). - * - Client edge: follows the alternate type via replaceAndMutateReference, + * - ARM edge: follows original source reference (wire-level graph). + * - Client edge: follows alternate type via replaceAndMutateReference, * using a non-member half-edge kind ("type-client") so the engine routes * directly to the alternate model instead of creating a stub member. */ @@ -648,9 +661,7 @@ describe("alternate type properties missing regression", () => { this.startTypeEdge(), ) as any; - // Client edge: traverse the alternate type. - // "type-client" is not a "property"/"variant"/"operation" member-edge - // kind, so replaceAndMutateReference routes directly to AlternateModel. + // Client edge: non-member half-edge kind routes directly to AlternateModel. const clientHalfEdge = new MutationHalfEdge("type-client", this, (tail) => { capturedAlternateMutation = tail; }); @@ -670,86 +681,16 @@ describe("alternate type properties missing regression", () => { // ARM path: property type is OriginalModel. expectModelType(propMutation.mutatedType.type, "OriginalModel"); - // Client path: AlternateModel's mutation must have been traversed. + // Client path: AlternateModel's mutation was traversed and its properties are accessible. expect(capturedAlternateMutation).toBeDefined(); expect(capturedAlternateMutation!.kind).toBe("Model"); expectModelType((capturedAlternateMutation as any).mutatedType, "AlternateModel"); - // AlternateModel's properties must be present in its mutation. const altProps = (capturedAlternateMutation as any).mutatedType.properties as Map< string, unknown >; expect(altProps.has("y")).toBe(true); - expect(altProps.has("x")).toBe(false); // x belongs to OriginalModel, not AlternateModel - }); - - it("properties of alternate model are missing when single type edge routes only to original model", async () => { - // This test documents the BUG scenario: when the property's type edge is - // connected only to the original model (as super.mutate() does), the alternate - // model is never traversed by the mutation engine. Callers that expect the - // alternate model's mutation to exist will not find its properties. - const { Foo, program, tk } = await compile(t.code` - model ${t.model("Foo")} { - prop: OriginalModel; - } - - model ${t.model("OriginalModel")} { - x: int32; - } - - model ${t.model("AlternateModel")} { - y: string; - } - `); - - const AlternateModel = program.getGlobalNamespaceType().models.get("AlternateModel")!; - - // Buggy mutation: only traverses the original source type; AlternateModel - // is never passed to the engine so no mutation is created for it. - class SingleEdgePropMutation extends SimpleModelPropertyMutation { - mutate() { - // Only the ARM (original) type is traversed — AlternateModel is ignored. - super.mutate(); - } - } - - const engine = createPropertyEngine(tk, SingleEdgePropMutation); - engine.mutate(Foo); - - // AlternateModel was never passed to the engine, so no mutation exists for it. - // Attempting to retrieve its mutation returns undefined (cache miss). - // The engine's internal cache should not contain a mutation for AlternateModel - // because the single-edge approach never traversed it. - // We verify this by checking the engine never produced a mutation for AlternateModel - // via engine.mutate directly. - const directMutation = (() => { - try { - // If AlternateModel had been traversed, it would already be cached. - // Calling engine.mutate now would create a NEW mutation (not the same one - // a dual-edge approach would produce at property-mutation time). - return engine.mutate(AlternateModel); - } catch { - return undefined; - } - })(); - - // After calling engine.mutate(AlternateModel) post-hoc, we get a mutation, - // but its properties are those of a fresh (uncustomized) traversal — not one - // that was produced in context alongside the property mutation. This shows - // that any context-sensitive processing of AlternateModel was skipped. - expect(directMutation).toBeDefined(); - expect((directMutation as any).mutatedType.properties.has("y")).toBe(true); - - // The key observation: the property's mutated type is still OriginalModel - // (not AlternateModel), confirming the alternate was never wired in. - const fooMutation = engine.mutate(Foo); - const propMutation = fooMutation.properties.get("prop")!; - expectModelType(propMutation.mutatedType.type, "OriginalModel"); - - // AlternateModel's properties are inaccessible through the property's type - // edge — the property still points to OriginalModel which has "x", not "y". - expect((propMutation.mutatedType.type as Model).properties.has("x")).toBe(true); - expect((propMutation.mutatedType.type as Model).properties.has("y")).toBe(false); + expect(altProps.has("x")).toBe(false); }); });