diff --git a/.chronus/changes/ef-updates-2025-11-4-17-44-46.md b/.chronus/changes/ef-updates-2025-11-4-17-44-46.md new file mode 100644 index 00000000000..f790d7ec3d8 --- /dev/null +++ b/.chronus/changes/ef-updates-2025-11-4-17-44-46.md @@ -0,0 +1,8 @@ +--- +changeKind: breaking +packages: + - "@typespec/http-canonicalization" + - "@typespec/mutator-framework" +--- + +Many other bug fixes, removals, and additions as described in this pull request: https://github.com/microsoft/typespec/pull/9141 \ No newline at end of file diff --git a/.chronus/changes/ef-updates-2025-11-4-17-45-6.md b/.chronus/changes/ef-updates-2025-11-4-17-45-6.md new file mode 100644 index 00000000000..1a7262852a6 --- /dev/null +++ b/.chronus/changes/ef-updates-2025-11-4-17-45-6.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@typespec/emitter-framework" +--- + +Add an SCCSet class which incrementally calculates strongly connected components from types added to it and updates reactive arrays with the topologically ordered types and components. \ No newline at end of file diff --git a/.chronus/changes/updates-2025-10-11-11-38-50.md b/.chronus/changes/updates-2025-10-11-11-38-50.md new file mode 100644 index 00000000000..943280b4433 --- /dev/null +++ b/.chronus/changes/updates-2025-10-11-11-38-50.md @@ -0,0 +1,7 @@ +--- +changeKind: breaking +packages: + - "@typespec/mutator-framework" +--- + +Fix mutations not handling linkage to parent types (e.g. model property -> model). Remove mutation subgraph, nodes are now unique per (type, key) pair. Remove reference mutations, use a distinct key for references if needed. \ No newline at end of file diff --git a/.chronus/changes/updates-2025-10-11-11-39-17.md b/.chronus/changes/updates-2025-10-11-11-39-17.md new file mode 100644 index 00000000000..6846af685f1 --- /dev/null +++ b/.chronus/changes/updates-2025-10-11-11-39-17.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@typespec/http-canonicalization" +--- + +Fix canonicalization of merge patch models. \ No newline at end of file diff --git a/.chronus/changes/updates-2025-10-11-11-39-37.md b/.chronus/changes/updates-2025-10-11-11-39-37.md new file mode 100644 index 00000000000..f1578a2537b --- /dev/null +++ b/.chronus/changes/updates-2025-10-11-11-39-37.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@typespec/http-canonicalization" +--- + +Remove metadata properties from wire types. \ No newline at end of file diff --git a/packages/emitter-framework/src/core/index.ts b/packages/emitter-framework/src/core/index.ts index 2b854ed8c66..6b3d6fb8a5a 100644 --- a/packages/emitter-framework/src/core/index.ts +++ b/packages/emitter-framework/src/core/index.ts @@ -1,4 +1,6 @@ export * from "./components/index.js"; export * from "./context/index.js"; +export * from "./scc-set.js"; export * from "./transport-name-policy.js"; +export * from "./type-connector.js"; export * from "./write-output.js"; diff --git a/packages/emitter-framework/src/core/scc-set.test.ts b/packages/emitter-framework/src/core/scc-set.test.ts new file mode 100644 index 00000000000..19dbc746eba --- /dev/null +++ b/packages/emitter-framework/src/core/scc-set.test.ts @@ -0,0 +1,293 @@ +import { Tester } from "#test/test-host.js"; +import { computed } from "@alloy-js/core"; +import type { Type } from "@typespec/compiler"; +import { t } from "@typespec/compiler/testing"; +import { describe, expect, it, vi } from "vitest"; +import { SCCSet, type NestedArray, type SCCComponent } from "./scc-set.js"; +import { typeDependencyConnector } from "./type-connector.js"; + +describe("SCCSet", () => { + it("topologically orders items", () => { + const edges = new Map([ + ["model", ["serializer"]], + ["serializer", ["helpers"]], + ["helpers", []], + ]); + + const set = new SCCSet((item) => edges.get(item) ?? []); + set.add("model"); + set.add("serializer"); + set.add("helpers"); + + expect([...set.items]).toEqual(["helpers", "serializer", "model"]); + expect(componentValues(set)).toEqual(["helpers", "serializer", "model"]); + }); + + it("groups strongly connected components", () => { + const edges = new Map([ + ["a", ["b"]], + ["b", ["a", "c"]], + ["c", []], + ]); + + const set = new SCCSet((item) => edges.get(item) ?? []); + set.add("a"); + set.add("b"); + set.add("c"); + + expect(componentValues(set)).toEqual(["c", ["a", "b"]]); + expect(set.items).toEqual(["c", "a", "b"]); + }); + + it("defers placeholders until added", () => { + const edges = new Map([ + ["root", ["child"]], + ["child", []], + ]); + + const set = new SCCSet((item) => edges.get(item) ?? []); + + set.add("root"); + expect(set.items).toEqual(["root"]); + expect(componentValues(set)).toEqual(["root"]); + + set.add("child"); + expect(set.items).toEqual(["child", "root"]); + expect(componentValues(set)).toEqual(["child", "root"]); + }); + + it("surfaces reachable nodes when requested", () => { + const edges = new Map([ + ["root", ["child"]], + ["child", ["leaf"]], + ["leaf", []], + ]); + + const set = new SCCSet((item) => edges.get(item) ?? [], { includeReachable: true }); + set.add("root"); + + expect(set.items).toEqual(["leaf", "child", "root"]); + expect(componentValues(set)).toEqual(["leaf", "child", "root"]); + }); + + it("mutates arrays in place when adding", () => { + const edges = new Map([["only", []]]); + const connector = vi.fn((item: string) => edges.get(item) ?? []); + + const set = new SCCSet(connector); + set.add("only"); + + const firstItems = set.items; + const firstComponents = set.components; + + expect(set.items).toBe(firstItems); + expect(set.components).toBe(firstComponents); + expect(connector).toHaveBeenCalledTimes(1); + + set.add("late"); + expect(connector).toHaveBeenCalledTimes(2); + expect(set.items).toBe(firstItems); + expect(set.components).toBe(firstComponents); + expect(firstItems).toEqual(["only", "late"]); + expect(componentValuesFrom(firstComponents)).toEqual(["only", "late"]); + }); + + it("notifies computed observers", () => { + const edges = new Map([ + ["model", ["serializer"]], + ["serializer", ["helpers"]], + ["helpers", []], + ["cycle-a", ["cycle-b"]], + ["cycle-b", ["cycle-a"]], + ]); + + const set = new SCCSet((item) => edges.get(item) ?? []); + const observedItems = computed(() => [...set.items]); + const observedComponents = computed(() => componentValues(set)); + const observedCycle = computed(() => { + const cycle = set.components.find((component) => Array.isArray(component.value)); + if (!cycle || !Array.isArray(cycle.value)) { + return []; + } + return [...cycle.value]; + }); + + expect(observedItems.value).toEqual([]); + expect(observedComponents.value).toEqual([]); + expect(observedCycle.value).toEqual([]); + + set.add("model"); + expect(observedItems.value).toEqual(["model"]); + expect(observedComponents.value).toEqual(["model"]); + + set.add("serializer"); + expect(observedItems.value).toEqual(["serializer", "model"]); + expect(observedComponents.value).toEqual(["serializer", "model"]); + + set.add("helpers"); + expect(observedItems.value).toEqual(["helpers", "serializer", "model"]); + expect(observedComponents.value).toEqual(["helpers", "serializer", "model"]); + + set.add("cycle-a"); + expect(observedCycle.value).toEqual([]); + + set.add("cycle-b"); + expect(observedCycle.value).toEqual(["cycle-a", "cycle-b"]); + }); + + it("orders dependent nodes even when added out of order", () => { + const edges = new Map([ + ["Leaf", []], + ["Indexed", ["Record"]], + ["Record", ["Leaf"]], + ["Base", ["Leaf"]], + ["Derived", ["Base", "Indexed"]], + ["CycleA", ["CycleB"]], + ["CycleB", ["CycleA"]], + ]); + + const insertionOrder = ["Derived", "Indexed", "Leaf", "Base", "CycleA", "CycleB"]; + const set = new SCCSet((item) => edges.get(item) ?? []); + for (const item of insertionOrder) { + set.add(item); + } + + expect([...set.items]).toEqual(["Leaf", "Indexed", "Base", "Derived", "CycleA", "CycleB"]); + expect(componentValues(set)).toEqual([ + "Leaf", + "Indexed", + "Base", + "Derived", + ["CycleA", "CycleB"], + ]); + }); + + it("batch adds nodes and recomputes once", () => { + const edges = new Map([ + ["Leaf", []], + ["Indexed", ["Record"]], + ["Record", ["Leaf"]], + ["Base", ["Leaf"]], + ["Derived", ["Base", "Indexed"]], + ["CycleA", ["CycleB"]], + ["CycleB", ["CycleA"]], + ]); + + const insertionOrder = ["Derived", "Indexed", "Leaf", "Base", "CycleA", "CycleB"]; + const set = new SCCSet((item) => edges.get(item) ?? []); + set.addAll(insertionOrder); + + expect([...set.items]).toEqual(["Leaf", "Indexed", "Base", "Derived", "CycleA", "CycleB"]); + expect(componentValues(set)).toEqual([ + "Leaf", + "Indexed", + "Base", + "Derived", + ["CycleA", "CycleB"], + ]); + }); + + it("exposes component connections", () => { + const edges = new Map([ + ["Leaf", []], + ["Base", ["Leaf"]], + ["Indexed", ["Leaf"]], + ["Derived", ["Base", "Indexed"]], + ["CycleA", ["CycleB"]], + ["CycleB", ["CycleA"]], + ]); + + const set = new SCCSet((item) => edges.get(item) ?? []); + set.addAll(["Derived", "Base", "Indexed", "Leaf", "CycleA", "CycleB"]); + + const getSingleton = (name: string) => + set.components.find((component) => component.value === name)!; + const format = (components: Iterable>) => + Array.from(components, (component) => + Array.isArray(component.value) ? component.value.join(",") : component.value, + ).sort(); + + const derived = getSingleton("Derived"); + const base = getSingleton("Base"); + const indexed = getSingleton("Indexed"); + const leaf = getSingleton("Leaf"); + const cycle = set.components.find((component) => Array.isArray(component.value))!; + + expect(format(derived.references)).toEqual(["Base", "Indexed"]); + expect(format(base.references)).toEqual(["Leaf"]); + expect(format(base.referencedBy)).toEqual(["Derived"]); + expect(format(indexed.referencedBy)).toEqual(["Derived"]); + expect(format(leaf.referencedBy)).toEqual(["Base", "Indexed"]); + expect(format(cycle.references)).toEqual([]); + expect(format(cycle.referencedBy)).toEqual([]); + }); + + it("orders TypeSpec models via connector", async () => { + const tester = await Tester.createInstance(); + const { Leaf, Indexed, Base, Derived, CycleA, CycleB } = await tester.compile( + t.code` + @test model ${t.model("Leaf")} { + value: string; + } + + @test model ${t.model("Indexed")} extends Record {} + + @test model ${t.model("Base")} { + leaf: Leaf; + } + + @test model ${t.model("Derived")} extends Base { + payload: Indexed; + } + + @test model ${t.model("CycleA")} { + next: CycleB; + } + + @test model ${t.model("CycleB")} { + prev: CycleA; + } + `, + ); + + const models = [Derived, Indexed, Leaf, Base, CycleA, CycleB] as Type[]; + const set = new SCCSet(typeDependencyConnector); + for (const type of models) { + set.add(type); + } + + const itemNames = set.items.map(getTypeLabel); + expect(itemNames).toEqual(["Leaf", "Indexed", "Base", "Derived", "CycleA", "CycleB"]); + + const componentNames = set.components.map(formatComponent); + expect(componentNames).toEqual(["Leaf", "Indexed", "Base", "Derived", ["CycleA", "CycleB"]]); + }); +}); + +function componentValues(set: SCCSet): NestedArray[] { + return componentValuesFrom(set.components); +} + +function componentValuesFrom(components: readonly SCCComponent[]): NestedArray[] { + return components.map((component) => component.value); +} + +function getTypeLabel(type: Type): string { + if ("name" in type && typeof type.name === "string" && type.name) { + return type.name; + } + return type.kind; +} + +type ComponentLabel = string | string[]; + +function formatComponent(component: SCCComponent): ComponentLabel { + return formatComponentValue(component.value); +} + +function formatComponentValue(componentValue: NestedArray): ComponentLabel { + if (Array.isArray(componentValue)) { + return (componentValue as Type[]).map(getTypeLabel); + } + return getTypeLabel(componentValue); +} diff --git a/packages/emitter-framework/src/core/scc-set.ts b/packages/emitter-framework/src/core/scc-set.ts new file mode 100644 index 00000000000..dafd907c1a0 --- /dev/null +++ b/packages/emitter-framework/src/core/scc-set.ts @@ -0,0 +1,777 @@ +import { shallowReactive } from "@alloy-js/core"; + +export type NestedArray = T | NestedArray[]; + +export interface SCCComponent { + /** + * Nested array representation of the items that belong to this component. + * Single-node components expose the item directly while cycles expose a nested array. + */ + readonly value: NestedArray; + /** Components that this component depends on (outgoing edges). */ + readonly references: ReadonlySet>; + /** Components that depend on this component (incoming edges). */ + readonly referencedBy: ReadonlySet>; +} + +type Connector = (item: T) => Iterable; + +export interface SCCSetOptions { + /** + * When true, every node reachable from an added node is automatically surfaced + * in the public `items`/`components` lists without requiring an explicit add. + */ + includeReachable?: boolean; +} + +interface ComponentRecord { + nodes: NodeRecord[]; + value: NestedArray; + size: number; + view: ComponentView; +} + +interface ComponentView extends SCCComponent { + readonly references: Set>; + readonly referencedBy: Set>; +} + +interface NodeRecord { + readonly item: T; + readonly neighbors: Set>; + readonly dependents: Set>; + added: boolean; + addedAt?: number; + component?: ComponentRecord; + initialized: boolean; +} + +interface RemovedComponent { + component: ComponentRecord; + index: number; +} + +/** + * Maintains a growing directed graph and exposes its strongly connected components (SCCs). + * + * The set incrementally applies Tarjan's algorithm so newly added nodes immediately update + * the public `items` and `components` views. Both arrays are shallow reactive so observers + * can hold references without re-fetching after each mutation. + */ +export class SCCSet { + /** + * Flattened, topologically ordered view of every node that has been added to the set. + * Nodes appear before dependents unless they belong to the same strongly connected component. + */ + public readonly items: T[]; + + /** + * Ordered strongly connected components that mirror `items`. Each entry exposes its members along + * with the components it depends on and the components that depend on it, enabling callers to walk + * the connectivity graph directly from any component. + */ + public readonly components: SCCComponent[]; + + readonly #nodes = new Map>(); + readonly #connector: Connector; + readonly #componentOrder: ComponentRecord[] = []; + #addCounter = 0; + readonly #includeReachable: boolean; + + /** + * Creates a new SCC set around the provided dependency connector function. + * @param connector Maps each item to the items it depends on (outgoing edges). + * @param options Controls automatic inclusion of reachable nodes. + */ + constructor(connector: Connector, options: SCCSetOptions = {}) { + this.#connector = connector; + this.items = shallowReactive([]); + this.components = shallowReactive[]>([]); + this.#includeReachable = !!options.includeReachable; + } + + /** + * Adds an item to the graph and captures its outgoing connections via the connector. + * Items can be referenced before they are added; they will only surface in the public + * views once explicitly added. + */ + public add(item: T): void { + const node = this.#getOrCreateNode(item); + if (node.added) { + return; + } + + node.added = true; + node.addedAt = this.#addCounter++; + this.#initializeNode(node, true); + + if (this.#includeReachable) { + this.#autoAddReachable(node); + this.#recomputeAll(); + } else { + this.#integrateNode(node); + } + } + + /** + * Adds multiple items and recomputes SCC ordering once at the end. + */ + public addAll(items: Iterable): void { + const newlyAdded: NodeRecord[] = []; + for (const item of items) { + const node = this.#getOrCreateNode(item); + if (node.added) { + continue; + } + node.added = true; + node.addedAt = this.#addCounter++; + this.#initializeNode(node, true); + newlyAdded.push(node); + } + + if (newlyAdded.length === 0) { + return; + } + + if (this.#includeReachable) { + for (const node of newlyAdded) { + this.#autoAddReachable(node); + } + } + + this.#recomputeAll(); + } + + /** + * Recursively adds every node reachable from the starting node, initializing metadata as needed. + */ + #autoAddReachable(start: NodeRecord): void { + const visited = new Set>([start]); + const stack = [...start.neighbors]; + + while (stack.length > 0) { + const current = stack.pop()!; + if (visited.has(current)) { + continue; + } + visited.add(current); + + if (!current.added) { + current.added = true; + current.addedAt = this.#addCounter++; + this.#initializeNode(current, true); + } + + for (const neighbor of current.neighbors) { + if (!visited.has(neighbor)) { + stack.push(neighbor); + } + } + } + } + + /** + * Retrieves the cached node for the provided item or materializes a new, uninitialized record. + */ + #getOrCreateNode(item: T): NodeRecord { + let existing = this.#nodes.get(item); + if (!existing) { + existing = { + item, + neighbors: new Set>(), + dependents: new Set>(), + added: false, + initialized: false, + } satisfies NodeRecord; + this.#nodes.set(item, existing); + } + return existing; + } + + /** + * Runs the connector for the node to refresh its neighbors and dependent relationships. + * @param force When true, existing neighbor edges are cleared before recomputing. + */ + #initializeNode(node: NodeRecord, force = false): void { + if (!force && node.initialized) { + return; + } + + if (force || node.initialized) { + for (const neighbor of node.neighbors) { + neighbor.dependents.delete(node); + } + node.neighbors.clear(); + } + + const dependencies = this.#connector(node.item); + node.initialized = true; + for (const dependency of dependencies) { + if (dependency === undefined) { + throw new Error( + `Connector returned undefined dependency while initializing ${String(node.item)}`, + ); + } + const neighbor = this.#getOrCreateNode(dependency); + node.neighbors.add(neighbor); + neighbor.dependents.add(node); + if (!neighbor.added) { + this.#initializeNode(neighbor); + } + } + } + + /** + * Inserts a node that was just added into the component ordering without recomputing the world. + */ + #integrateNode(node: NodeRecord): void { + const forward = this.#collectReachable(node, (current) => current.neighbors); + const backward = this.#collectReachable(node, (current) => current.dependents); + const candidates = new Set>(); + for (const seen of forward) { + if (backward.has(seen)) { + candidates.add(seen); + } + } + candidates.add(node); + + const orderedNodes = Array.from(candidates).sort( + (left, right) => (left.addedAt ?? 0) - (right.addedAt ?? 0), + ); + + const dependencyComponents = this.#collectNeighboringComponents( + orderedNodes, + (nodeRecord) => nodeRecord.neighbors, + candidates, + ); + const dependentComponents = this.#collectNeighboringComponents( + orderedNodes, + (nodeRecord) => nodeRecord.dependents, + candidates, + ); + + const dependentClosure = this.#collectDependentComponentClosure(orderedNodes, candidates); + const sortedDependents = this.#sortComponentsTopologically(dependentClosure); + + const insertIndexBeforeRemoval = this.#computeInsertIndex( + dependencyComponents, + dependentComponents, + ); + const componentsToRemove = new Set>(); + for (const member of candidates) { + if (member.component) { + componentsToRemove.add(member.component); + } + } + for (const component of dependentClosure) { + componentsToRemove.add(component); + } + + const removedComponents = this.#removeComponents(componentsToRemove); + const newComponent = this.#createComponent(orderedNodes); + const insertIndex = this.#adjustInsertIndex(insertIndexBeforeRemoval, removedComponents); + this.#insertComponent(newComponent, insertIndex); + + let nextIndex = insertIndex + 1; + for (const component of sortedDependents) { + this.#insertComponent(component, nextIndex++); + } + + this.#refreshComponentConnections(); + } + + /** + * Walks the graph in the provided direction to find all reachable, added nodes. + */ + #collectReachable( + start: NodeRecord, + next: (node: NodeRecord) => Iterable>, + ): Set> { + const visited = new Set>(); + const stack: NodeRecord[] = [start]; + while (stack.length > 0) { + const current = stack.pop()!; + if (visited.has(current) || !current.added) { + continue; + } + visited.add(current); + for (const neighbor of next(current)) { + if (neighbor.added && !visited.has(neighbor)) { + stack.push(neighbor); + } + } + } + return visited; + } + + /** + * Collects components adjacent to the provided nodes that are not part of an excluded set. + */ + #collectNeighboringComponents( + nodes: NodeRecord[], + next: (node: NodeRecord) => Iterable>, + excluded: Set>, + ): Set> { + const components = new Set>(); + for (const node of nodes) { + for (const neighbor of next(node)) { + if (!neighbor.added || excluded.has(neighbor) || !neighbor.component) { + continue; + } + components.add(neighbor.component); + } + } + return components; + } + + /** + * Computes the closure of components that depend (directly or indirectly) on the start nodes. + */ + #collectDependentComponentClosure( + startNodes: NodeRecord[], + excluded: Set>, + ): Set> { + const closure = new Set>(); + const visited = new Set>(); + const stack = [...startNodes]; + + while (stack.length > 0) { + const current = stack.pop()!; + if (visited.has(current)) { + continue; + } + visited.add(current); + + for (const dependent of current.dependents) { + if (excluded.has(dependent) || visited.has(dependent)) { + continue; + } + + if (dependent.added && dependent.component) { + if (!closure.has(dependent.component)) { + closure.add(dependent.component); + for (const member of dependent.component.nodes) { + stack.push(member); + } + } + } else { + stack.push(dependent); + } + } + } + + return closure; + } + + /** + * Sorts the provided components in topological order, falling back to insertion order on cycles. + */ + #sortComponentsTopologically(components: Set>): ComponentRecord[] { + if (components.size === 0) { + return []; + } + + const componentList = Array.from(components); + const inDegree = new Map, number>(); + const adjacency = new Map, Set>>(); + + for (const component of componentList) { + inDegree.set(component, 0); + adjacency.set(component, new Set()); + } + + for (const component of componentList) { + for (const node of component.nodes) { + for (const dependent of node.dependents) { + const dependentComponent = dependent.component; + if (!dependentComponent || dependentComponent === component) { + continue; + } + if (components.has(dependentComponent)) { + if (!adjacency.get(component)!.has(dependentComponent)) { + adjacency.get(component)!.add(dependentComponent); + inDegree.set(dependentComponent, (inDegree.get(dependentComponent) ?? 0) + 1); + } + } + } + } + } + + const queue = componentList + .filter((component) => (inDegree.get(component) ?? 0) === 0) + .sort((left, right) => this.#compareComponentAddedAt(left, right)); + const ordered: ComponentRecord[] = []; + while (queue.length > 0) { + const current = queue.shift()!; + ordered.push(current); + for (const neighbor of adjacency.get(current) ?? []) { + const remaining = (inDegree.get(neighbor) ?? 0) - 1; + inDegree.set(neighbor, remaining); + if (remaining === 0) { + queue.push(neighbor); + queue.sort((left, right) => this.#compareComponentAddedAt(left, right)); + } + } + } + + if (ordered.length !== components.size) { + return componentList.sort((left, right) => this.#compareComponentAddedAt(left, right)); + } + + return ordered; + } + + /** + * Orders components by the earliest time any of their nodes were added to the set. + */ + #compareComponentAddedAt(left: ComponentRecord, right: ComponentRecord): number { + return this.#getComponentAddedAt(left) - this.#getComponentAddedAt(right); + } + + /** + * Returns the earliest add-counter value for nodes within the component. + */ + #getComponentAddedAt(component: ComponentRecord): number { + return component.nodes.reduce( + (min, node) => Math.min(min, node.addedAt ?? Number.POSITIVE_INFINITY), + Number.POSITIVE_INFINITY, + ); + } + + /** + * Determines where a new component should be inserted so dependency ordering stays valid. + */ + #computeInsertIndex( + dependencies: Set>, + dependents: Set>, + ): number { + const dependencyIndex = dependencies.size + ? Math.max(...Array.from(dependencies, (component) => this.#getComponentIndex(component))) + : -1; + const lowerBound = dependencyIndex + 1; + if (!dependents.size) { + if (!dependencies.size) { + return this.#componentOrder.length; + } + return Math.min(lowerBound, this.#componentOrder.length); + } + + const upperBound = Math.min( + ...Array.from(dependents, (component) => this.#getComponentIndex(component)), + ); + if (upperBound < lowerBound) { + return lowerBound; + } + return upperBound; + } + + /** + * Retrieves the current ordering index for the component, throwing if it is unknown. + */ + #getComponentIndex(component: ComponentRecord): number { + const index = this.#componentOrder.indexOf(component); + if (index === -1) { + throw new Error("Component not found in order."); + } + return index; + } + + /** + * Calculates the position in `items` where the first element of a component at index would live. + */ + #getItemsStartIndexForInsert(targetIndex: number): number { + let start = 0; + for (let i = 0; i < targetIndex; i++) { + start += this.#componentOrder[i].size; + } + return start; + } + + /** + * Convenience helper for `getItemsStartIndexForInsert` that names the intent for existing indices. + */ + #getItemsStartIndexForIndex(componentIndex: number): number { + return this.#getItemsStartIndexForInsert(componentIndex); + } + + /** + * Removes the specified components from both the ordering list and flattened items array. + * Returns metadata describing what was removed so later insertions can adjust their offsets. + */ + #removeComponents(components: Set>): RemovedComponent[] { + if (components.size === 0) { + return []; + } + + const indexed = Array.from(components, (component) => ({ + component, + index: this.#getComponentIndex(component), + })).sort((a, b) => b.index - a.index); + + const removed: RemovedComponent[] = []; + for (const { component, index } of indexed) { + const startIndex = this.#getItemsStartIndexForIndex(index); + this.#componentOrder.splice(index, 1); + this.components.splice(index, 1); + this.items.splice(startIndex, component.size); + component.view.references.clear(); + component.view.referencedBy.clear(); + for (const node of component.nodes) { + node.component = undefined; + } + removed.push({ component, index }); + } + + removed.sort((a, b) => a.index - b.index); + return removed; + } + + /** + * Adjusts a desired insertion point to account for previously removed components. + */ + #adjustInsertIndex(desiredIndex: number, removed: RemovedComponent[]): number { + let shift = 0; + for (const removedComponent of removed) { + if (removedComponent.index < desiredIndex) { + shift++; + } + } + return Math.max(0, desiredIndex - shift); + } + + /** + * Inserts a component into the ordering and mirrors the change in the public lists. + */ + #insertComponent(component: ComponentRecord, index: number): void { + const startIndex = this.#getItemsStartIndexForInsert(index); + for (const node of component.nodes) { + node.component = component; + } + this.#componentOrder.splice(index, 0, component); + this.components.splice(index, 0, component.view); + const items = component.nodes.map((record) => record.item); + this.items.splice(startIndex, 0, ...items); + } + + /** + * Builds a component record for the provided nodes and assigns the back-reference on each node. + */ + #createComponent(nodes: NodeRecord[]): ComponentRecord { + const value = this.#createComponentValue(nodes); + const view: ComponentView = { + value, + references: new Set>(), + referencedBy: new Set>(), + }; + const component: ComponentRecord = { + nodes, + value, + size: nodes.length, + view, + }; + for (const node of nodes) { + node.component = component; + } + return component; + } + + /** + * Generates the structure stored in `components` for the given nodes (item vs. nested array). + */ + #createComponentValue(nodes: NodeRecord[]): NestedArray { + if (nodes.length === 1) { + return nodes[0].item; + } + const items = nodes.map((record) => record.item); + return shallowReactive(items) as NestedArray; + } + + /** + * Rebuilds the complete SCC ordering from scratch using Tarjan's algorithm and updates outputs. + */ + #recomputeAll(): void { + const nodes = Array.from(this.#nodes.values()).filter((node) => node.added); + for (const node of nodes) { + node.component = undefined; + } + + if (nodes.length === 0) { + for (const component of this.#componentOrder) { + component.view.references.clear(); + component.view.referencedBy.clear(); + } + this.#componentOrder.length = 0; + this.components.length = 0; + this.items.length = 0; + return; + } + + const indexMap = new Map, number>(); + const lowlinkMap = new Map, number>(); + const stack: NodeRecord[] = []; + const onStack = new Set>(); + let index = 0; + const components: ComponentRecord[] = []; + + const stronglyConnect = (node: NodeRecord): void => { + indexMap.set(node, index); + lowlinkMap.set(node, index); + index++; + stack.push(node); + onStack.add(node); + + for (const neighbor of node.neighbors) { + if (!neighbor.added) { + continue; + } + if (!indexMap.has(neighbor)) { + stronglyConnect(neighbor); + lowlinkMap.set(node, Math.min(lowlinkMap.get(node)!, lowlinkMap.get(neighbor)!)); + } else if (onStack.has(neighbor)) { + lowlinkMap.set(node, Math.min(lowlinkMap.get(node)!, indexMap.get(neighbor)!)); + } + } + + if (lowlinkMap.get(node) === indexMap.get(node)) { + const componentNodes: NodeRecord[] = []; + let member: NodeRecord; + do { + member = stack.pop()!; + onStack.delete(member); + componentNodes.push(member); + } while (member !== node); + componentNodes.sort((left, right) => (left.addedAt ?? 0) - (right.addedAt ?? 0)); + const component = this.#createComponent(componentNodes); + components.push(component); + } + }; + + for (const node of nodes) { + if (!indexMap.has(node)) { + stronglyConnect(node); + } + } + + const adjacency = new Map, Set>>(); + const inDegree = new Map, number>(); + for (const component of components) { + adjacency.set(component, new Set()); + inDegree.set(component, 0); + } + + for (const component of components) { + for (const node of component.nodes) { + const dependencyComponents = new Set>(); + const visitedNodes = new Set>(); + for (const neighbor of node.neighbors) { + this.#collectComponentDependencies(neighbor, dependencyComponents, visitedNodes); + } + for (const dependency of dependencyComponents) { + if (dependency === component) { + continue; + } + const dependents = adjacency.get(dependency)!; + if (!dependents.has(component)) { + dependents.add(component); + inDegree.set(component, (inDegree.get(component) ?? 0) + 1); + } + } + } + } + + const queue = components + .filter((component) => (inDegree.get(component) ?? 0) === 0) + .sort((left, right) => this.#compareComponentAddedAt(left, right)); + const orderedComponents: ComponentRecord[] = []; + while (queue.length > 0) { + const current = queue.shift()!; + orderedComponents.push(current); + for (const dependent of adjacency.get(current) ?? []) { + const nextDegree = (inDegree.get(dependent) ?? 0) - 1; + inDegree.set(dependent, nextDegree); + if (nextDegree === 0) { + queue.push(dependent); + queue.sort((left, right) => this.#compareComponentAddedAt(left, right)); + } + } + } + + if (orderedComponents.length !== components.length) { + orderedComponents.push( + ...components + .filter((component) => !orderedComponents.includes(component)) + .sort((left, right) => this.#compareComponentAddedAt(left, right)), + ); + } + + this.#componentOrder.splice(0, this.#componentOrder.length, ...orderedComponents); + this.components.splice( + 0, + this.components.length, + ...orderedComponents.map((component) => component.view), + ); + const flatItems: T[] = []; + for (const component of orderedComponents) { + for (const node of component.nodes) { + flatItems.push(node.item); + } + } + this.items.splice(0, this.items.length, ...flatItems); + + this.#refreshComponentConnections(); + } + + /** + * Traverses outward from a node to find components it ultimately depends on, even through + * nodes that are not yet part of the public set. + */ + #collectComponentDependencies( + node: NodeRecord, + collected: Set>, + visited: Set>, + ): void { + if (visited.has(node)) { + return; + } + visited.add(node); + + if (node.added) { + if (node.component) { + collected.add(node.component); + } + return; + } + + for (const neighbor of node.neighbors) { + this.#collectComponentDependencies(neighbor, collected, visited); + } + } + + /** + * Updates each public component view so callers can traverse the component graph without + * recomputing edges manually. + */ + #refreshComponentConnections(): void { + for (const component of this.#componentOrder) { + component.view.references.clear(); + component.view.referencedBy.clear(); + } + + for (const component of this.#componentOrder) { + for (const node of component.nodes) { + for (const neighbor of node.neighbors) { + if (!neighbor.added) { + continue; + } + const neighborComponent = neighbor.component; + if (!neighborComponent || neighborComponent === component) { + continue; + } + const dependencyView = neighborComponent.view; + component.view.references.add(dependencyView); + dependencyView.referencedBy.add(component.view); + } + } + } + } +} diff --git a/packages/emitter-framework/src/core/type-connector.ts b/packages/emitter-framework/src/core/type-connector.ts new file mode 100644 index 00000000000..adebe512625 --- /dev/null +++ b/packages/emitter-framework/src/core/type-connector.ts @@ -0,0 +1,67 @@ +import type { Type } from "@typespec/compiler"; + +/** + * Connector for {@link TypeSpec} {@link Type}s that captures the type graph edges used for + * topological ordering. Each returned dependency represents an incoming edge for the source + * type, e.g. a model depends on its base model, property types, and indexer key/value types. + */ +export function typeDependencyConnector(type: Type): Iterable { + return iterateTypeDependencies(type); +} + +function* iterateTypeDependencies(type: Type): IterableIterator { + switch (type.kind) { + case "Model": + if (type.baseModel) { + yield type.baseModel; + } + + if (type.indexer) { + yield type.indexer.key; + yield type.indexer.value; + } + + for (const property of type.properties.values()) { + yield property.type; + } + break; + case "ModelProperty": + yield type.type; + break; + case "Interface": + for (const operation of type.operations.values()) { + yield operation; + } + break; + case "Operation": + yield type.parameters; + yield type.returnType; + break; + case "Union": + for (const variant of type.variants.values()) { + yield variant; + } + break; + case "UnionVariant": + yield type.type; + break; + case "Tuple": + yield* type.values; + break; + case "Enum": + for (const member of type.members.values()) { + yield member; + } + break; + case "EnumMember": + if (type.sourceMember) { + yield type.sourceMember; + } + break; + case "Scalar": + if (type.baseScalar) { + yield type.baseScalar; + } + break; + } +} diff --git a/packages/http-canonicalization/CHANGELOG.md b/packages/http-canonicalization/CHANGELOG.md index e86e0848d9a..9818feb59a1 100644 --- a/packages/http-canonicalization/CHANGELOG.md +++ b/packages/http-canonicalization/CHANGELOG.md @@ -1,7 +1,5 @@ # Changelog - @typespec/http-canonicalization - - ## 0.13.0 ### Features @@ -15,4 +13,3 @@ ### Bug Fixes - [#8833](https://github.com/microsoft/typespec/pull/8833) Add forgotten index file. - diff --git a/packages/http-canonicalization/package.json b/packages/http-canonicalization/package.json index 76e2ebb55a8..dfb37b7263d 100644 --- a/packages/http-canonicalization/package.json +++ b/packages/http-canonicalization/package.json @@ -31,6 +31,7 @@ "license": "MIT", "description": "", "dependencies": { + "@typespec/emitter-framework": "workspace:^", "@typespec/mutator-framework": "workspace:^" }, "peerDependencies": { diff --git a/packages/http-canonicalization/src/codecs.ts b/packages/http-canonicalization/src/codecs.ts index 3e5f559be3f..261fc28e1a0 100644 --- a/packages/http-canonicalization/src/codecs.ts +++ b/packages/http-canonicalization/src/codecs.ts @@ -1,29 +1,38 @@ -import { getEncode, type MemberType, type Program, type Type } from "@typespec/compiler"; +import { + getEncode, + getFriendlyName, + type MemberType, + type ModelProperty, + type Program, + type Type, +} from "@typespec/compiler"; import type { Typekit } from "@typespec/compiler/typekit"; import { isHeader } from "@typespec/http"; -import type { HttpCanonicalization } from "./http-canonicalization-classes.js"; -export interface CodecEncodeResult { +export interface EncodingInfo { codec: Codec; - encodedType: Type; + languageType: Type; + wireType: Type; } + export class CodecRegistry { $: Typekit; - #codecs: (typeof Codec)[]; + #codecs: Codec[]; + constructor($: Typekit) { - this.#codecs = []; this.$ = $; + this.#codecs = []; } - addCodec(codec: typeof Codec) { + addCodec(codec: Codec) { this.#codecs.push(codec); } - detect(type: HttpCanonicalization): Codec { + encode(sourceType: Type, referenceTypes: MemberType[]): EncodingInfo { for (const codec of this.#codecs) { - const codecInstance = codec.detect(this.$, type); - if (codecInstance) { - return codecInstance; + const result = codec.encode(this.$, sourceType, referenceTypes); + if (result) { + return { codec, ...result }; } } @@ -33,19 +42,12 @@ export class CodecRegistry { export abstract class Codec { abstract id: string; - canonicalization: HttpCanonicalization; - $: Typekit; - - constructor($: Typekit, canonicalization: HttpCanonicalization) { - this.canonicalization = canonicalization; - this.$ = $; - } - static detect($: Typekit, canonicalization: HttpCanonicalization): Codec | undefined { - return undefined; - } - - abstract encode(): { languageType: Type; wireType: Type }; + abstract encode( + $: Typekit, + sourceType: Type, + referenceTypes: MemberType[], + ): { languageType: Type; wireType: Type } | null; static getMetadata< TTypeSource extends Type, @@ -84,103 +86,88 @@ export abstract class Codec { export class IdentityCodec extends Codec { readonly id = "identity"; - static detect($: Typekit, canonicalization: HttpCanonicalization) { - return new IdentityCodec($, canonicalization); - } - encode() { + encode($: Typekit, sourceType: Type, referenceTypes: MemberType[]) { return { - wireType: this.canonicalization.sourceType, - languageType: this.canonicalization.sourceType, + wireType: sourceType, + languageType: sourceType, }; } } export class UnixTimestamp64Codec extends Codec { readonly id = "unix-timestamp-64"; - static detect($: Typekit, canonicalization: HttpCanonicalization) { - const type = canonicalization.sourceType; - if (!$.scalar.is(type) || !$.type.isAssignableTo(type, $.builtin.utcDateTime)) { - return; + encode($: Typekit, sourceType: Type, referenceTypes: MemberType[]) { + if (!$.scalar.is(sourceType) || !$.type.isAssignableTo(sourceType, $.builtin.utcDateTime)) { + return null; } - const encodingInfo = this.getMetadata( + const encodingInfo = Codec.getMetadata( $, - type, - canonicalization.referenceTypes, + sourceType, + referenceTypes, $.modelProperty.is, getEncode, ); if (!encodingInfo) { - return; + return null; } if (encodingInfo.encoding === "unix-timestamp" && encodingInfo.type === $.builtin.int64) { - return new UnixTimestamp64Codec($, canonicalization); + return { + languageType: $.builtin.utcDateTime, + wireType: $.builtin.float64, + }; } - } - encode() { - return { - languageType: this.$.builtin.int64, - wireType: this.$.builtin.float64, - }; + return null; } } export class UnixTimestamp32Codec extends Codec { readonly id = "unix-timestamp-32"; - static detect($: Typekit, canonicalization: HttpCanonicalization) { - const type = canonicalization.sourceType; - if (!$.scalar.is(type) || !$.type.isAssignableTo(type, $.builtin.utcDateTime)) { - return; + encode($: Typekit, sourceType: Type, referenceTypes: MemberType[]) { + if (!$.scalar.is(sourceType) || !$.type.isAssignableTo(sourceType, $.builtin.utcDateTime)) { + return null; } - const encodingInfo = this.getMetadata( + const encodingInfo = Codec.getMetadata( $, - type, - canonicalization.referenceTypes, + sourceType, + referenceTypes, $.modelProperty.is, getEncode, ); if (!encodingInfo) { - return; + return null; } - if (encodingInfo.encoding === "unix-timestamp" && encodingInfo.type === $.builtin.int32) { - return new UnixTimestamp32Codec($, canonicalization); + if (encodingInfo.encoding === "unixTimestamp" && encodingInfo.type === $.builtin.int32) { + return { + languageType: $.builtin.utcDateTime, + wireType: $.builtin.float64, + }; } - } - encode() { - return { - languageType: this.$.builtin.int32, - wireType: this.$.builtin.float64, - }; + return null; } } export class Rfc3339Codec extends Codec { readonly id = "rfc3339"; - static detect($: Typekit, canonicalization: HttpCanonicalization) { - const type = canonicalization.sourceType; - - if (!$.scalar.is(type) || !$.type.isAssignableTo(type, $.builtin.utcDateTime)) { - return; + encode($: Typekit, sourceType: Type, referenceTypes: MemberType[]) { + if (!$.scalar.is(sourceType) || !$.type.isAssignableTo(sourceType, $.builtin.utcDateTime)) { + return null; } - return new Rfc3339Codec($, canonicalization); - } - - encode() { return { - languageType: this.canonicalization.sourceType, - wireType: this.$.builtin.string, + languageType: sourceType, + wireType: $.builtin.string, }; } } @@ -188,157 +175,190 @@ export class Rfc3339Codec extends Codec { export class Rfc7231Codec extends Codec { readonly id = "rfc7231"; - static detect($: Typekit, canonicalization: HttpCanonicalization) { - const type = canonicalization.sourceType; - - if (!$.scalar.is(type) || !$.type.isAssignableTo(type, $.builtin.utcDateTime)) { - return; + encode($: Typekit, sourceType: Type, referenceTypes: MemberType[]) { + if (!$.scalar.is(sourceType) || !$.type.isAssignableTo(sourceType, $.builtin.utcDateTime)) { + return null; } - const encodingInfo = this.getMetadata( + const encodingInfo = Codec.getMetadata( $, - type, - canonicalization.referenceTypes, + sourceType, + referenceTypes, $.modelProperty.is, getEncode, ); if (!encodingInfo) { - if ( - this.getMetadata( - $, - undefined, - canonicalization.referenceTypes, - $.modelProperty.is, - isHeader, - ) - ) { - return new Rfc7231Codec($, canonicalization); + if (Codec.getMetadata($, undefined, referenceTypes, $.modelProperty.is, isHeader)) { + return { + languageType: sourceType, + wireType: $.builtin.string, + }; } - return; + return null; } if (encodingInfo.encoding === "rfc7231") { - return new Rfc7231Codec($, canonicalization); + return { + languageType: sourceType, + wireType: $.builtin.string, + }; } - } - encode() { - return { - languageType: this.canonicalization.sourceType, - wireType: this.$.builtin.string, - }; + return null; } } export class Base64Codec extends Codec { readonly id = "base64"; - static detect($: Typekit, canonicalization: HttpCanonicalization) { - const type = canonicalization.sourceType; - - if (!$.type.isAssignableTo(type, $.builtin.bytes)) { - return; + encode($: Typekit, sourceType: Type, referenceTypes: MemberType[]) { + if (!$.type.isAssignableTo(sourceType, $.builtin.bytes)) { + return null; } - return new Base64Codec($, canonicalization); - } - - encode() { return { - languageType: this.canonicalization.sourceType, - wireType: this.$.builtin.string, + languageType: sourceType, + wireType: $.builtin.string, }; } } +export interface CoerceToFloat64CodecOptions { + lossyInteger?: boolean; + lossyDecimal?: boolean; +} + export class CoerceToFloat64Codec extends Codec { readonly id = "coerce-to-float64"; + options: CoerceToFloat64CodecOptions; + constructor(options: CoerceToFloat64CodecOptions = {}) { + super(); + this.options = options; + } - static detect($: Typekit, canonicalization: HttpCanonicalization) { - const type = canonicalization.sourceType; - - if (!$.type.isAssignableTo(type, $.builtin.numeric)) { - return; + encode($: Typekit, sourceType: Type, referenceTypes: MemberType[]) { + if ( + this.options.lossyInteger && + this.options.lossyDecimal && + !$.type.isAssignableTo(sourceType, $.builtin.numeric) + ) { + return null; + } else if ( + this.options.lossyInteger && + !$.scalar.extendsInteger(sourceType) && + !$.scalar.extendsFloat(sourceType) + ) { + return null; + } else if ( + this.options.lossyDecimal && + !$.scalar.extendsDecimal(sourceType) && + !$.scalar.extendsFloat(sourceType) + ) { + return null; + } else if ( + !$.scalar.extendsFloat(sourceType) && + !$.scalar.extendsInt32(sourceType) && + !$.scalar.extendsUint32(sourceType) + ) { + return null; } - return new CoerceToFloat64Codec($, canonicalization); - } - - encode() { return { - languageType: this.canonicalization.sourceType, - wireType: this.$.builtin.float64, + languageType: sourceType, + wireType: $.builtin.float64, }; } } -export class NumericToStringCodec extends Codec { - readonly id = "numeric-to-string"; - - static detect($: Typekit, canonicalization: HttpCanonicalization) { - const type = canonicalization.sourceType; +export class ArrayJoinCodec extends Codec { + readonly id = "array-join"; - if (!$.type.isAssignableTo(type, $.builtin.numeric)) { - return; + encode($: Typekit, sourceType: Type, referenceTypes: MemberType[]) { + if (!$.array.is(sourceType)) { + return null; } - return new NumericToStringCodec($, canonicalization); - } - - encode() { + // Note: This codec previously checked canonicalization.options.location + // This logic may need to be refactored to pass location info differently return { - languageType: this.canonicalization.sourceType, - wireType: this.$.builtin.string, + languageType: sourceType, + wireType: $.builtin.string, }; } } -export class ArrayJoinCodec extends Codec { - readonly id = "array-join"; - static detect($: Typekit, canonicalization: HttpCanonicalization) { - const type = canonicalization.sourceType; +interface RenameCodecOptions { + namer?(type: Type): string | undefined; +} +/** + * Renames the language type according to the language's name policy and + * friendly name decorator. + */ +export class RenameCodec extends Codec { + readonly id = "model-property-rename"; + options: RenameCodecOptions; + + constructor(options: RenameCodecOptions = {}) { + super(); + this.options = options; + } - if (!$.array.is(type)) { - return; + encode($: Typekit, sourceType: Type, referenceTypes: MemberType[]) { + if (!$.modelProperty.is(sourceType)) { + return null; } - if ( - canonicalization.options.location === "query" || - canonicalization.options.location === "header" || - canonicalization.options.location === "path" - ) { - return new ArrayJoinCodec($, canonicalization); + if (!("name" in sourceType)) { + return null; } - } - encode() { - return { - languageType: this.canonicalization.sourceType, - wireType: this.$.builtin.string, - }; + const friendlyName = getFriendlyName($.program, sourceType); + if (friendlyName && friendlyName !== sourceType.name) { + const clonedProp = $.type.clone(sourceType as ModelProperty); + clonedProp.name = friendlyName; + return { + languageType: clonedProp, + wireType: sourceType, + }; + } + + if (this.options.namer) { + const name = this.options.namer(sourceType); + if (name && name !== sourceType.name) { + const clonedProp = $.type.clone(sourceType as ModelProperty); + clonedProp.name = name; + $.type.finishType(clonedProp); + return { + languageType: clonedProp, + wireType: sourceType, + }; + } + } + + return null; } } -const jsonEncoderRegistryCache = new WeakMap(); - -export const getJsonEncoderRegistry = ($: Typekit) => { - if (jsonEncoderRegistryCache.has($.program)) { - return jsonEncoderRegistryCache.get($.program)!; +const jsonCodecRegistryCache = new WeakMap(); +export const getJsonCodecRegistry = ($: Typekit) => { + if (jsonCodecRegistryCache.has($.program)) { + return jsonCodecRegistryCache.get($.program)!; } const registry = new CodecRegistry($); - registry.addCodec(Rfc7231Codec); - registry.addCodec(Rfc3339Codec); - registry.addCodec(UnixTimestamp32Codec); - registry.addCodec(UnixTimestamp64Codec); - registry.addCodec(Base64Codec); - registry.addCodec(CoerceToFloat64Codec); - registry.addCodec(NumericToStringCodec); - registry.addCodec(ArrayJoinCodec); - registry.addCodec(IdentityCodec); - - jsonEncoderRegistryCache.set($.program, registry); + registry.addCodec(new CoerceToFloat64Codec()); + registry.addCodec(new Rfc7231Codec()); + registry.addCodec(new UnixTimestamp32Codec()); + registry.addCodec(new UnixTimestamp64Codec()); + registry.addCodec(new Rfc3339Codec()); + registry.addCodec(new Base64Codec()); + registry.addCodec(new Base64Codec()); + registry.addCodec(new ArrayJoinCodec()); + registry.addCodec(new RenameCodec()); + registry.addCodec(new IdentityCodec()); + + jsonCodecRegistryCache.set($.program, registry); return registry; }; diff --git a/packages/http-canonicalization/src/enum-member.test.ts b/packages/http-canonicalization/src/enum-member.test.ts new file mode 100644 index 00000000000..b2e3eb1b12a --- /dev/null +++ b/packages/http-canonicalization/src/enum-member.test.ts @@ -0,0 +1,75 @@ +import { t, type TesterInstance } from "@typespec/compiler/testing"; +import { $ } from "@typespec/compiler/typekit"; +import { Visibility } from "@typespec/http"; +import { beforeEach, expect, it } from "vitest"; +import { Tester } from "../test/test-host.js"; +import { EnumMemberHttpCanonicalization } from "./enum-member.js"; +import { EnumHttpCanonicalization } from "./enum.js"; +import { HttpCanonicalizer } from "./http-canonicalization.js"; + +let runner: TesterInstance; + +beforeEach(async () => { + runner = await Tester.createInstance(); +}); + +it("canonicalizes enum members correctly", async () => { + const { Color, program } = await runner.compile(t.code` + enum ${t.enum("Color")} { Red, Green, Blue } + `); + + const tk = $(program); + const engine = new HttpCanonicalizer(tk); + + const canonical = engine.canonicalize(Color, { + visibility: Visibility.Read, + }) as EnumHttpCanonicalization; + + const redMember = canonical.members.get("Red")!; + + expect(redMember).toBeInstanceOf(EnumMemberHttpCanonicalization); + expect(redMember.isDeclaration).toBe(true); + expect(redMember.codec).toBe(null); + + // Language and wire types should be the enum member + expect(redMember.languageType.name).toBe("Red"); + expect(redMember.wireType.name).toBe("Red"); +}); + +it("enum member has correct source type", async () => { + const { Status, program } = await runner.compile(t.code` + enum ${t.enum("Status")} { + Active: "active", + Inactive: "inactive" + } + `); + + const tk = $(program); + const engine = new HttpCanonicalizer(tk); + + const canonical = engine.canonicalize(Status, { + visibility: Visibility.Read, + }) as EnumHttpCanonicalization; + + const activeMember = canonical.members.get("Active")!; + + expect(activeMember.sourceType.name).toBe("Active"); + expect(activeMember.sourceType.value).toBe("active"); +}); + +it("enum member uses identity codec for subgraph", async () => { + const { Color, program } = await runner.compile(t.code` + enum ${t.enum("Color")} { Red, Green, Blue } + `); + + const tk = $(program); + const engine = new HttpCanonicalizer(tk); + + const canonical = engine.canonicalize(Color, { + visibility: Visibility.Read, + }) as EnumHttpCanonicalization; + + const redMember = canonical.members.get("Red")!; + + expect(redMember.subgraphUsesIdentityCodec()).toBe(true); +}); diff --git a/packages/http-canonicalization/src/enum-member.ts b/packages/http-canonicalization/src/enum-member.ts new file mode 100644 index 00000000000..a5ef40625a5 --- /dev/null +++ b/packages/http-canonicalization/src/enum-member.ts @@ -0,0 +1,109 @@ +import type { EnumMember, MemberType } from "@typespec/compiler"; +import { + EnumMemberMutation, + MutationHalfEdge, + type MutationNodeForType, + type MutationTraits, +} from "@typespec/mutator-framework"; +import type { Codec } from "./codecs.js"; +import type { HttpCanonicalizationMutations } from "./http-canonicalization-classes.js"; +import type { + CanonicalizationPredicate, + HttpCanonicalizationCommon, + HttpCanonicalizationInfo, + HttpCanonicalizer, +} from "./http-canonicalization.js"; +import { HttpCanonicalizationOptions } from "./options.js"; + +/** + * Canonicalizes enum member types for HTTP. + */ +export class EnumMemberHttpCanonicalization + extends EnumMemberMutation< + HttpCanonicalizationOptions, + HttpCanonicalizationMutations, + HttpCanonicalizer + > + implements HttpCanonicalizationCommon +{ + isDeclaration: boolean = false; + codec: Codec | null = null; + + #languageMutationNode: MutationNodeForType; + #wireMutationNode: MutationNodeForType; + + get languageMutationNode() { + return this.#languageMutationNode; + } + + get wireMutationNode() { + return this.#wireMutationNode; + } + + get languageType() { + return this.#languageMutationNode.mutatedType; + } + + get wireType() { + return this.#wireMutationNode.mutatedType; + } + + /** + * Tests whether the subgraph rooted at this canonicalization uses only + * the identity codec (no transformation). + */ + subgraphUsesIdentityCodec(): boolean { + return this.engine.subgraphUsesIdentityCodec(this); + } + + /** + * Tests whether the subgraph rooted at this canonicalization satisfies + * the provided predicate. + */ + subgraphMatchesPredicate(predicate: CanonicalizationPredicate): boolean { + return this.engine.subgraphMatchesPredicate(this, predicate); + } + + static mutationInfo( + engine: HttpCanonicalizer, + sourceType: EnumMember, + referenceTypes: MemberType[], + options: HttpCanonicalizationOptions, + halfEdge?: MutationHalfEdge, + traits?: MutationTraits, + ): HttpCanonicalizationInfo { + return { + mutationKey: options.mutationKey, + codec: null as any, // EnumMembers don't need a codec + isSynthetic: traits?.isSynthetic, + }; + } + + constructor( + engine: HttpCanonicalizer, + sourceType: EnumMember, + referenceTypes: MemberType[], + options: HttpCanonicalizationOptions, + info: HttpCanonicalizationInfo, + ) { + super(engine, sourceType, referenceTypes, options, info); + this.options = options; + this.#languageMutationNode = this.engine.getMutationNode( + this.sourceType, + info.mutationKey + "-language", + ); + this.#wireMutationNode = this.engine.getMutationNode( + this.sourceType, + info.mutationKey + "-wire", + ); + this.isDeclaration = !!this.sourceType.name; + } + + /** + * Canonicalize this enum member for HTTP. + * EnumMember is a leaf type with no children to mutate. + */ + mutate() { + // EnumMember is a leaf type with no children to mutate + } +} diff --git a/packages/http-canonicalization/src/enum.test.ts b/packages/http-canonicalization/src/enum.test.ts new file mode 100644 index 00000000000..ba2eadfac6e --- /dev/null +++ b/packages/http-canonicalization/src/enum.test.ts @@ -0,0 +1,148 @@ +import { expectTypeEquals, t, type TesterInstance } from "@typespec/compiler/testing"; +import { $ } from "@typespec/compiler/typekit"; +import { Visibility } from "@typespec/http"; +import { beforeEach, expect, it } from "vitest"; +import { Tester } from "../test/test-host.js"; +import { EnumMemberHttpCanonicalization } from "./enum-member.js"; +import { EnumHttpCanonicalization } from "./enum.js"; +import { HttpCanonicalizer } from "./http-canonicalization.js"; + +let runner: TesterInstance; + +beforeEach(async () => { + runner = await Tester.createInstance(); +}); + +it("canonicalizes a simple enum", async () => { + const { Color, program } = await runner.compile(t.code` + enum ${t.enum("Color")} { Red, Green, Blue } + `); + + const tk = $(program); + const engine = new HttpCanonicalizer(tk); + + const canonical = engine.canonicalize(Color, { + visibility: Visibility.Read, + }); + + expect(canonical).toBeInstanceOf(EnumHttpCanonicalization); + const enumCanonical = canonical as EnumHttpCanonicalization; + + // The source type should be the original enum + expectTypeEquals(enumCanonical.sourceType, Color); + + // Language and wire types should be identical for a simple enum + expectTypeEquals(enumCanonical.languageType, enumCanonical.wireType); + + // Should be a declaration + expect(enumCanonical.isDeclaration).toBe(true); + + // Should have three members + expect(enumCanonical.members.size).toBe(3); + expect(enumCanonical.members.has("Red")).toBe(true); + expect(enumCanonical.members.has("Green")).toBe(true); + expect(enumCanonical.members.has("Blue")).toBe(true); +}); + +it("canonicalizes an enum with string values", async () => { + const { Status, program } = await runner.compile(t.code` + enum ${t.enum("Status")} { + Active: "active", + Inactive: "inactive", + Pending: "pending" + } + `); + + const tk = $(program); + const engine = new HttpCanonicalizer(tk); + + const canonical = engine.canonicalize(Status, { + visibility: Visibility.Read, + }) as EnumHttpCanonicalization; + + expect(canonical.members.size).toBe(3); + + const activeCanonical = canonical.members.get("Active"); + expect(activeCanonical).toBeInstanceOf(EnumMemberHttpCanonicalization); + expect(activeCanonical!.sourceType.value).toBe("active"); +}); + +it("canonicalizes an enum with numeric values", async () => { + const { Priority, program } = await runner.compile(t.code` + enum ${t.enum("Priority")} { + Low: 1, + Medium: 2, + High: 3 + } + `); + + const tk = $(program); + const engine = new HttpCanonicalizer(tk); + + const canonical = engine.canonicalize(Priority, { + visibility: Visibility.Read, + }) as EnumHttpCanonicalization; + + expect(canonical.members.size).toBe(3); + + const lowCanonical = canonical.members.get("Low"); + expect(lowCanonical).toBeInstanceOf(EnumMemberHttpCanonicalization); + expect(lowCanonical!.sourceType.value).toBe(1); + + const highCanonical = canonical.members.get("High"); + expect(highCanonical!.sourceType.value).toBe(3); +}); + +it("enum has no codec", async () => { + const { Color, program } = await runner.compile(t.code` + enum ${t.enum("Color")} { Red, Green, Blue } + `); + + const tk = $(program); + const engine = new HttpCanonicalizer(tk); + + const canonical = engine.canonicalize(Color, { + visibility: Visibility.Read, + }) as EnumHttpCanonicalization; + + expect(canonical.codec).toBe(null); +}); + +it("uses identity codec for subgraph", async () => { + const { Color, program } = await runner.compile(t.code` + enum ${t.enum("Color")} { Red, Green, Blue } + `); + + const tk = $(program); + const engine = new HttpCanonicalizer(tk); + + const canonical = engine.canonicalize(Color, { + visibility: Visibility.Read, + }) as EnumHttpCanonicalization; + + expect(canonical.subgraphUsesIdentityCodec()).toBe(true); +}); + +it("canonicalizes enum used in model property", async () => { + const { Item, program } = await runner.compile(t.code` + enum ${t.enum("Status")} { Active, Inactive } + model ${t.model("Item")} { + status: Status; + } + `); + + const tk = $(program); + const engine = new HttpCanonicalizer(tk); + + const canonical = engine.canonicalize(Item, { + visibility: Visibility.Read, + }); + + const statusProp = canonical.properties.get("status")!; + expect(statusProp.type).toBeInstanceOf(EnumHttpCanonicalization); + + const enumType = statusProp.type as EnumHttpCanonicalization; + expect(enumType.members.size).toBe(2); + expect(enumType.members.has("Active")).toBe(true); + expect(enumType.members.has("Inactive")).toBe(true); +}); diff --git a/packages/http-canonicalization/src/enum.ts b/packages/http-canonicalization/src/enum.ts new file mode 100644 index 00000000000..0adda9edadb --- /dev/null +++ b/packages/http-canonicalization/src/enum.ts @@ -0,0 +1,115 @@ +import type { Enum, MemberType } from "@typespec/compiler"; +import { + EnumMutation, + MutationHalfEdge, + type MutationNodeForType, + type MutationTraits, +} from "@typespec/mutator-framework"; +import type { Codec } from "./codecs.js"; +import type { HttpCanonicalizationMutations } from "./http-canonicalization-classes.js"; +import type { + CanonicalizationPredicate, + HttpCanonicalizationCommon, + HttpCanonicalizationInfo, + HttpCanonicalizer, +} from "./http-canonicalization.js"; +import { HttpCanonicalizationOptions } from "./options.js"; + +/** + * Canonicalizes enum types for HTTP. + */ +export class EnumHttpCanonicalization + extends EnumMutation< + HttpCanonicalizationOptions, + HttpCanonicalizationMutations, + HttpCanonicalizer + > + implements HttpCanonicalizationCommon +{ + isDeclaration: boolean = false; + codec: Codec | null = null; + + #languageMutationNode: MutationNodeForType; + #wireMutationNode: MutationNodeForType; + + get languageMutationNode() { + return this.#languageMutationNode; + } + + get wireMutationNode() { + return this.#wireMutationNode; + } + + get languageType() { + return this.#languageMutationNode.mutatedType; + } + + get wireType() { + return this.#wireMutationNode.mutatedType; + } + + /** + * Tests whether the subgraph rooted at this canonicalization uses only + * the identity codec (no transformation). + */ + subgraphUsesIdentityCodec(): boolean { + return this.engine.subgraphUsesIdentityCodec(this); + } + + /** + * Tests whether the subgraph rooted at this canonicalization satisfies + * the provided predicate. + */ + subgraphMatchesPredicate(predicate: CanonicalizationPredicate): boolean { + return this.engine.subgraphMatchesPredicate(this, predicate); + } + + static mutationInfo( + engine: HttpCanonicalizer, + sourceType: Enum, + referenceTypes: MemberType[], + options: HttpCanonicalizationOptions, + halfEdge?: MutationHalfEdge, + traits?: MutationTraits, + ): HttpCanonicalizationInfo { + return { + mutationKey: options.mutationKey, + codec: null as any, // Enums don't need a codec + isSynthetic: traits?.isSynthetic, + }; + } + + constructor( + engine: HttpCanonicalizer, + sourceType: Enum, + referenceTypes: MemberType[], + options: HttpCanonicalizationOptions, + info: HttpCanonicalizationInfo, + ) { + super(engine, sourceType, referenceTypes, options, info); + this.options = options; + this.#languageMutationNode = this.engine.getMutationNode(this.sourceType, { + mutationKey: info.mutationKey + "-language", + isSynthetic: info.isSynthetic, + }); + this.#wireMutationNode = this.engine.getMutationNode(this.sourceType, { + mutationKey: info.mutationKey + "-wire", + isSynthetic: info.isSynthetic, + }); + this.isDeclaration = !!this.sourceType.name; + } + + protected startMemberEdge(): MutationHalfEdge { + return new MutationHalfEdge("member", this, (tail) => { + this.#languageMutationNode.connectMember(tail.languageMutationNode); + this.#wireMutationNode.connectMember(tail.wireMutationNode); + }); + } + + /** + * Canonicalize this enum for HTTP. + */ + mutate() { + super.mutateMembers(); + } +} diff --git a/packages/http-canonicalization/src/http-canonicalization-classes.ts b/packages/http-canonicalization/src/http-canonicalization-classes.ts index 7de9ca5bf62..1d0acd49a0c 100644 --- a/packages/http-canonicalization/src/http-canonicalization-classes.ts +++ b/packages/http-canonicalization/src/http-canonicalization-classes.ts @@ -1,4 +1,6 @@ import type { InstancesFor } from "@typespec/mutator-framework"; +import { EnumMemberHttpCanonicalization } from "./enum-member.js"; +import { EnumHttpCanonicalization } from "./enum.js"; import { IntrinsicHttpCanonicalization } from "./intrinsic.js"; import { LiteralHttpCanonicalization } from "./literal.js"; import { ModelPropertyHttpCanonicalization } from "./model-property.js"; @@ -16,6 +18,8 @@ export const CANONICALIZATION_CLASSES = { Union: UnionHttpCanonicalization, Intrinsic: IntrinsicHttpCanonicalization, UnionVariant: UnionVariantHttpCanonicalization, + Enum: EnumHttpCanonicalization, + EnumMember: EnumMemberHttpCanonicalization, String: LiteralHttpCanonicalization, Number: LiteralHttpCanonicalization, Boolean: LiteralHttpCanonicalization, diff --git a/packages/http-canonicalization/src/http-canonicalization.test.ts b/packages/http-canonicalization/src/http-canonicalization.test.ts index 22f14ee769c..fa24df8647e 100644 --- a/packages/http-canonicalization/src/http-canonicalization.test.ts +++ b/packages/http-canonicalization/src/http-canonicalization.test.ts @@ -1,12 +1,19 @@ -import { t } from "@typespec/compiler/testing"; +import { expectTypeEquals, t } from "@typespec/compiler/testing"; import { $ } from "@typespec/compiler/typekit"; +import { SCCSet } from "@typespec/emitter-framework"; import { Visibility } from "@typespec/http"; import { expect, it } from "vitest"; import { Tester } from "../test/test-host.js"; -import { HttpCanonicalizer } from "./http-canonicalization.js"; +import type { HttpCanonicalization } from "./http-canonicalization-classes.js"; +import { + HttpCanonicalizer, + httpCanonicalizationDependencyConnector, +} from "./http-canonicalization.js"; import type { ModelPropertyHttpCanonicalization } from "./model-property.js"; import type { ModelHttpCanonicalization } from "./model.js"; import { HttpCanonicalizationOptions } from "./options.js"; +import type { ScalarHttpCanonicalization } from "./scalar.js"; +import type { UnionHttpCanonicalization } from "./union.js"; it("canonicalizes models for read visibility", async () => { const runner = await Tester.createInstance(); @@ -27,14 +34,17 @@ it("canonicalizes models for read visibility", async () => { const read = canonicalizer.canonicalize( Foo, - new HttpCanonicalizationOptions({ visibility: Visibility.Read }), + new HttpCanonicalizationOptions({ + visibility: Visibility.Read, + contentType: "application/json", + }), ); expect(read.sourceType).toBe(Foo); // validate mutation node expect(read.properties.size).toBe(2); const deletedProperty = read.properties.get("name")! as ModelPropertyHttpCanonicalization; - expect(deletedProperty.languageType).toBe(tk.intrinsic.never); + expectTypeEquals(deletedProperty.languageType as any, tk.intrinsic.never); // validate language type expect(read.languageType.name).toBe("Foo"); @@ -81,7 +91,7 @@ it("returns the same canonicalization for the same type", async () => { model ${t.model("Foo")} { @visibility(Lifecycle.Read) createdAt: utcDateTime; name: string; - } + } `); const tk = $(program); @@ -97,3 +107,222 @@ it("returns the same canonicalization for the same type", async () => { expect(read1 === read2).toBe(true); }); + +it("handles referring to the same canonicalization", async () => { + const runner = await Tester.createInstance(); + const { Foo, Bar, Baz, program } = await runner.compile(t.code` + model ${t.model("Foo")} { + @visibility(Lifecycle.Read) createdAt: utcDateTime; + name: string; + } + + model ${t.model("Bar")} { + foo: Foo; + } + + model ${t.model("Baz")} { + foo: Foo; + } + `); + + const tk = $(program); + + const canonicalizer = new HttpCanonicalizer(tk); + + const createFoo = canonicalizer.canonicalize(Foo, { + visibility: Visibility.Create, + }) as ModelHttpCanonicalization; + + const createBar = canonicalizer.canonicalize(Bar, { + visibility: Visibility.Create, + }) as ModelHttpCanonicalization; + + expect(createBar.properties.get("foo")!.type === createFoo).toBe(true); + expectTypeEquals(createBar.properties.get("foo")!.languageType.type, createFoo.languageType); + + const createBaz = canonicalizer.canonicalize(Baz, { + visibility: Visibility.Create, + }) as ModelHttpCanonicalization; + + expect(createBaz.properties.get("foo")!.type === createFoo).toBe(true); +}); + +it("orders canonicalizations in an SCC set", async () => { + const runner = await Tester.createInstance(); + const { ApiKey, Animal, BaseModel, DerivedModel, Dog, Cat, Wrapper, AnimalUnion, program } = + await runner.compile(t.code` + scalar ${t.scalar("ApiKey")} extends string; + + @discriminator("kind") + model ${t.model("Animal")} { + kind: string; + } + + model ${t.model("Dog")} extends Animal { + kind: "Dog"; + } + + model ${t.model("Cat")} extends Animal { + kind: "Cat"; + } + + model ${t.model("BaseModel")} { + shared: ApiKey; + } + + model ${t.model("DerivedModel")} extends BaseModel { + derived: string; + } + + model ${t.model("Wrapper")} extends DerivedModel { + pet: Animal; + } + + union ${t.union("AnimalUnion")} { + dog: Dog, + cat: Cat, + } + `); + + const tk = $(program); + const canonicalizer = new HttpCanonicalizer(tk); + const options = new HttpCanonicalizationOptions({ + visibility: Visibility.Read, + contentType: "application/json", + }); + + const apiKey = canonicalizer.canonicalize(ApiKey, options) as ScalarHttpCanonicalization; + const baseModel = canonicalizer.canonicalize(BaseModel, options) as ModelHttpCanonicalization; + const derivedModel = canonicalizer.canonicalize( + DerivedModel, + options, + ) as ModelHttpCanonicalization; + const wrapper = canonicalizer.canonicalize(Wrapper, options) as ModelHttpCanonicalization; + const animal = canonicalizer.canonicalize(Animal, options) as ModelHttpCanonicalization; + const dogModel = canonicalizer.canonicalize(Dog, options) as ModelHttpCanonicalization; + const catModel = canonicalizer.canonicalize(Cat, options) as ModelHttpCanonicalization; + const union = canonicalizer.canonicalize(AnimalUnion, options) as UnionHttpCanonicalization; + + const set = new SCCSet(httpCanonicalizationDependencyConnector); + set.addAll([apiKey, baseModel, derivedModel, wrapper, animal, dogModel, catModel, union]); + + const indexOf = (value: HttpCanonicalization) => { + expect(value).toBeDefined(); + const idx = set.items.indexOf(value); + expect(idx).toBeGreaterThanOrEqual(0); + return idx; + }; + + const expectBefore = (dependency: HttpCanonicalization, dependent: HttpCanonicalization) => { + expect(dependency).toBeDefined(); + expect(dependent).toBeDefined(); + expect(indexOf(dependency)).toBeLessThan(indexOf(dependent)); + }; + + expectBefore(baseModel, derivedModel); + expectBefore(derivedModel, wrapper); + expectBefore(dogModel, union); + expectBefore(catModel, union); +}); + +it("detects identity codec subgraphs", async () => { + const runner = await Tester.createInstance(); + const { Foo, program } = await runner.compile(t.code` + model ${t.model("Foo")} { + name: string; + } + `); + + const canonicalizer = new HttpCanonicalizer($(program)); + const options = new HttpCanonicalizationOptions({ + visibility: Visibility.Read, + contentType: "application/json", + }); + + const foo = canonicalizer.canonicalize(Foo, options); + + expect(canonicalizer.subgraphUsesIdentityCodec(foo)).toBe(true); + expect(foo.subgraphUsesIdentityCodec()).toBe(true); +}); + +it("detects non-identity codec subgraphs", async () => { + const runner = await Tester.createInstance(); + const { Foo, program } = await runner.compile(t.code` + model ${t.model("Foo")} { + createdAt: utcDateTime; + } + `); + + const canonicalizer = new HttpCanonicalizer($(program)); + const options = new HttpCanonicalizationOptions({ + visibility: Visibility.Read, + contentType: "application/json", + }); + + const foo = canonicalizer.canonicalize(Foo, options); + expect(canonicalizer.subgraphUsesIdentityCodec(foo)).toBe(false); + expect(foo.subgraphUsesIdentityCodec()).toBe(false); +}); + +it("propagates non-identity codecs to dependent components", async () => { + const runner = await Tester.createInstance(); + const { Wrapper, program } = await runner.compile(t.code` + model ${t.model("Plain")} { + name: string; + } + + model ${t.model("Encoded")} { + @encode(DateTimeKnownEncoding.rfc7231) + createdAt: utcDateTime; + } + + model ${t.model("Wrapper")} { + plain: Plain; + encoded: Encoded; + } + `); + + const canonicalizer = new HttpCanonicalizer($(program)); + const options = new HttpCanonicalizationOptions({ + visibility: Visibility.Read, + contentType: "application/json", + }); + + const wrapper = canonicalizer.canonicalize(Wrapper, options) as ModelHttpCanonicalization; + const plain = wrapper.properties.get("plain")!.type as ModelHttpCanonicalization; + const encoded = wrapper.properties.get("encoded")!.type as ModelHttpCanonicalization; + expect(canonicalizer.subgraphUsesIdentityCodec(plain)).toBe(true); + expect(canonicalizer.subgraphUsesIdentityCodec(wrapper)).toBe(false); + expect(plain.subgraphUsesIdentityCodec()).toBe(true); + expect(encoded.subgraphUsesIdentityCodec()).toBe(false); + expect(wrapper.subgraphUsesIdentityCodec()).toBe(false); +}); + +it("marks every canonicalization in a strongly connected component as non-identity when one member is encoded", async () => { + const runner = await Tester.createInstance(); + const { NodeA, NodeB, program } = await runner.compile(t.code` + model ${t.model("NodeA")} { + nodeB: NodeB; + @encode(DateTimeKnownEncoding.rfc7231) + createdAt: utcDateTime; + } + + model ${t.model("NodeB")} { + nodeA: NodeA; + } + `); + + const canonicalizer = new HttpCanonicalizer($(program)); + const options = new HttpCanonicalizationOptions({ + visibility: Visibility.Read, + contentType: "application/json", + }); + + const nodeA = canonicalizer.canonicalize(NodeA, options) as ModelHttpCanonicalization; + const nodeB = canonicalizer.canonicalize(NodeB, options) as ModelHttpCanonicalization; + + expect(canonicalizer.subgraphUsesIdentityCodec(nodeA)).toBe(false); + expect(nodeA.subgraphUsesIdentityCodec()).toBe(false); + expect(nodeB.subgraphUsesIdentityCodec()).toBe(false); + expect(canonicalizer.subgraphUsesIdentityCodec(nodeB)).toBe(false); +}); diff --git a/packages/http-canonicalization/src/http-canonicalization.ts b/packages/http-canonicalization/src/http-canonicalization.ts index 6e221dc4404..4af32251c13 100644 --- a/packages/http-canonicalization/src/http-canonicalization.ts +++ b/packages/http-canonicalization/src/http-canonicalization.ts @@ -1,39 +1,381 @@ import type { Type } from "@typespec/compiler"; import type { Typekit } from "@typespec/compiler/typekit"; -import { MutationEngine, MutationSubgraph } from "@typespec/mutator-framework"; +import { SCCSet, type NestedArray, type SCCComponent } from "@typespec/emitter-framework"; +import { + MutationEngine, + MutationHalfEdge, + MutationNode, + type MutationInfo, +} from "@typespec/mutator-framework"; +import { + getJsonCodecRegistry, + IdentityCodec, + type Codec, + type CodecRegistry, + type EncodingInfo, +} from "./codecs.js"; import { CANONICALIZATION_CLASSES, + type HttpCanonicalization, type HttpCanonicalizationMutations, } from "./http-canonicalization-classes.js"; import { HttpCanonicalizationOptions, type HttpCanonicalizationOptionsInit } from "./options.js"; -export interface LanguageMapper { - getLanguageType(specType: Type): Type; +/** + * A predicate that tests canonicalizations in a subgraph and caches the result. + */ +export abstract class CanonicalizationPredicate { + /** + * Unique identifier for the predicate, used for caching results. + */ + abstract readonly id: string; + + /** + * Tests whether a canonicalization satisfies this predicate. + * @param canonicalization The canonicalization to test. + * @returns True if the canonicalization satisfies the predicate. + */ + abstract test(canonicalization: HttpCanonicalization): boolean; + + /** + * Cache of computed results keyed by HttpCanonicalization. + */ + readonly #cache = new WeakMap(); + + /** + * Gets the cached result for a canonicalization, if available. + */ + getCached(canonicalization: HttpCanonicalization): boolean | undefined { + return this.#cache.get(canonicalization); + } + + /** + * Sets the cached result for a canonicalization. + */ + setCached(canonicalization: HttpCanonicalization, result: boolean): void { + this.#cache.set(canonicalization, result); + } +} + +/** + * A predicate that tests whether a canonicalization uses the identity codec (no transformation). + */ +export class IdentityCodecPredicate extends CanonicalizationPredicate { + readonly id = "identity"; + + test(canonicalization: HttpCanonicalization): boolean { + const codec = canonicalization.codec; + return !codec || codec instanceof IdentityCodec || codec.id === "identity"; + } } -export const TSLanguageMapper: LanguageMapper = { - getLanguageType(specType: Type): Type { - // TypeScript emitter handles all the built-in types. - return specType; - }, -}; +/** + * Pre-built predicate for testing identity codec usage. + */ +export const identityCodecPredicate = new IdentityCodecPredicate(); + +export interface HttpCanonicalizationCommon { + /** + * Codec responsible for transforming the scalar into language and wire types. + */ + codec: Codec | null; + + /** + * Whether the source type for this type is declared in TypeSpec. + */ + isDeclaration: boolean; + + /** + * The language mutation node for this canonicalization. + */ + get languageMutationNode(): MutationNode; + + /** + * The wire mutation node for this canonicalization. + */ + get wireMutationNode(): MutationNode; + + /** + * The possibly mutated language type for this literal. + */ + get languageType(): Type; + + /** + * The possibly mutated wire type for this literal. + */ + get wireType(): Type; +} export class HttpCanonicalizer extends MutationEngine { - constructor($: Typekit) { + codecs: CodecRegistry; + + constructor($: Typekit, codecs: CodecRegistry = getJsonCodecRegistry($)) { super($, CANONICALIZATION_CLASSES); - this.registerSubgraph("language"); - this.registerSubgraph("wire"); + this.codecs = codecs; } - getLanguageSubgraph(options: HttpCanonicalizationOptions): MutationSubgraph { - return this.getMutationSubgraph(options, "language"); + canonicalize( + type: T, + options?: HttpCanonicalizationOptionsInit | HttpCanonicalizationOptions, + edge?: MutationHalfEdge, + ) { + return this.mutate( + type, + options instanceof HttpCanonicalizationOptions + ? options + : new HttpCanonicalizationOptions(options), + edge, + ); } - getWireSubgraph(options: HttpCanonicalizationOptions): MutationSubgraph { - return this.getMutationSubgraph(options, "wire"); + /** + * Tests whether the subgraph rooted at the given canonicalization satisfies + * the provided predicate. Results are cached on the predicate instance. + * + * @param canonicalization The root canonicalization to test. + * @param predicate The predicate to test against canonicalizations in the subgraph. + * @returns True if all canonicalizations in the subgraph satisfy the predicate. + */ + subgraphMatchesPredicate( + canonicalization: HttpCanonicalization, + predicate: CanonicalizationPredicate, + ): boolean { + const cached = predicate.getCached(canonicalization); + if (cached !== undefined) { + return cached; + } + + const set = new SCCSet(httpCanonicalizationDependencyConnector, { + includeReachable: true, + }); + set.add(canonicalization); + + const componentResults = new Map, boolean>(); + + for (const component of set.components) { + const members = this.#componentMembers(component); + if (members.length === 0) { + continue; + } + + const existingValue = this.#getExistingComponentResult(members, predicate); + if (existingValue !== undefined) { + componentResults.set(component, existingValue); + continue; + } + + const result = this.#evaluateComponentPredicate( + component, + members, + componentResults, + predicate, + ); + for (const member of members) { + predicate.setCached(member, result); + } + componentResults.set(component, result); + } + + return predicate.getCached(canonicalization) ?? false; + } + + /** + * Tests whether the subgraph rooted at the given canonicalization uses only + * the identity codec (no transformation). + * + * @param canonicalization The root canonicalization to test. + * @returns True if all codecs in the subgraph are identity codecs. + */ + subgraphUsesIdentityCodec(canonicalization: HttpCanonicalization): boolean { + return this.subgraphMatchesPredicate(canonicalization, identityCodecPredicate); + } + + #evaluateComponentPredicate( + component: SCCComponent, + members: HttpCanonicalization[], + componentResults: Map, boolean>, + predicate: CanonicalizationPredicate, + ): boolean { + for (const member of members) { + if (!predicate.test(member)) { + return false; + } + } + + for (const dependency of component.references) { + const dependencyValue = + componentResults.get(dependency) ?? + this.#getExistingComponentResult(this.#componentMembers(dependency), predicate); + if (dependencyValue === undefined) { + throw new Error("Dependency predicate state missing before evaluation."); + } + + if (!dependencyValue) { + return false; + } + } + + return true; + } + + #componentMembers(component: SCCComponent): HttpCanonicalization[] { + return this.#flattenComponent(component.value); + } + + #getExistingComponentResult( + members: HttpCanonicalization[], + predicate: CanonicalizationPredicate, + ): boolean | undefined { + if (members.length === 0) { + return undefined; + } + const firstValue = predicate.getCached(members[0]); + if (firstValue === undefined) { + return undefined; + } + for (const member of members) { + if (predicate.getCached(member) !== firstValue) { + throw new Error("Inconsistent predicate state detected within a component."); + } + } + return firstValue; } - canonicalize(type: T, options?: HttpCanonicalizationOptionsInit) { - return this.mutate(type, new HttpCanonicalizationOptions(options)); + #flattenComponent(value: NestedArray): HttpCanonicalization[] { + if (Array.isArray(value)) { + return (value as HttpCanonicalization[][]).flat(Infinity) as HttpCanonicalization[]; + } + return [value]; + } +} + +export interface HttpCanonicalizationInfo extends MutationInfo { + encodingInfo?: EncodingInfo; +} + +/** + * Enumerates the canonicalizations referenced by the provided HTTP canonicalization. + * This can be supplied directly to an `SCCSet` connector to keep canonicalizations + * ordered by their dependency graph. + */ +export function* httpCanonicalizationDependencyConnector( + canonicalization: HttpCanonicalization, +): IterableIterator { + switch (canonicalization.kind) { + case "Operation": { + if (canonicalization.parameters) { + yield assertDependencyDefined( + canonicalization.parameters as HttpCanonicalization | undefined, + canonicalization, + "parameters", + ); + } + if (canonicalization.returnType) { + yield assertDependencyDefined( + canonicalization.returnType as HttpCanonicalization | undefined, + canonicalization, + "returnType", + ); + } + break; + } + case "Model": { + if (canonicalization.baseModel) { + yield assertDependencyDefined( + canonicalization.baseModel as HttpCanonicalization | undefined, + canonicalization, + "baseModel", + ); + } + for (const property of canonicalization.properties.values()) { + if (!property.isVisible) { + return; + } + yield assertDependencyDefined( + property as HttpCanonicalization | undefined, + canonicalization, + "property", + ); + } + if (canonicalization.indexer && canonicalization.indexer.value) { + yield assertDependencyDefined( + canonicalization.indexer.value as HttpCanonicalization | undefined, + canonicalization, + "indexer.value", + ); + } + break; + } + case "ModelProperty": { + yield assertDependencyDefined( + canonicalization.type as HttpCanonicalization | undefined, + canonicalization, + "type", + ); + break; + } + case "Scalar": { + if (canonicalization.baseScalar) { + yield assertDependencyDefined( + canonicalization.baseScalar as HttpCanonicalization | undefined, + canonicalization, + "baseScalar", + ); + } + break; + } + case "Union": { + for (const variant of canonicalization.variants.values()) { + yield assertDependencyDefined( + variant as HttpCanonicalization | undefined, + canonicalization, + "variant", + ); + } + break; + } + case "UnionVariant": { + yield assertDependencyDefined( + canonicalization.type as HttpCanonicalization | undefined, + canonicalization, + "type", + ); + break; + } + case "Enum": { + for (const member of canonicalization.members.values()) { + yield assertDependencyDefined( + member as HttpCanonicalization | undefined, + canonicalization, + "member", + ); + } + break; + } + case "EnumMember": + case "Intrinsic": + case "Literal": + break; + default: { + const _exhaustiveCheck: never = canonicalization; + void _exhaustiveCheck; + break; + } + } +} + +function assertDependencyDefined( + dependency: HttpCanonicalization | undefined, + canonicalization: HttpCanonicalization, + path: string, +): HttpCanonicalization { + if (dependency === undefined) { + /* eslint-disable-next-line no-console */ + console.error("Undefined HTTP canonicalization dependency.", { + canonicalization, + path, + }); + throw new Error(`HTTP canonicalization dependency "${path}" resolved to undefined.`); } + return dependency; } diff --git a/packages/http-canonicalization/src/index.ts b/packages/http-canonicalization/src/index.ts index 2bc509f0afa..80e5772ba12 100644 --- a/packages/http-canonicalization/src/index.ts +++ b/packages/http-canonicalization/src/index.ts @@ -1,4 +1,6 @@ export * from "./codecs.js"; +export * from "./enum-member.js"; +export * from "./enum.js"; export * from "./http-canonicalization-classes.js"; export * from "./http-canonicalization.js"; export * from "./intrinsic.js"; diff --git a/packages/http-canonicalization/src/intrinsic.ts b/packages/http-canonicalization/src/intrinsic.ts index 7f7e488c0bc..5bbc552ecfc 100644 --- a/packages/http-canonicalization/src/intrinsic.ts +++ b/packages/http-canonicalization/src/intrinsic.ts @@ -1,52 +1,74 @@ import type { IntrinsicType, MemberType } from "@typespec/compiler"; -import { IntrinsicMutation } from "@typespec/mutator-framework"; +import { + IntrinsicMutation, + type MutationHalfEdge, + type MutationNodeForType, + type MutationTraits, +} from "@typespec/mutator-framework"; +import type { Codec } from "./codecs.js"; import type { HttpCanonicalizationMutations } from "./http-canonicalization-classes.js"; -import { HttpCanonicalizer } from "./http-canonicalization.js"; +import { + HttpCanonicalizer, + type CanonicalizationPredicate, + type HttpCanonicalizationCommon, + type HttpCanonicalizationInfo, +} from "./http-canonicalization.js"; import { HttpCanonicalizationOptions } from "./options.js"; /** * Canonicalizes intrinsic types for HTTP. */ -export class IntrinsicHttpCanonicalization extends IntrinsicMutation< - HttpCanonicalizationOptions, - HttpCanonicalizationMutations, - HttpCanonicalizer -> { - /** - * Canonicalization options. - */ - options: HttpCanonicalizationOptions; - /** - * Indicates if this intrinsic represents a named declaration. Always false. - */ - isDeclaration: boolean = false; +export class IntrinsicHttpCanonicalization + extends IntrinsicMutation< + HttpCanonicalizationOptions, + HttpCanonicalizationMutations, + HttpCanonicalizer + > + implements HttpCanonicalizationCommon +{ + isDeclaration = false; + codec: Codec | null = null; - /** - * Mutation subgraph for language types. - */ - get #languageSubgraph() { - return this.engine.getLanguageSubgraph(this.options); + #languageMutationNode: MutationNodeForType; + #wireMutationNode: MutationNodeForType; + + get languageMutationNode() { + return this.#languageMutationNode; } - /** - * Mutation subgraph for wire types. - */ - get #wireSubgraph() { - return this.engine.getWireSubgraph(this.options); + get wireMutationNode() { + return this.#wireMutationNode; } /** - * The possibly mutated language type for this intrinsic. + * Tests whether the subgraph rooted at this canonicalization uses only + * the identity codec (no transformation). */ - get languageType() { - return this.getMutatedType(this.#languageSubgraph); + subgraphUsesIdentityCodec(): boolean { + return this.engine.subgraphUsesIdentityCodec(this); } /** - * The possibly mutated wire type for this intrinsic. + * Tests whether the subgraph rooted at this canonicalization satisfies + * the provided predicate. */ - get wireType() { - return this.getMutatedType(this.#wireSubgraph); + subgraphMatchesPredicate(predicate: CanonicalizationPredicate): boolean { + return this.engine.subgraphMatchesPredicate(this, predicate); + } + + static mutationInfo( + engine: HttpCanonicalizer, + sourceType: IntrinsicType, + referenceTypes: MemberType[], + options: HttpCanonicalizationOptions, + halfEdge?: MutationHalfEdge, + traits?: MutationTraits, + ): HttpCanonicalizationInfo { + return { + mutationKey: options.mutationKey, + codec: null as any, // Intrinsics don't need a codec + isSynthetic: traits?.isSynthetic, + }; } constructor( @@ -54,8 +76,30 @@ export class IntrinsicHttpCanonicalization extends IntrinsicMutation< sourceType: IntrinsicType, referenceTypes: MemberType[], options: HttpCanonicalizationOptions, + info: HttpCanonicalizationInfo, ) { - super(engine, sourceType, referenceTypes, options); - this.options = options; + super(engine, sourceType, referenceTypes, options, info); + this.#languageMutationNode = this.engine.getMutationNode( + this.sourceType, + info.mutationKey + "-language", + ); + this.#wireMutationNode = this.engine.getMutationNode( + this.sourceType, + info.mutationKey + "-wire", + ); + } + + /** + * The possibly mutated language type for this intrinsic. + */ + get languageType() { + return this.#languageMutationNode.mutatedType; + } + + /** + * The possibly mutated wire type for this intrinsic. + */ + get wireType() { + return this.#wireMutationNode.mutatedType; } } diff --git a/packages/http-canonicalization/src/literal.ts b/packages/http-canonicalization/src/literal.ts index a7b3d827cd1..4301dcdd0e3 100644 --- a/packages/http-canonicalization/src/literal.ts +++ b/packages/http-canonicalization/src/literal.ts @@ -1,53 +1,82 @@ import type { BooleanLiteral, MemberType, NumericLiteral, StringLiteral } from "@typespec/compiler"; -import { LiteralMutation } from "@typespec/mutator-framework"; +import { + LiteralMutation, + type MutationHalfEdge, + type MutationNodeForType, + type MutationTraits, +} from "@typespec/mutator-framework"; +import type { Codec } from "./codecs.js"; import type { HttpCanonicalizationMutations } from "./http-canonicalization-classes.js"; -import type { HttpCanonicalizer } from "./http-canonicalization.js"; +import type { + CanonicalizationPredicate, + HttpCanonicalizationCommon, + HttpCanonicalizationInfo, + HttpCanonicalizer, +} from "./http-canonicalization.js"; import { HttpCanonicalizationOptions } from "./options.js"; /** * Canonicalizes literal types for HTTP. */ -export class LiteralHttpCanonicalization extends LiteralMutation< - HttpCanonicalizationOptions, - HttpCanonicalizationMutations, - HttpCanonicalizer -> { - /** - * Canonicalization options. - */ - options: HttpCanonicalizationOptions; - /** - * Indicates if the literal is defined as a named TypeSpec declaration. Always - * false for literals. - */ - isDeclaration: boolean = false; +export class LiteralHttpCanonicalization + extends LiteralMutation< + HttpCanonicalizationOptions, + HttpCanonicalizationMutations, + HttpCanonicalizer + > + implements HttpCanonicalizationCommon +{ + isDeclaration = false; + codec: Codec | null = null; - /** - * Mutation subgraph for language types. - */ - get #languageSubgraph() { - return this.engine.getLanguageSubgraph(this.options); + #languageMutationNode: MutationNodeForType; + #wireMutationNode: MutationNodeForType; + + get languageMutationNode() { + return this.#languageMutationNode; } - /** - * Mutation subgraph for wire types. - */ - get #wireSubgraph() { - return this.engine.getWireSubgraph(this.options); + get wireMutationNode() { + return this.#wireMutationNode; + } + + get languageType() { + return this.#languageMutationNode.mutatedType; + } + + get wireType() { + return this.#wireMutationNode.mutatedType; } /** - * The possibly mutated language type for this literal. + * Tests whether the subgraph rooted at this canonicalization uses only + * the identity codec (no transformation). */ - get languageType() { - return this.getMutatedType(this.#languageSubgraph); + subgraphUsesIdentityCodec(): boolean { + return this.engine.subgraphUsesIdentityCodec(this); } /** - * The possibly mutated wire type for this literal. + * Tests whether the subgraph rooted at this canonicalization satisfies + * the provided predicate. */ - get wireType() { - return this.getMutatedType(this.#wireSubgraph); + subgraphMatchesPredicate(predicate: CanonicalizationPredicate): boolean { + return this.engine.subgraphMatchesPredicate(this, predicate); + } + + static mutationInfo( + engine: HttpCanonicalizer, + sourceType: StringLiteral | NumericLiteral | BooleanLiteral, + referenceTypes: MemberType[], + options: HttpCanonicalizationOptions, + halfEdge?: MutationHalfEdge, + traits?: MutationTraits, + ): HttpCanonicalizationInfo { + return { + mutationKey: options.mutationKey, + codec: null as any, // Literals don't need a codec + isSynthetic: traits?.isSynthetic, + }; } constructor( @@ -55,8 +84,17 @@ export class LiteralHttpCanonicalization extends LiteralMutation< sourceType: StringLiteral | NumericLiteral | BooleanLiteral, referenceTypes: MemberType[], options: HttpCanonicalizationOptions, + info: HttpCanonicalizationInfo, ) { - super(engine, sourceType, referenceTypes, options); + super(engine, sourceType, referenceTypes, options, info); this.options = options; + this.#languageMutationNode = this.engine.getMutationNode( + this.sourceType, + info.mutationKey + "-language", + ); + this.#wireMutationNode = this.engine.getMutationNode( + this.sourceType, + info.mutationKey + "-wire", + ); } } diff --git a/packages/http-canonicalization/src/model-property.test.ts b/packages/http-canonicalization/src/model-property.test.ts index 8f808115f6d..091a0471e6f 100644 --- a/packages/http-canonicalization/src/model-property.test.ts +++ b/packages/http-canonicalization/src/model-property.test.ts @@ -1,17 +1,64 @@ -import { t, type TesterInstance } from "@typespec/compiler/testing"; +import { expectTypeEquals, t, type TesterInstance } from "@typespec/compiler/testing"; import { $ } from "@typespec/compiler/typekit"; import { Visibility } from "@typespec/http"; import { beforeEach, expect, it } from "vitest"; import { Tester } from "../test/test-host.js"; +import { + ArrayJoinCodec, + Base64Codec, + CodecRegistry, + CoerceToFloat64Codec, + IdentityCodec, + RenameCodec, + Rfc3339Codec, + Rfc7231Codec, + UnixTimestamp32Codec, + UnixTimestamp64Codec, +} from "./codecs.js"; import { HttpCanonicalizer } from "./http-canonicalization.js"; +import type { ScalarHttpCanonicalization } from "./scalar.js"; let runner: TesterInstance; beforeEach(async () => { runner = await Tester.createInstance(); }); -// skip, haven't implemented metadata stuff yet -it.skip("removes metadata properties from wire type", async () => { +it("canonicalizes properties with encoding differently than the referenced type", async () => { + const { Foo, program } = await runner.compile(t.code` + model ${t.model("Foo")} { + @encode(DateTimeKnownEncoding.rfc7231) + ${t.modelProperty("one")}: utcDateTime; + + @encode(DateTimeKnownEncoding.rfc3339) + ${t.modelProperty("two")}: utcDateTime; + } + `); + + const tk = $(program); + + const canonicalizer = new HttpCanonicalizer(tk); + const canonicalized = canonicalizer.canonicalize(Foo, { + visibility: Visibility.Read, + contentType: "application/json", + }); + + const one = canonicalized.properties.get("one")!; + const two = canonicalized.properties.get("two")!; + + expectTypeEquals(one.languageType.type, tk.builtin.utcDateTime); + expectTypeEquals(one.wireType.type, tk.builtin.string); + + expectTypeEquals(two.languageType.type, tk.builtin.utcDateTime); + expectTypeEquals(two.wireType.type, tk.builtin.string); + + const oneType = one.type as ScalarHttpCanonicalization; + const twoType = two.type as ScalarHttpCanonicalization; + + expect(oneType.codec.id).toBe("rfc7231"); + expect(twoType.codec.id).toBe("rfc3339"); +}); + +it("removes metadata properties from wire type", async () => { const { Foo, program } = await runner.compile(t.code` model ${t.model("Foo")} { @visibility(Lifecycle.Read) @@ -31,3 +78,84 @@ it.skip("removes metadata properties from wire type", async () => { expect(write.languageType.properties.has("etag")).toBe(true); expect(write.wireType.properties.has("etag")).toBe(false); }); + +it("makes nullable properties optional on the wire", async () => { + const { Foo, program } = await runner.compile(t.code` + model ${t.model("Foo")} { + id: string | null; + } + `); + + const tk = $(program); + + const canonicalizer = new HttpCanonicalizer(tk); + const foo = canonicalizer.canonicalize(Foo, { + visibility: Visibility.Read, + contentType: "application/json", + }); + const prop = foo.properties.get("id")!; + expect(prop.typeIsNullable).toBe(true); + + const langType = prop.languageType; + expect(langType.optional).toBe(false); + + const wireType = prop.wireType; + expect(wireType.optional).toBe(true); +}); + +it("applies friendly name", async () => { + const { Foo, program } = await runner.compile(t.code` + model ${t.model("Foo")} { + @friendlyName("FooId") + id: string | null; + } + `); + + const tk = $(program); + + const canonicalizer = new HttpCanonicalizer(tk); + const foo = canonicalizer.canonicalize(Foo, { + visibility: Visibility.Read, + contentType: "application/json", + }); + + const prop = foo.properties.get("id")!; + expect(prop).toBeDefined(); + expect(prop.languageType.name).toBe("FooId"); + expect(prop.wireType.name).toBe("id"); +}); + +it("Applies renames", async () => { + const { program, test } = await runner.compile(t.code` + op ${t.op("test")}(): Foo; + + model ${t.model("Foo")} { + rename_thing: string; + } + `); + + const tk = $(program); + const codecs = new CodecRegistry(tk); + codecs.addCodec(new CoerceToFloat64Codec()); + codecs.addCodec(new Rfc7231Codec()); + codecs.addCodec(new UnixTimestamp32Codec()); + codecs.addCodec(new UnixTimestamp64Codec()); + codecs.addCodec(new Rfc3339Codec()); + codecs.addCodec(new Base64Codec()); + codecs.addCodec(new Base64Codec()); + codecs.addCodec(new ArrayJoinCodec()); + codecs.addCodec( + new RenameCodec({ + namer(type) { + return (type as any).name + "Renamed"; + }, + }), + ); + codecs.addCodec(new IdentityCodec()); + const canonicalizer = new HttpCanonicalizer(tk, codecs); + + const canonTest = canonicalizer.canonicalize(test); + const model = (canonTest as any).responses[0].responses[0].body.bodies[0].type; + expect(model.languageType.properties.size).toBe(1); + expect(model.languageType.properties.has("rename_thingRenamed")).toBe(true); +}); diff --git a/packages/http-canonicalization/src/model-property.ts b/packages/http-canonicalization/src/model-property.ts index 56bc9ebefaa..21a48219563 100644 --- a/packages/http-canonicalization/src/model-property.ts +++ b/packages/http-canonicalization/src/model-property.ts @@ -1,37 +1,50 @@ import type { MemberType, ModelProperty } from "@typespec/compiler"; -import { getHeaderFieldOptions, getQueryParamOptions, isVisible } from "@typespec/http"; -import { ModelPropertyMutation } from "@typespec/mutator-framework"; -import { Codec, getJsonEncoderRegistry } from "./codecs.js"; +import { + getHeaderFieldOptions, + getPathParamOptions, + getQueryParamOptions, + isBody, + isBodyRoot, + isMetadata, + isVisible, +} from "@typespec/http"; +import { + ModelPropertyMutation, + MutationHalfEdge, + type MutationNodeForType, + type MutationTraits, +} from "@typespec/mutator-framework"; +import type { Codec, EncodingInfo } from "./codecs.js"; +import type { HttpCanonicalizationMutations } from "./http-canonicalization-classes.js"; import type { - HttpCanonicalization, - HttpCanonicalizationMutations, -} from "./http-canonicalization-classes.js"; -import type { HttpCanonicalizer } from "./http-canonicalization.js"; + CanonicalizationPredicate, + HttpCanonicalizationCommon, + HttpCanonicalizationInfo, + HttpCanonicalizer, +} from "./http-canonicalization.js"; import { HttpCanonicalizationOptions } from "./options.js"; /** * Canonicalizes model properties, tracking request/response metadata and visibility. */ -export class ModelPropertyHttpCanonicalization extends ModelPropertyMutation< - HttpCanonicalizationOptions, - HttpCanonicalizationMutations, - HttpCanonicalizer -> { - /** - * Indicates if this property corresponds to a named declaration. Always - * false. - */ - isDeclaration: boolean = false; +export class ModelPropertyHttpCanonicalization + extends ModelPropertyMutation< + HttpCanonicalizationMutations, + HttpCanonicalizationOptions, + HttpCanonicalizer + > + implements HttpCanonicalizationCommon +{ + isDeclaration = false; /** * Whether the property is visible given the current visibility options. */ isVisible: boolean = false; - /** - * Codec used to transform the property's type between language and wire views. - */ - codec: Codec; + codec: Codec | null = null; + + #encodingInfo: EncodingInfo | null = null; /** * True when the property is a query parameter. @@ -53,6 +66,11 @@ export class ModelPropertyHttpCanonicalization extends ModelPropertyMutation< */ headerName: string = ""; + /** + * Whether the property is metadata (i.e. not part of an HTTP body). + */ + isMetadata: boolean = false; + /** * True when the property is a path parameter. */ @@ -69,31 +87,83 @@ export class ModelPropertyHttpCanonicalization extends ModelPropertyMutation< explode: boolean = false; /** - * Mutation subgraph for language types. + * Whether this is the property which declares the HTTP content type. */ - get #languageSubgraph() { - return this.engine.getLanguageSubgraph(this.options); - } + isContentTypeProperty: boolean = false; /** - * Mutation subgraph for wire types. + * Whether this is the property which declares the HTTP body. */ - get #wireSubgraph() { - return this.engine.getWireSubgraph(this.options); + isBodyProperty: boolean = false; + + #languageMutationNode: MutationNodeForType; + get languageMutationNode() { + return this.#languageMutationNode; + } + + #wireMutationNode: MutationNodeForType; + get wireMutationNode() { + return this.#wireMutationNode; } /** * The possibly mutated language type for this property. */ get languageType() { - return this.getMutatedType(this.#languageSubgraph); + return this.#languageMutationNode.mutatedType; } /** * The possibly mutated wire type for this property. */ get wireType() { - return this.getMutatedType(this.#wireSubgraph); + return this.#wireMutationNode.mutatedType; + } + + /** + * Tests whether the subgraph rooted at this canonicalization uses only + * the identity codec (no transformation). + */ + subgraphUsesIdentityCodec(): boolean { + return this.engine.subgraphUsesIdentityCodec(this); + } + + /** + * Tests whether the subgraph rooted at this canonicalization satisfies + * the provided predicate. + */ + subgraphMatchesPredicate(predicate: CanonicalizationPredicate): boolean { + return this.engine.subgraphMatchesPredicate(this, predicate); + } + + /** + * Whether the type of this property is a nullable union. For the JSON content + * type, nullable properties are optional on the wire. + */ + typeIsNullable: boolean = false; + + protected startTypeEdge() { + return new MutationHalfEdge("type", this, (tail) => { + this.#languageMutationNode.connectType(tail.languageMutationNode); + this.#wireMutationNode.connectType(tail.wireMutationNode); + }); + } + + static mutationInfo( + engine: HttpCanonicalizer, + sourceType: ModelProperty, + referenceTypes: MemberType[], + options: HttpCanonicalizationOptions, + halfEdge?: MutationHalfEdge, + traits?: MutationTraits, + ): HttpCanonicalizationInfo { + const encodingInfo = engine.codecs.encode(sourceType, referenceTypes); + + return { + mutationKey: options.mutationKey, + encodingInfo, + isSynthetic: traits?.isSynthetic, + }; } constructor( @@ -101,8 +171,18 @@ export class ModelPropertyHttpCanonicalization extends ModelPropertyMutation< sourceType: ModelProperty, referenceTypes: MemberType[], options: HttpCanonicalizationOptions, + info: HttpCanonicalizationInfo, ) { - super(engine, sourceType, referenceTypes, options); + super(engine, sourceType, referenceTypes, options, info); + this.#languageMutationNode = this.engine.getMutationNode( + this.sourceType, + info.mutationKey + "-language", + ); + this.#wireMutationNode = this.engine.getMutationNode( + this.sourceType, + info.mutationKey + "-wire", + ); + this.isDeclaration = !!this.sourceType.name; this.isVisible = isVisible(this.engine.$.program, this.sourceType, this.options.visibility); const headerInfo = getHeaderFieldOptions(this.engine.$.program, this.sourceType); @@ -118,7 +198,7 @@ export class ModelPropertyHttpCanonicalization extends ModelPropertyMutation< this.queryParameterName = queryInfo.name; this.explode = !!queryInfo.explode; } else { - const pathInfo = getQueryParamOptions(this.engine.$.program, this.sourceType); + const pathInfo = getPathParamOptions(this.engine.$.program, this.sourceType); if (pathInfo) { this.isPathParameter = true; this.pathParameterName = pathInfo.name; @@ -127,23 +207,41 @@ export class ModelPropertyHttpCanonicalization extends ModelPropertyMutation< } } - const registry = getJsonEncoderRegistry(this.engine.$); - this.codec = registry.detect(this); + this.isMetadata = isMetadata(this.engine.$.program, this.sourceType); + this.isBodyProperty = + isBody(this.engine.$.program, this.sourceType) || + isBodyRoot(this.engine.$.program, this.sourceType); + + this.isContentTypeProperty = this.isHeader && this.headerName.toLowerCase() === "content-type"; + + if ( + this.engine.$.union.is(this.sourceType.type) && + this.options.contentType === "application/json" + ) { + const variants = [...this.sourceType.type.variants.values()]; + if (variants.some((v) => v.type === this.engine.$.intrinsic.null)) { + this.typeIsNullable = true; + } + } + + this.codec = info.encodingInfo?.codec ?? null; + this.#encodingInfo = info.encodingInfo ?? null; } /** * Apply HTTP canonicalization. */ mutate() { - const languageNode = this.getMutationNode(this.#languageSubgraph); - const wireNode = this.getMutationNode(this.#wireSubgraph); - if (!this.isVisible) { - languageNode.delete(); - wireNode.delete(); + this.#languageMutationNode.delete(); + this.#wireMutationNode.delete(); return; } + if (this.isMetadata) { + this.#wireMutationNode.delete(); + } + const newOptions = this.isHeader ? this.options.with({ location: `header${this.explode ? "-explode" : ""}`, @@ -158,6 +256,27 @@ export class ModelPropertyHttpCanonicalization extends ModelPropertyMutation< }) : this.options.with({ location: "body" }); - this.type = this.engine.mutateReference(this.sourceType, newOptions) as HttpCanonicalization; + if (this.typeIsNullable) { + // nullable things often mean optional things, I guess. + this.#wireMutationNode.mutate((prop) => { + prop.optional = true; + }); + } + + if (this.#encodingInfo) { + const { languageType, wireType } = this.#encodingInfo; + if (languageType !== this.sourceType) { + this.#languageMutationNode = this.#languageMutationNode.replace( + languageType as ModelProperty, + ) as MutationNodeForType; + } + if (wireType !== this.sourceType) { + this.#wireMutationNode = this.#wireMutationNode.replace( + wireType as ModelProperty, + ) as MutationNodeForType; + } + } + + super.mutate(newOptions); } } diff --git a/packages/http-canonicalization/src/model.test.ts b/packages/http-canonicalization/src/model.test.ts new file mode 100644 index 00000000000..fdbb0e28cca --- /dev/null +++ b/packages/http-canonicalization/src/model.test.ts @@ -0,0 +1,141 @@ +import { t, type TesterInstance } from "@typespec/compiler/testing"; +import { $ } from "@typespec/compiler/typekit"; +import { Visibility } from "@typespec/http"; +import { beforeEach, expect, it } from "vitest"; +import { Tester } from "../test/test-host.js"; +import type { HttpCanonicalization } from "./http-canonicalization-classes.js"; +import { HttpCanonicalizer } from "./http-canonicalization.js"; +import type { ModelHttpCanonicalization } from "./model.js"; + +let runner: TesterInstance; +beforeEach(async () => { + runner = await Tester.createInstance(); +}); + +it("applies friendly name", async () => { + const { Foo, program } = await runner.compile(t.code` + @friendlyName("Bar") + model ${t.model("Foo")} { + } + `); + + const tk = $(program); + + const canonicalizer = new HttpCanonicalizer(tk); + const canonicalized = canonicalizer.canonicalize(Foo, { + visibility: Visibility.Read, + }); + + expect(canonicalized.languageType.name).toBe("Bar"); + expect(canonicalized.wireType.name).toBe("Bar"); +}); + +it("works for polymorphic models", async () => { + const { Animal, Test, program } = await runner.compile(t.code` + @discriminator("kind") + model ${t.model("Animal")} { + kind: string; + } + + model Dog extends Animal { + kind: "Dog"; + } + + model Cat extends Animal { + kind: "Cat"; + } + + model ${t.model("Test")} { + prop: Animal; + } + `); + + const tk = $(program); + + const canonicalizer = new HttpCanonicalizer(tk); + const canonicalized = canonicalizer.canonicalize(Animal, { + visibility: Visibility.Read, + contentType: "application/json", + }); + + expect(canonicalized.isPolymorphicModel).toBe(true); + + const union = canonicalized.polymorphicModelUnion!; + expect(union.variants.size).toBe(2); + const info = tk.union.getDiscriminatedUnion(union.languageType); + expect(info!.options).toEqual({ + envelope: "none", + discriminatorPropertyName: "kind", + envelopePropertyName: "value", + }); + expect(canonicalized.languageMutationNode.kind === "Model").toBe(true); + + // verify that the Test model's prop property points to the union + const testCanonicalized = canonicalizer.canonicalize(Test, { + visibility: Visibility.Read, + contentType: "application/json", + }); + const propType = testCanonicalized.properties.get("prop")!.type as HttpCanonicalization; + expect(propType.kind === "Union").toBe(true); +}); + +it("works for polymorphic models that are referenced in arrays", async () => { + const { Test, program } = await runner.compile(t.code` + @discriminator("kind") + model ${t.model("Animal")} { + kind: string; + } + + model Dog extends Animal { + kind: "Dog"; + } + + model Cat extends Animal { + kind: "Cat"; + } + + model ${t.model("Test")} { + prop: Animal[]; + } + `); + + const tk = $(program); + + const canonicalizer = new HttpCanonicalizer(tk); + // verify that the Test model's prop property points to the union + const testCanonicalized = canonicalizer.canonicalize(Test, { + visibility: Visibility.Read, + contentType: "application/json", + }); + const propType = testCanonicalized.properties.get("prop")!.type as ModelHttpCanonicalization; + expect(propType.kind).toBe("Model"); + const indexerType = propType.indexer?.value as HttpCanonicalization; + expect(indexerType.kind).toBe("Union"); +}); + +it("uses effective models", async () => { + const { Animal, Test, program } = await runner.compile(t.code` + model ${t.model("Animal")} { + kind: string; + } + + model ${t.model("Test")} { + prop: { ... Animal }; + } + `); + + const tk = $(program); + + const canonicalizer = new HttpCanonicalizer(tk); + // verify that the Test model's prop property points to the union + const testCanonicalized = canonicalizer.canonicalize(Test, { + visibility: Visibility.Read, + contentType: "application/json", + }); + const animalCanonicalized = canonicalizer.canonicalize(Animal, { + visibility: Visibility.Read, + contentType: "application/json", + }); + const propType = testCanonicalized.properties.get("prop")!.type as ModelHttpCanonicalization; + expect(propType === animalCanonicalized).toBe(true); +}); diff --git a/packages/http-canonicalization/src/model.ts b/packages/http-canonicalization/src/model.ts index e43d3228ec4..f1da99c2fec 100644 --- a/packages/http-canonicalization/src/model.ts +++ b/packages/http-canonicalization/src/model.ts @@ -1,57 +1,156 @@ -import type { MemberType, Model } from "@typespec/compiler"; +import { + discriminatedDecorator, + getDiscriminator, + getFriendlyName, + type MemberType, + type Model, + type Union, +} from "@typespec/compiler"; +import type { Typekit } from "@typespec/compiler/typekit"; import { getVisibilitySuffix, Visibility } from "@typespec/http"; -import { ModelMutation } from "@typespec/mutator-framework"; -import { Codec, getJsonEncoderRegistry } from "./codecs.js"; +import { + ModelMutation, + MutationHalfEdge, + type MutationNodeForType, + type MutationTraits, +} from "@typespec/mutator-framework"; +import { Codec } from "./codecs.js"; import type { HttpCanonicalizationMutations } from "./http-canonicalization-classes.js"; -import type { HttpCanonicalizer } from "./http-canonicalization.js"; +import type { + CanonicalizationPredicate, + HttpCanonicalizationCommon, + HttpCanonicalizationInfo, + HttpCanonicalizer, +} from "./http-canonicalization.js"; import type { ModelPropertyHttpCanonicalization } from "./model-property.js"; import { HttpCanonicalizationOptions } from "./options.js"; -import type { ScalarHttpCanonicalization } from "./scalar.js"; +import type { UnionHttpCanonicalization } from "./union.js"; +const polymorphicUnionCache = new WeakMap(); +function getUnionForPolymorphicModel($: Typekit, model: Model) { + if (polymorphicUnionCache.has(model)) { + return polymorphicUnionCache.get(model)!; + } + + const unionInfo = $.model.getDiscriminatedUnion(model)!; + + const union = $.union.create({ + name: model.name + "Union", + decorators: [ + [ + discriminatedDecorator, + { envelope: "none", discriminatorPropertyName: unionInfo.propertyName }, + ], + ], + variants: [...unionInfo.variants].map(([name, type]) => { + return $.unionVariant.create({ name, type }); + }), + }); + + polymorphicUnionCache.set(model, union); + return union; +} /** * Canonicalizes models for HTTP. */ -export class ModelHttpCanonicalization extends ModelMutation< - HttpCanonicalizationOptions, - HttpCanonicalizationMutations, - HttpCanonicalizer -> { - /** - * Indicates if the canonicalization wraps a named TypeSpec declaration. - */ - isDeclaration: boolean = false; +export class ModelHttpCanonicalization + extends ModelMutation< + HttpCanonicalizationMutations, + HttpCanonicalizationOptions, + HttpCanonicalizer + > + implements HttpCanonicalizationCommon +{ + isDeclaration = false; + codec: Codec | null = null; + + #languageMutationNode: MutationNodeForType; + get languageMutationNode() { + return this.#languageMutationNode; + } + + #wireMutationNode: MutationNodeForType; + get wireMutationNode() { + return this.#wireMutationNode; + } + + get languageType() { + return this.#languageMutationNode.mutatedType; + } + + get wireType() { + return this.#wireMutationNode.mutatedType; + } /** - * Codec chosen to transform language and wire types for this model. + * Whether this this model is a polymorphic model, i.e. has the @discriminator + * decorator on it. Such models are essentially unions of all their subtypes. */ - codec: Codec; + isPolymorphicModel: boolean = false; /** - * Mutation subgraph for language types. + * When this model is a polymorphic model, a discriminated union type of all + * the subtypes of the model. */ - get #languageSubgraph() { - return this.engine.getLanguageSubgraph(this.options); - } + polymorphicModelUnion: UnionHttpCanonicalization | null = null; /** - * Mutation subgraph for wire types. + * Tests whether the subgraph rooted at this canonicalization uses only + * the identity codec (no transformation). */ - get #wireSubgraph() { - return this.engine.getWireSubgraph(this.options); + subgraphUsesIdentityCodec(): boolean { + return this.engine.subgraphUsesIdentityCodec(this); } /** - * The possibly mutated language type for this model. + * Tests whether the subgraph rooted at this canonicalization satisfies + * the provided predicate. */ - get languageType() { - return this.getMutatedType(this.#languageSubgraph); + subgraphMatchesPredicate(predicate: CanonicalizationPredicate): boolean { + return this.engine.subgraphMatchesPredicate(this, predicate); } - /** - * The possibly mutated wire type for this model. - */ - get wireType() { - return this.getMutatedType(this.#wireSubgraph); + static mutationInfo( + engine: HttpCanonicalizer, + sourceType: Model, + referenceTypes: MemberType[], + options: HttpCanonicalizationOptions, + halfEdge?: MutationHalfEdge, + traits?: MutationTraits, + ): HttpCanonicalizationInfo | UnionHttpCanonicalization | ModelHttpCanonicalization { + // Models don't directly use codecs, they're used on their properties + const isDiscriminated = !!getDiscriminator(engine.$.program, sourceType); + if (halfEdge?.head !== undefined && halfEdge.kind !== "base" && isDiscriminated) { + // If we aren't the base of another model, we are the union. + + const union = getUnionForPolymorphicModel(engine.$, sourceType); + if (referenceTypes.length === 0) { + return engine.mutate(union, options, halfEdge, { isSynthetic: true }); + } else { + return engine.replaceAndMutateReference(referenceTypes[0], union, options, halfEdge); + } + } + + const effectiveModel = engine.$.model.getEffectiveModel(sourceType); + if (effectiveModel !== sourceType) { + // If this model is an alias, we just forward to the effective model + if (referenceTypes.length === 0) { + return engine.mutate(effectiveModel, options, halfEdge); + } else { + return engine.replaceAndMutateReference( + referenceTypes[0], + effectiveModel, + options, + halfEdge, + ); + } + } + + return { + mutationKey: options.mutationKey, + isPolymorphicModel: isDiscriminated, + isSynthetic: traits?.isSynthetic, + }; } constructor( @@ -59,11 +158,21 @@ export class ModelHttpCanonicalization extends ModelMutation< sourceType: Model, referenceTypes: MemberType[], options: HttpCanonicalizationOptions, + info: HttpCanonicalizationInfo, + traits: MutationTraits, ) { - super(engine, sourceType, referenceTypes, options); + super(engine, sourceType, referenceTypes, options, info); this.isDeclaration = !!this.sourceType.name; - const registry = getJsonEncoderRegistry(this.engine.$); - this.codec = registry.detect(this); + this.#languageMutationNode = this.engine.getMutationNode(this.sourceType, { + mutationKey: info.mutationKey + "-language", + isSynthetic: info.isSynthetic, + }); + this.#wireMutationNode = this.engine.getMutationNode(this.sourceType, { + mutationKey: info.mutationKey + "-wire", + isSynthetic: info.isSynthetic, + }); + + this.isPolymorphicModel = !!info.isPolymorphicModel; } /** @@ -78,48 +187,68 @@ export class ModelHttpCanonicalization extends ModelMutation< ); } - /** - * Applies mutations required to build the language and wire views of the model. - */ mutate() { - const languageNode = this.getMutationNode(this.engine.getLanguageSubgraph(this.options)); - languageNode.whenMutated(this.#renameWhenMutated.bind(this)); + if (this.isPolymorphicModel) { + this.polymorphicModelUnion = this.engine.canonicalize( + getUnionForPolymorphicModel(this.engine.$, this.sourceType), + this.options, + ); + } + // fix up merge patch update graph node + if (this.sourceType.name === "MergePatchUpdate" && this.sourceType.properties.size > 0) { + const firstProp = this.sourceType.properties.values().next().value!; + const model = firstProp.model!; - const wireNode = this.getMutationNode(this.engine.getWireSubgraph(this.options)); - wireNode.whenMutated(this.#renameWhenMutated.bind(this)); + this.#languageMutationNode = this.#languageMutationNode.replace( + this.engine.$.type.clone(model), + ) as any; + this.#wireMutationNode = this.#wireMutationNode.replace( + this.engine.$.type.clone(model), + ) as any; + } - if (this.engine.$.array.is(this.sourceType) && this.sourceType.name === "Array") { - if (this.sourceType.baseModel) { - this.baseModel = this.engine.mutate(this.sourceType.baseModel, this.options); - } + const friendlyName = getFriendlyName(this.engine.$.program, this.sourceType); + if (friendlyName) { + this.#languageMutationNode.mutate((type) => { + type.name = friendlyName; + }); + this.#wireMutationNode.mutate((type) => (type.name = friendlyName)); + } else { + this.#languageMutationNode.whenMutated(this.#renameWhenMutated.bind(this)); + this.#wireMutationNode.whenMutated(this.#renameWhenMutated.bind(this)); + } - for (const prop of this.sourceType.properties.values()) { - this.properties.set(prop.name, this.engine.mutate(prop, this.options)); - } + super.mutateBaseModel(); + super.mutateProperties(); + super.mutateIndexer(); + } - const newIndexerOptions: Partial = { - visibility: this.options.visibility | Visibility.Item, - }; + protected startBaseEdge(): MutationHalfEdge { + return new MutationHalfEdge("base", this, (tail) => { + this.#languageMutationNode.connectBase(tail.languageMutationNode); + this.#wireMutationNode.connectBase(tail.wireMutationNode); + }); + } - if (this.options.isJsonMergePatch()) { - newIndexerOptions.contentType = "application/json"; - } + protected startPropertyEdge(): MutationHalfEdge { + return new MutationHalfEdge("property", this, (tail) => { + this.#languageMutationNode.connectProperty(tail.languageMutationNode); + this.#wireMutationNode.connectProperty(tail.wireMutationNode); + }); + } - this.indexer = { - key: this.engine.mutate( - this.sourceType.indexer.key, - this.options, - ) as ScalarHttpCanonicalization, - value: this.engine.mutate( - this.sourceType.indexer.value, - this.options.with(newIndexerOptions), - ), - }; - - return; - } + protected startIndexerValueEdge(): MutationHalfEdge { + return new MutationHalfEdge("indexerValue", this, (tail) => { + this.#languageMutationNode.connectIndexerValue(tail.languageMutationNode); + this.#wireMutationNode.connectIndexerValue(tail.wireMutationNode); + }); + } - super.mutate(); + protected startIndexerKeyEdge(): MutationHalfEdge { + return new MutationHalfEdge("indexerKey", this, (tail) => { + this.#languageMutationNode.connectIndexerKey(tail.languageMutationNode); + this.#wireMutationNode.connectIndexerKey(tail.wireMutationNode); + }); } /** diff --git a/packages/http-canonicalization/src/operation.test.ts b/packages/http-canonicalization/src/operation.test.ts index 6e8befa1b4f..0aa1e687fbe 100644 --- a/packages/http-canonicalization/src/operation.test.ts +++ b/packages/http-canonicalization/src/operation.test.ts @@ -1,4 +1,5 @@ -import { t, type TesterInstance } from "@typespec/compiler/testing"; +import type { Model } from "@typespec/compiler"; +import { expectTypeEquals, t, type TesterInstance } from "@typespec/compiler/testing"; import { $ } from "@typespec/compiler/typekit"; import { beforeEach, describe, expect, it } from "vitest"; import { Tester } from "../test/test-host.js"; @@ -28,7 +29,11 @@ describe("Operation parameters", async () => { const canonicalizer = new HttpCanonicalizer(tk); const createFooCanonical = canonicalizer.canonicalize(createFoo); - const bodyType = createFooCanonical.requestParameters.body!.type as ModelHttpCanonicalization; + const body = createFooCanonical.requestParameters.body!; + expect(body.bodyKind).toBe("single"); + if (body.bodyKind !== "single") throw new Error("Expected single body"); + expect(body.bodies.length).toBe(1); + const bodyType = body.bodies[0]!.type as ModelHttpCanonicalization; expect(bodyType).toBeDefined(); expect(bodyType).toBeInstanceOf(ModelHttpCanonicalization); const fooProp = bodyType.properties.get("foo")!; @@ -56,7 +61,11 @@ describe("Operation parameters", async () => { const tk = $(program); const canonicalizer = new HttpCanonicalizer(tk); const createFooCanonical = canonicalizer.canonicalize(createFoo); - const bodyType = createFooCanonical.requestParameters.body!.type as ModelHttpCanonicalization; + const body = createFooCanonical.requestParameters.body!; + expect(body.bodyKind).toBe("single"); + if (body.bodyKind !== "single") throw new Error("Expected single body"); + expect(body.bodies.length).toBe(1); + const bodyType = body.bodies[0]!.type as ModelHttpCanonicalization; expect(bodyType).toBeDefined(); expect(bodyType).toBeInstanceOf(ModelHttpCanonicalization); expect(bodyType.languageType.name).toBe("FooCreate"); @@ -80,10 +89,92 @@ describe("Operation parameters", async () => { const dateProp = createFooCanonical.requestParameters.properties[0]; expect(dateProp.kind).toBe("header"); const scalarType = dateProp.property.type as ScalarHttpCanonicalization; - expect(scalarType.wireType === tk.builtin.string).toBe(true); + expectTypeEquals(scalarType.wireType, tk.builtin.string); expect(scalarType.codec.id).toBe("rfc7231"); expect(createFooCanonical.requestParameters.properties.length).toBe(2); }); + + it("works with merge patch", async () => { + const { updateFoo, program } = await runner.compile(t.code` + model ${t.model("Foo")} { + @visibility(Lifecycle.Read) createdAt: utcDateTime; + name: string; + } + + @route("/foo") + @patch + op ${t.op("updateFoo")}(@body body: MergePatchUpdate): Foo; + `); + + const tk = $(program); + const canonicalizer = new HttpCanonicalizer(tk); + const updateFooCanonical = canonicalizer.canonicalize(updateFoo); + const body = updateFooCanonical.requestParameters.body!; + expect(body.bodyKind).toBe("single"); + if (body.bodyKind !== "single") throw new Error("Expected single body"); + const bodyProp = body.bodies[0]!.property!; + expect(bodyProp.languageType.name).toBe("body"); + expect((bodyProp.languageType.type as Model).name).toBe("FooMergePatchUpdate"); + expect(body.bodies.length).toBe(1); + expect(body.bodies[0]!.type).toBeInstanceOf(ModelHttpCanonicalization); + expect((body.bodies[0]!.type.languageType as Model).name).toBe("FooMergePatchUpdate"); + }); + + it("has the same canonicalization for bodies inside and outside parameters", async () => { + const { createFoo, program } = await runner.compile(t.code` + model ${t.model("Foo")} { + name: string; + } + + @route("/foo") + @post + op ${t.op("createFoo")}(@body body: Foo): Foo; + `); + + const tk = $(program); + const canonicalizer = new HttpCanonicalizer(tk); + const createFooCanonical = canonicalizer.canonicalize(createFoo); + const body = createFooCanonical.requestParameters.body!; + expect(body.bodyKind).toBe("single"); + if (body.bodyKind !== "single") throw new Error("Expected single body"); + const viaBody = body.bodies[0]!.type; + const viaProp = createFooCanonical.requestParameters.properties[0]!.property + .type as ModelHttpCanonicalization; + expect(viaBody === viaProp).toBe(true); + }); + + it("handles multiple content types", async () => { + const { createFoo, program } = await runner.compile(t.code` + model ${t.model("Foo")} { + name: string; + } + + @route("/foo") + @post + op ${t.op("createFoo")}(@header("Content-Type") contentType: "application/json" | "application/xml", @body body: Foo): Foo; + `); + + const tk = $(program); + const canonicalizer = new HttpCanonicalizer(tk); + const createFooCanonical = canonicalizer.canonicalize(createFoo); + const body = createFooCanonical.requestParameters.body!; + expect(body.bodyKind).toBe("single"); + if (body.bodyKind !== "single") throw new Error("Expected single body"); + + // Should have canonicalized bodies for both content types + expect(body.bodies.length).toBe(2); + expect(body.contentTypes).toEqual(["application/json", "application/xml"]); + + // Check first content type (application/json) + const jsonBody = body.bodies[0]!; + expect(jsonBody.contentType).toBe("application/json"); + expect(jsonBody.type).toBeInstanceOf(ModelHttpCanonicalization); + + // Check second content type (application/xml) + const xmlBody = body.bodies[1]!; + expect(xmlBody.contentType).toBe("application/xml"); + expect(xmlBody.type).toBeInstanceOf(ModelHttpCanonicalization); + }); }); describe("Operation responses", async () => { @@ -116,13 +207,15 @@ describe("Operation responses", async () => { const etagHeader = content.headers!.etag; expect(etagHeader).toBeDefined(); const etagType = etagHeader!.type as ScalarHttpCanonicalization; - expect(etagType.wireType === tk.builtin.string).toBe(true); + expectTypeEquals(etagType.wireType, tk.builtin.string); expect(content.body).toBeDefined(); const body = content.body!; expect(body.bodyKind).toBe("single"); + if (body.bodyKind !== "single") throw new Error("Expected single body"); - const bodyType = body.type as ModelHttpCanonicalization; + expect(body.bodies.length).toBe(1); + const bodyType = body.bodies[0]!.type as ModelHttpCanonicalization; expect(bodyType.visibleProperties.has("name")).toBe(true); expect(bodyType.visibleProperties.has("createdAt")).toBe(true); }); diff --git a/packages/http-canonicalization/src/operation.ts b/packages/http-canonicalization/src/operation.ts index a4b15abd7a3..4fb8f85f3f3 100644 --- a/packages/http-canonicalization/src/operation.ts +++ b/packages/http-canonicalization/src/operation.ts @@ -1,18 +1,28 @@ import type { MemberType, ModelProperty, Operation } from "@typespec/compiler"; import { - type HttpOperation, - type HttpVerb, resolveRequestVisibility, Visibility, + type HttpOperation, + type HttpVerb, } from "@typespec/http"; import "@typespec/http/experimental/typekit"; -import { OperationMutation } from "@typespec/mutator-framework"; +import { + MutationHalfEdge, + OperationMutation, + type MutationNodeForType, + type MutationTraits, +} from "@typespec/mutator-framework"; import type { HttpCanonicalization, HttpCanonicalizationMutations, } from "./http-canonicalization-classes.js"; -import type { HttpCanonicalizer } from "./http-canonicalization.js"; +import type { + CanonicalizationPredicate, + HttpCanonicalizationCommon, + HttpCanonicalizationInfo, + HttpCanonicalizer, +} from "./http-canonicalization.js"; import type { ModelPropertyHttpCanonicalization } from "./model-property.js"; import type { ModelHttpCanonicalization } from "./model.js"; import { HttpCanonicalizationOptions } from "./options.js"; @@ -170,12 +180,22 @@ export interface CanonicalHttpOperationBodyBase { readonly property?: ModelPropertyHttpCanonicalization; } -export interface CanonicalHttpBody { +export interface CanonicalHttpBodyForContentType { + /** The content type for this canonicalization. */ + readonly contentType: string; + /** The canonicalized type for this content type. */ readonly type: HttpCanonicalization; + /** The canonicalized property for this content type, if any. */ + readonly property?: ModelPropertyHttpCanonicalization; +} + +export interface CanonicalHttpBody { /** If the body was explicitly set with `@body`. */ readonly isExplicit: boolean; /** If the body contains metadata annotations to ignore. */ readonly containsMetadataAnnotations: boolean; + /** Canonicalized body for each content type. */ + readonly bodies: CanonicalHttpBodyForContentType[]; } export interface CanonicalHttpOperationBody @@ -273,18 +293,19 @@ export interface CanonicalHttpOperationFileBody extends CanonicalHttpOperationBo /** * Canonicalizes operations by deriving HTTP-specific request and response shapes. */ -export class OperationHttpCanonicalization extends OperationMutation< - HttpCanonicalizationOptions, - HttpCanonicalizationMutations, - HttpCanonicalizer -> { +export class OperationHttpCanonicalization + extends OperationMutation< + HttpCanonicalizationOptions, + HttpCanonicalizationMutations, + HttpCanonicalizer + > + implements HttpCanonicalizationCommon +{ /** * Cached HTTP metadata for this operation. */ #httpOperationInfo: HttpOperation; - /** - * Indicates if the operation corresponds to a named declaration. Always true. - */ + codec = null; isDeclaration: boolean = true; /** * Canonicalized request parameters grouped by location. @@ -331,32 +352,66 @@ export class OperationHttpCanonicalization extends OperationMutation< */ name: string; + #languageMutationNode: MutationNodeForType; + #wireMutationNode: MutationNodeForType; + /** - * Mutation subgraph for language types. + * The language mutation node for this operation. */ - get #languageSubgraph() { - return this.engine.getLanguageSubgraph(this.options); + get languageMutationNode() { + return this.#languageMutationNode; } /** - * Mutation subgraph for wire types. + * The wire mutation node for this operation. */ - get #wireSubgraph() { - return this.engine.getWireSubgraph(this.options); + get wireMutationNode() { + return this.#wireMutationNode; } /** * The language type for this operation. */ get languageType() { - return this.getMutatedType(this.#languageSubgraph); + return this.#languageMutationNode.mutatedType; } /** * The wire type for this operation. */ get wireType() { - return this.getMutatedType(this.#wireSubgraph); + return this.#wireMutationNode.mutatedType; + } + + /** + * Tests whether the subgraph rooted at this canonicalization uses only + * the identity codec (no transformation). + */ + subgraphUsesIdentityCodec(): boolean { + return this.engine.subgraphUsesIdentityCodec(this); + } + + /** + * Tests whether the subgraph rooted at this canonicalization satisfies + * the provided predicate. + */ + subgraphMatchesPredicate(predicate: CanonicalizationPredicate): boolean { + return this.engine.subgraphMatchesPredicate(this, predicate); + } + + static mutationInfo( + engine: HttpCanonicalizer, + sourceType: Operation, + referenceTypes: MemberType[], + options: HttpCanonicalizationOptions, + halfEdge?: MutationHalfEdge, + traits?: MutationTraits, + ): HttpCanonicalizationInfo { + return { + mutationKey: options.mutationKey, + codec: null as any, // Operations don't need a codec + isSynthetic: traits?.isSynthetic, + }; } constructor( @@ -364,8 +419,17 @@ export class OperationHttpCanonicalization extends OperationMutation< sourceType: Operation, referenceTypes: MemberType[] = [], options: HttpCanonicalizationOptions, + info: HttpCanonicalizationInfo, ) { - super(engine, sourceType, referenceTypes, options); + super(engine, sourceType, referenceTypes, options, info); + this.#languageMutationNode = this.engine.getMutationNode( + this.sourceType, + info.mutationKey + "-language", + ); + this.#wireMutationNode = this.engine.getMutationNode( + this.sourceType, + info.mutationKey + "-wire", + ); this.#httpOperationInfo = this.engine.$.httpOperation.get(this.sourceType); this.uriTemplate = this.#httpOperationInfo.uriTemplate; @@ -380,6 +444,20 @@ export class OperationHttpCanonicalization extends OperationMutation< this.method = this.#httpOperationInfo.verb; } + protected startParametersEdge(): MutationHalfEdge { + return new MutationHalfEdge("parameters", this, (tail) => { + this.#languageMutationNode.connectParameters(tail.languageMutationNode); + this.#wireMutationNode.connectParameters(tail.wireMutationNode); + }); + } + + protected startReturnTypeEdge(): MutationHalfEdge { + return new MutationHalfEdge("returnType", this, (tail) => { + this.#languageMutationNode.connectReturnType(tail.languageMutationNode); + this.#wireMutationNode.connectReturnType(tail.wireMutationNode); + }); + } + /** * Canonicalize this mutation for HTTP. */ @@ -413,7 +491,7 @@ export class OperationHttpCanonicalization extends OperationMutation< for (const param of paramInfo.properties) { this.requestParameters.properties.push( - this.#canonicalizeHttpProperty(param, this.parameterVisibility), + this.#canonicalizeHttpProperty(param, this.parameterVisibility, paramInfo.body), ); } @@ -466,9 +544,10 @@ export class OperationHttpCanonicalization extends OperationMutation< return { properties: content.properties.map((property) => - this.#canonicalizeHttpProperty(property, this.returnTypeVisibility), + this.#canonicalizeHttpProperty(property, this.returnTypeVisibility, content.body), ), headers: Object.keys(canonicalHeaders).length > 0 ? canonicalHeaders : undefined, + body: content.body ? this.#canonicalizeBody(content.body, this.returnTypeVisibility) : undefined, @@ -481,8 +560,23 @@ export class OperationHttpCanonicalization extends OperationMutation< #canonicalizeHttpProperty( property: HttpOperationPropertyInfo, visibility: Visibility, + bodyInfo?: HttpPayloadBodyInfo, ): CanonicalHttpProperty { - const canonicalProperty = this.#canonicalizeModelProperty(property.property, visibility); + // For body-related properties, we need to pass the content type from the body info + const contentType = + bodyInfo && + (property.kind === "body" || + property.kind === "bodyRoot" || + property.kind === "multipartBody" || + property.kind === "bodyProperty") + ? bodyInfo.contentTypes[0] + : undefined; + + const canonicalProperty = this.#canonicalizeModelProperty( + property.property, + visibility, + contentType, + ); switch (property.kind) { case "header": @@ -581,15 +675,18 @@ export class OperationHttpCanonicalization extends OperationMutation< contentTypeProperty: body.contentTypeProperty ? this.#canonicalizeModelProperty(body.contentTypeProperty, visibility) : undefined, - property: body.property - ? this.#canonicalizeModelProperty(body.property, visibility) - : undefined, - type: this.engine.canonicalize(body.type, { - visibility, - contentType: body.contentTypes[0], - }) as HttpCanonicalization, isExplicit: body.isExplicit, containsMetadataAnnotations: body.containsMetadataAnnotations, + bodies: body.contentTypes.map((contentType) => ({ + contentType, + type: this.engine.canonicalize(body.type, { + visibility, + contentType, + }) as HttpCanonicalization, + property: body.property + ? this.#canonicalizeModelProperty(body.property, visibility, contentType) + : undefined, + })), } satisfies CanonicalHttpOperationBody; case "multipart": return this.#canonicalizeMultipartBody(body, visibility); @@ -718,8 +815,12 @@ export class OperationHttpCanonicalization extends OperationMutation< #canonicalizeModelProperty( property: ModelProperty, visibility: Visibility, + contentType?: string, ): ModelPropertyHttpCanonicalization { - return this.engine.canonicalize(property, new HttpCanonicalizationOptions({ visibility })); + return this.engine.canonicalize( + property, + new HttpCanonicalizationOptions({ visibility, contentType }), + ); } /** diff --git a/packages/http-canonicalization/src/options.ts b/packages/http-canonicalization/src/options.ts index ef7e7d40042..6a66ff55cf6 100644 --- a/packages/http-canonicalization/src/options.ts +++ b/packages/http-canonicalization/src/options.ts @@ -1,5 +1,6 @@ import { Visibility } from "@typespec/http"; import { MutationOptions } from "@typespec/mutator-framework"; +import type { HttpCanonicalization } from "./http-canonicalization-classes.js"; export type HttpCanonicalizationLocation = | "header" @@ -14,20 +15,23 @@ export interface HttpCanonicalizationOptionsInit { visibility?: Visibility; location?: HttpCanonicalizationLocation; contentType?: string; + namePolicy?: (canonicalization: HttpCanonicalization) => string | undefined; } export class HttpCanonicalizationOptions extends MutationOptions { visibility: Visibility; location: HttpCanonicalizationLocation; contentType: string; + namePolicy?: (canonicalization: HttpCanonicalization) => string | undefined; constructor(options: HttpCanonicalizationOptionsInit = {}) { super(); this.visibility = options.visibility ?? Visibility.All; this.location = options.location ?? "body"; this.contentType = options.contentType ?? "none"; + this.namePolicy = options.namePolicy; } - cacheKey(): string { + get mutationKey(): string { return `visibility:${this.visibility}|location:${this.location}|contentType:${this.contentType}`; } @@ -36,10 +40,7 @@ export class HttpCanonicalizationOptions extends MutationOptions { visibility: newOptions.visibility ?? this.visibility, location: newOptions.location ?? this.location, contentType: newOptions.contentType ?? this.contentType, + namePolicy: newOptions.namePolicy ?? this.namePolicy, }); } - - isJsonMergePatch(): boolean { - return this.contentType === "application/merge-patch+json"; - } } diff --git a/packages/http-canonicalization/src/scalar.test.ts b/packages/http-canonicalization/src/scalar.test.ts index 58d6f3e1a28..ec832d8e52c 100644 --- a/packages/http-canonicalization/src/scalar.test.ts +++ b/packages/http-canonicalization/src/scalar.test.ts @@ -1,4 +1,4 @@ -import { t, type TesterInstance } from "@typespec/compiler/testing"; +import { expectTypeEquals, t, type TesterInstance } from "@typespec/compiler/testing"; import { $ } from "@typespec/compiler/typekit"; import { Visibility } from "@typespec/http"; import { beforeEach, expect, it } from "vitest"; @@ -24,9 +24,9 @@ it("canonicalizes a string", async () => { }); // No mutation happens in this case, so: - expect(canonicalMyString.sourceType === canonicalMyString.languageType).toBe(true); + expectTypeEquals(canonicalMyString.sourceType, canonicalMyString.languageType); - expect(canonicalMyString.sourceType === canonicalMyString.wireType).toBe(true); + expectTypeEquals(canonicalMyString.sourceType, canonicalMyString.wireType); expect(canonicalMyString.codec.id).toBe("identity"); }); @@ -39,17 +39,17 @@ it("canonicalizes an int32 scalar", async () => { const tk = $(program); const engine = new HttpCanonicalizer(tk); - const canonicalMyString = engine.canonicalize(myNumber, { + const canonicalMyNumber = engine.canonicalize(myNumber, { visibility: Visibility.Read, + contentType: "application/json", }); // We leave the language type the same - expect(canonicalMyString.sourceType === canonicalMyString.languageType).toBe(true); - + expectTypeEquals(canonicalMyNumber.sourceType, canonicalMyNumber.languageType); // but the wire type is a float64 - expect(canonicalMyString.sourceType === canonicalMyString.wireType).toBe(false); - expect(canonicalMyString.wireType === tk.builtin.float64).toBe(true); - expect(canonicalMyString.codec.id).toBe("coerce-to-float64"); + expect(canonicalMyNumber.sourceType === canonicalMyNumber.wireType).toBe(false); + expectTypeEquals(canonicalMyNumber.wireType, tk.builtin.float64); + expect(canonicalMyNumber.codec.id).toBe("coerce-to-float64"); }); it("canonicalizes a utcDateTime scalar", async () => { @@ -62,9 +62,10 @@ it("canonicalizes a utcDateTime scalar", async () => { const canonicalMyString = engine.canonicalize(myDateTime, { visibility: Visibility.Read, + contentType: "application/json", }); - expect(canonicalMyString.wireType === tk.builtin.string).toBe(true); + expectTypeEquals(canonicalMyString.wireType, tk.builtin.string); expect(canonicalMyString.codec.id).toBe("rfc3339"); }); @@ -79,17 +80,18 @@ it("canonicalizes a utcDateTime scalar with encode decorator", async () => { const canonicalMyString = engine.canonicalize(myDateTime, { visibility: Visibility.Read, + contentType: "application/json", }); // the codec is set appropriately expect(canonicalMyString.codec.id).toBe("rfc7231"); // We leave the language type the same - expect(canonicalMyString.sourceType === canonicalMyString.languageType).toBe(true); + expectTypeEquals(canonicalMyString.sourceType, canonicalMyString.languageType); // but the wire type is a string expect(canonicalMyString.sourceType === canonicalMyString.wireType).toBe(false); - expect(canonicalMyString.wireType === tk.builtin.string).toBe(true); + expectTypeEquals(canonicalMyString.wireType, tk.builtin.string); }); it("canonicalizes a utcDateTime scalar with encode decorator on a member", async () => { @@ -105,6 +107,7 @@ it("canonicalizes a utcDateTime scalar with encode decorator on a member", async const engine = new HttpCanonicalizer(tk); const canonicalFoo = engine.canonicalize(Foo, { visibility: Visibility.Read, + contentType: "application/json", }); // navigating canonicalization @@ -112,11 +115,39 @@ it("canonicalizes a utcDateTime scalar with encode decorator on a member", async .type as ScalarHttpCanonicalization; expect(canonicalDateTime).toBeInstanceOf(ScalarHttpCanonicalization); - expect(canonicalDateTime.wireType === tk.builtin.string).toBe(true); + expectTypeEquals(canonicalDateTime.wireType, tk.builtin.string); expect(canonicalDateTime.codec.id).toBe("rfc7231"); // navigating mutated type const wireFoo = canonicalFoo.wireType; const wireDateType = wireFoo.properties.get("createdAt")!.type; - expect(wireDateType === tk.builtin.string).toBe(true); + expectTypeEquals(wireDateType, tk.builtin.string); +}); + +it("canonicalizes a unix timestamp", async () => { + const { myDateTime1, myDateTime2, program } = await runner.compile(t.code` + @encode("unixTimestamp", int32) + scalar ${t.scalar("myDateTime1")} extends utcDateTime; + @encode(DateTimeKnownEncoding.unixTimestamp, int32) + scalar ${t.scalar("myDateTime2")} extends utcDateTime; + `); + + const tk = $(program); + const engine = new HttpCanonicalizer(tk); + + const canonicalDate1 = engine.canonicalize(myDateTime1, { + visibility: Visibility.Read, + contentType: "application/json", + }); + + // the codec is set appropriately + expect(canonicalDate1.codec.id).toBe("unix-timestamp-32"); + + const canonicalDate2 = engine.canonicalize(myDateTime2, { + visibility: Visibility.Read, + contentType: "application/json", + }); + + // the codec is set appropriately + expect(canonicalDate2.codec.id).toBe("unix-timestamp-32"); }); diff --git a/packages/http-canonicalization/src/scalar.ts b/packages/http-canonicalization/src/scalar.ts index 67bf3079ff4..3ae9ba5e13d 100644 --- a/packages/http-canonicalization/src/scalar.ts +++ b/packages/http-canonicalization/src/scalar.ts @@ -1,57 +1,89 @@ import type { MemberType, Scalar } from "@typespec/compiler"; -import { ScalarMutation } from "@typespec/mutator-framework"; -import { getJsonEncoderRegistry, type Codec } from "./codecs.js"; +import { + MutationHalfEdge, + ScalarMutation, + type MutationNodeForType, + type MutationTraits, +} from "@typespec/mutator-framework"; +import { type Codec, type EncodingInfo } from "./codecs.js"; import type { HttpCanonicalizationMutations } from "./http-canonicalization-classes.js"; -import type { HttpCanonicalizer } from "./http-canonicalization.js"; +import type { + CanonicalizationPredicate, + HttpCanonicalizationCommon, + HttpCanonicalizationInfo, + HttpCanonicalizer, +} from "./http-canonicalization.js"; import { HttpCanonicalizationOptions } from "./options.js"; /** * Canonicalizes scalar types by applying encoding-specific mutations. */ -export class ScalarHttpCanonicalization extends ScalarMutation< - HttpCanonicalizationOptions, - HttpCanonicalizationMutations, - HttpCanonicalizer -> { - /** - * Canonicalization options. - */ - options: HttpCanonicalizationOptions; - /** - * Codec responsible for transforming the scalar into language and wire types. - */ +export class ScalarHttpCanonicalization + extends ScalarMutation< + HttpCanonicalizationOptions, + HttpCanonicalizationMutations, + HttpCanonicalizer + > + implements HttpCanonicalizationCommon +{ codec: Codec; - /** - * Indicates whether the scalar is a named TypeSpec declaration. - */ + #encodingInfo: EncodingInfo; isDeclaration: boolean = false; - /** - * Mutation subgraph for language types. - */ - get #languageSubgraph() { - return this.engine.getLanguageSubgraph(this.options); + #languageMutationNode: MutationNodeForType; + get languageMutationNode() { + return this.#languageMutationNode; } - /** - * Mutation subgraph for wire types. - */ - get #wireSubgraph() { - return this.engine.getWireSubgraph(this.options); + #wireMutationNode: MutationNodeForType; + get wireMutationNode() { + return this.#wireMutationNode; + } + + get languageType() { + return this.#languageMutationNode.mutatedType; + } + + get wireType() { + return this.#wireMutationNode.mutatedType; } /** - * The possibly mutated language type for this scalar. + * Tests whether the subgraph rooted at this canonicalization uses only + * the identity codec (no transformation). */ - get languageType() { - return this.getMutatedType(this.#languageSubgraph); + subgraphUsesIdentityCodec(): boolean { + return this.engine.subgraphUsesIdentityCodec(this); } /** - * The possibly mutated wire type for this scalar. + * Tests whether the subgraph rooted at this canonicalization satisfies + * the provided predicate. */ - get wireType() { - return this.getMutatedType(this.#wireSubgraph); + subgraphMatchesPredicate(predicate: CanonicalizationPredicate): boolean { + return this.engine.subgraphMatchesPredicate(this, predicate); + } + + static mutationInfo( + engine: HttpCanonicalizer, + sourceType: Scalar, + referenceTypes: MemberType[], + options: HttpCanonicalizationOptions, + halfEdge?: MutationHalfEdge, + traits?: MutationTraits, + ): HttpCanonicalizationInfo { + let mutationKey = options.mutationKey; + const encodingInfo = engine.codecs.encode(sourceType, referenceTypes); + + if (encodingInfo.codec) { + mutationKey += `-codec-${encodingInfo.codec.id}`; + } + + return { + mutationKey, + encodingInfo, + isSynthetic: traits?.isSynthetic, + }; } constructor( @@ -59,12 +91,21 @@ export class ScalarHttpCanonicalization extends ScalarMutation< sourceType: Scalar, referenceTypes: MemberType[], options: HttpCanonicalizationOptions, + info: HttpCanonicalizationInfo, ) { - super(engine, sourceType, referenceTypes, options); + super(engine, sourceType, referenceTypes, options, info); this.options = options; + this.#languageMutationNode = this.engine.getMutationNode( + this.sourceType, + info.mutationKey + "-language", + ); + this.#wireMutationNode = this.engine.getMutationNode( + this.sourceType, + info.mutationKey + "-wire", + ); - const registry = getJsonEncoderRegistry(this.engine.$); - this.codec = registry.detect(this); + this.#encodingInfo = info.encodingInfo!; + this.codec = info.encodingInfo!.codec; this.isDeclaration = false; } @@ -72,15 +113,23 @@ export class ScalarHttpCanonicalization extends ScalarMutation< * Canonicalize this scalar for HTTP. */ mutate() { - const languageNode = this.getMutationNode(this.#languageSubgraph); - const wireNode = this.getMutationNode(this.#wireSubgraph); - - const { languageType, wireType } = this.codec.encode(); + const { languageType, wireType } = this.#encodingInfo; if (languageType !== this.sourceType) { - languageNode.replace(languageType as Scalar); + this.#languageMutationNode = this.#languageMutationNode.replace( + languageType as Scalar, + ) as MutationNodeForType; } if (wireType !== this.sourceType) { - wireNode.replace(wireType as Scalar); + this.#wireMutationNode = this.#wireMutationNode.replace( + wireType as Scalar, + ) as MutationNodeForType; } } + + protected startBaseScalarEdge(): MutationHalfEdge { + return new MutationHalfEdge("base", this, (tail) => { + this.#languageMutationNode.connectBaseScalar(tail.languageMutationNode); + this.#wireMutationNode.connectBaseScalar(tail.wireMutationNode); + }); + } } diff --git a/packages/http-canonicalization/src/union-variant.ts b/packages/http-canonicalization/src/union-variant.ts index cec46814cbe..c5f5870bc9b 100644 --- a/packages/http-canonicalization/src/union-variant.ts +++ b/packages/http-canonicalization/src/union-variant.ts @@ -1,56 +1,86 @@ import type { MemberType, UnionVariant } from "@typespec/compiler"; -import { UnionVariantMutation } from "@typespec/mutator-framework"; +import { + MutationHalfEdge, + UnionVariantMutation, + type MutationNodeForType, + type MutationTraits, +} from "@typespec/mutator-framework"; +import type { Codec } from "./codecs.js"; import type { HttpCanonicalizationMutations } from "./http-canonicalization-classes.js"; -import type { HttpCanonicalizer } from "./http-canonicalization.js"; +import type { + CanonicalizationPredicate, + HttpCanonicalizationCommon, + HttpCanonicalizationInfo, + HttpCanonicalizer, +} from "./http-canonicalization.js"; import { HttpCanonicalizationOptions } from "./options.js"; /** * Canonicalizes a union variant for HTTP. */ -export class UnionVariantHttpCanonicalization extends UnionVariantMutation< - HttpCanonicalizationOptions, - HttpCanonicalizationMutations, - HttpCanonicalizer -> { - /** - * Canonicalization options. - */ - options: HttpCanonicalizationOptions; - /** - * Indicates if the variant corresponds to a named declaration. Always false. - */ +export class UnionVariantHttpCanonicalization + extends UnionVariantMutation< + HttpCanonicalizationOptions, + HttpCanonicalizationMutations, + HttpCanonicalizer + > + implements HttpCanonicalizationCommon +{ isDeclaration: boolean = false; + codec: Codec | null = null; /** * Whether the variant is visible under the current visibility options. */ isVisible: boolean = true; - /** - * Mutation subgraph for language types. - */ - get #languageSubgraph() { - return this.engine.getLanguageSubgraph(this.options); + #languageMutationNode: MutationNodeForType; + #wireMutationNode: MutationNodeForType; + + get languageMutationNode() { + return this.#languageMutationNode; } - /** - * Mutation subgraph for wire types. - */ - get #wireSubgraph() { - return this.engine.getWireSubgraph(this.options); + get wireMutationNode() { + return this.#wireMutationNode; + } + + get languageType() { + return this.#languageMutationNode.mutatedType; + } + + get wireType() { + return this.#wireMutationNode.mutatedType; } /** - * The possibly mutated language type for this variant. + * Tests whether the subgraph rooted at this canonicalization uses only + * the identity codec (no transformation). */ - get languageType() { - return this.getMutatedType(this.#languageSubgraph); + subgraphUsesIdentityCodec(): boolean { + return this.engine.subgraphUsesIdentityCodec(this); } /** - * The possibly mutated wire type for this variant. + * Tests whether the subgraph rooted at this canonicalization satisfies + * the provided predicate. */ - get wireType() { - return this.getMutatedType(this.#wireSubgraph); + subgraphMatchesPredicate(predicate: CanonicalizationPredicate): boolean { + return this.engine.subgraphMatchesPredicate(this, predicate); + } + + static mutationInfo( + engine: HttpCanonicalizer, + sourceType: UnionVariant, + referenceTypes: MemberType[], + options: HttpCanonicalizationOptions, + halfEdge?: MutationHalfEdge, + traits?: MutationTraits, + ): HttpCanonicalizationInfo { + return { + mutationKey: options.mutationKey, + codec: null as any, // Union variants don't need a codec + isSynthetic: traits?.isSynthetic, + }; } constructor( @@ -58,25 +88,38 @@ export class UnionVariantHttpCanonicalization extends UnionVariantMutation< sourceType: UnionVariant, referenceTypes: MemberType[], options: HttpCanonicalizationOptions, + info: HttpCanonicalizationInfo, ) { - super(engine, sourceType, referenceTypes, options); + super(engine, sourceType, referenceTypes, options, info); this.options = options; + this.#languageMutationNode = this.engine.getMutationNode( + this.sourceType, + info.mutationKey + "-language", + ); + this.#wireMutationNode = this.engine.getMutationNode( + this.sourceType, + info.mutationKey + "-wire", + ); this.isDeclaration = !!this.sourceType.name; } + protected startTypeEdge(): MutationHalfEdge { + return new MutationHalfEdge("type", this, (tail) => { + this.#languageMutationNode.connectType(tail.languageMutationNode); + this.#wireMutationNode.connectType(tail.wireMutationNode); + }); + } + /** * Canonicalize this union variant for HTTP. */ mutate() { - const languageNode = this.getMutationNode(this.#languageSubgraph); - const wireNode = this.getMutationNode(this.#wireSubgraph); - if (this.isVisible) { super.mutate(); return; } - languageNode.delete(); - wireNode.delete(); + this.#languageMutationNode.delete(); + this.#wireMutationNode.delete(); } } diff --git a/packages/http-canonicalization/src/union.test.ts b/packages/http-canonicalization/src/union.test.ts index c0597d1a480..9d075d722dd 100644 --- a/packages/http-canonicalization/src/union.test.ts +++ b/packages/http-canonicalization/src/union.test.ts @@ -1,3 +1,4 @@ +import type { Model } from "@typespec/compiler"; import { t, type TesterInstance } from "@typespec/compiler/testing"; import { $ } from "@typespec/compiler/typekit"; import { Visibility } from "@typespec/http"; @@ -12,6 +13,39 @@ beforeEach(async () => { runner = await Tester.createInstance(); }); +it("works with discriminated unions without envelope", async () => { + const { Choice, program } = await runner.compile(t.code` + model ${t.model("First")} { kind: "first"; } + model ${t.model("Second")} { kind: "second"; } + + @discriminated(#{ envelope: "none", discriminatorPropertyName: "kind" }) + union ${t.union("Choice")} { + first: First; + second: Second; + } + `); + + const canonicalizer = new HttpCanonicalizer($(program)); + const canonical = canonicalizer.canonicalize(Choice, { + visibility: Visibility.All, + }) as UnionHttpCanonicalization; + + expect(canonical.languageVariantTests.length).toBe(0); + expect(canonical.variantDescriptors.length).toBe(2); + + const [firstVariant, secondVariant] = canonical.variantDescriptors; + + expect(firstVariant.variant.sourceType.type).toBeDefined(); + expect((firstVariant.variant.sourceType.type as Model).name).toBe("First"); + expect(firstVariant.envelopeType).toBe(null); + expect(firstVariant.discriminatorValue).toBe("first"); + + expect(secondVariant.variant.sourceType.type).toBeDefined(); + expect((secondVariant.variant.sourceType.type as Model).name).toBe("Second"); + expect(secondVariant.envelopeType).toBe(null); + expect(secondVariant.discriminatorValue).toBe("second"); +}); + describe("UnionCanonicalization variant detection", () => { it("detects literal property discriminants for object unions", async () => { const { Choice, First, Second, program } = await runner.compile(t.code` @@ -38,7 +72,7 @@ describe("UnionCanonicalization variant detection", () => { expect(canonical.wireVariantTests).toEqual(canonical.languageVariantTests); }); - it("prioritizes primitives before object variants and prepends type guards", async () => { + it("orders and takes into account previous tests", async () => { const { Choice, First, Second, program } = await runner.compile(t.code` model ${t.model("First")} { kind: "first"; } model ${t.model("Second")} { kind: "second"; } @@ -51,9 +85,7 @@ describe("UnionCanonicalization variant detection", () => { }) as UnionHttpCanonicalization; expect(canonical.languageVariantTests.length).toBe(3); - - const [numberVariant, firstVariant, secondVariant] = canonical.languageVariantTests; - + const [firstVariant, secondVariant, numberVariant] = canonical.languageVariantTests; expect(numberVariant.variant.sourceType.type.kind).toBe("Scalar"); expect(numberVariant.tests).toEqual([{ kind: "type", path: [], type: "number" }]); @@ -64,10 +96,7 @@ describe("UnionCanonicalization variant detection", () => { ]); expect(secondVariant.variant.sourceType.type).toBe(Second); - expect(secondVariant.tests).toEqual([ - { kind: "type", path: [], type: "object" }, - { kind: "literal", path: ["kind"], value: "second" }, - ]); + expect(secondVariant.tests).toEqual([{ kind: "type", path: [], type: "object" }]); expect(canonical.wireVariantTests.map((entry) => entry.variant)).toEqual( canonical.languageVariantTests.map((entry) => entry.variant), @@ -111,6 +140,28 @@ describe("UnionCanonicalization variant detection", () => { canonicalizer.canonicalize(Choice, { visibility: Visibility.All, }); - }).rejects.toThrow(/Unable to distinguish union variant/); + }).rejects.toThrow(/Unable to distinguish language type/); + }); + + it("supports extensible union pattern", async () => { + const { Choice, program } = await runner.compile(t.code` + union ${t.union("Choice")} { + string; + "first"; + "second"; + } + `); + + const canonicalizer = new HttpCanonicalizer($(program)); + const canonical = canonicalizer.canonicalize(Choice, { + visibility: Visibility.All, + }); + + expect(canonical.languageVariantTests).toHaveLength(3); + expect(canonical.languageVariantTests.map((entry) => entry.tests)).toEqual([ + [{ kind: "literal", path: [], value: "first" }], + [{ kind: "literal", path: [], value: "second" }], + [{ kind: "type", path: [], type: "string" }], + ]); }); }); diff --git a/packages/http-canonicalization/src/union.ts b/packages/http-canonicalization/src/union.ts index 0ec2857cf0b..28925e625cb 100644 --- a/packages/http-canonicalization/src/union.ts +++ b/packages/http-canonicalization/src/union.ts @@ -11,12 +11,22 @@ import type { UnionVariant, } from "@typespec/compiler"; import { getVisibilitySuffix, Visibility } from "@typespec/http"; -import { UnionMutation } from "@typespec/mutator-framework"; +import { + MutationHalfEdge, + UnionMutation, + type MutationNodeForType, + type MutationTraits, +} from "@typespec/mutator-framework"; import type { HttpCanonicalization, HttpCanonicalizationMutations, } from "./http-canonicalization-classes.js"; -import type { HttpCanonicalizer } from "./http-canonicalization.js"; +import type { + CanonicalizationPredicate, + HttpCanonicalizationCommon, + HttpCanonicalizationInfo, + HttpCanonicalizer, +} from "./http-canonicalization.js"; import { ModelHttpCanonicalization } from "./model.js"; import { HttpCanonicalizationOptions } from "./options.js"; import type { UnionVariantHttpCanonicalization } from "./union-variant.jsx"; @@ -71,15 +81,15 @@ interface VariantTestDefinition { /** * Canonicalizes union types, tracking discriminators and runtime variant tests. */ -export class UnionHttpCanonicalization extends UnionMutation< - HttpCanonicalizationOptions, - HttpCanonicalizationMutations, - HttpCanonicalizer -> { - /** - * Canonicalization options guiding union transformation. - */ - options: HttpCanonicalizationOptions; +export class UnionHttpCanonicalization + extends UnionMutation< + HttpCanonicalizationOptions, + HttpCanonicalizationMutations, + HttpCanonicalizer + > + implements HttpCanonicalizationCommon +{ + codec = null; /** * Indicates if this union corresponds to a named declaration. */ @@ -128,32 +138,66 @@ export class UnionHttpCanonicalization extends UnionMutation< */ envelopePropertyName: string | null = null; + #languageMutationNode: MutationNodeForType; + #wireMutationNode: MutationNodeForType; + /** - * Mutation subgraph for language types. + * The language mutation node for this union. */ - get #languageSubgraph() { - return this.engine.getLanguageSubgraph(this.options); + get languageMutationNode() { + return this.#languageMutationNode; } /** - * Mutation subgraph for wire types. + * The wire mutation node for this union. */ - get #wireSubgraph() { - return this.engine.getWireSubgraph(this.options); + get wireMutationNode() { + return this.#wireMutationNode; } /** * The potentially mutated language type for this union. */ get languageType() { - return this.getMutatedType(this.#languageSubgraph); + return this.#languageMutationNode.mutatedType; } /** * The potentially mutated wire type for this union. */ get wireType() { - return this.getMutatedType(this.#wireSubgraph); + return this.#wireMutationNode.mutatedType; + } + + /** + * Tests whether the subgraph rooted at this canonicalization uses only + * the identity codec (no transformation). + */ + subgraphUsesIdentityCodec(): boolean { + return this.engine.subgraphUsesIdentityCodec(this); + } + + /** + * Tests whether the subgraph rooted at this canonicalization satisfies + * the provided predicate. + */ + subgraphMatchesPredicate(predicate: CanonicalizationPredicate): boolean { + return this.engine.subgraphMatchesPredicate(this, predicate); + } + + static mutationInfo( + engine: HttpCanonicalizer, + sourceType: Union, + referenceTypes: MemberType[], + options: HttpCanonicalizationOptions, + halfEdge?: MutationHalfEdge, + traits?: MutationTraits, + ): HttpCanonicalizationInfo { + return { + mutationKey: options.mutationKey, + codec: null as any, // Unions don't need a codec + isSynthetic: traits?.isSynthetic, + }; } constructor( @@ -161,17 +205,39 @@ export class UnionHttpCanonicalization extends UnionMutation< sourceType: Union, referenceTypes: MemberType[], options: HttpCanonicalizationOptions, + info: HttpCanonicalizationInfo, ) { - super(engine, sourceType, referenceTypes, options); + super(engine, sourceType, referenceTypes, options, info); + this.#languageMutationNode = this.engine.getMutationNode(this.sourceType, { + mutationKey: info.mutationKey + "-language", + isSynthetic: info.isSynthetic, + }); + this.#wireMutationNode = this.engine.getMutationNode(this.sourceType, { + mutationKey: info.mutationKey + "-wire", + isSynthetic: info.isSynthetic, + }); + this.options = options; this.isDeclaration = !!this.sourceType.name; this.#discriminatedUnionInfo = this.engine.$.union.getDiscriminatedUnion(sourceType) ?? null; this.isDiscriminated = !!this.#discriminatedUnionInfo; - this.envelopePropertyName = this.#discriminatedUnionInfo?.options.envelopePropertyName ?? null; + this.envelopeKind = this.#discriminatedUnionInfo?.options.envelope ?? "none"; + this.envelopePropertyName = null; + if (this.#discriminatedUnionInfo?.options.envelope !== "none") { + this.envelopePropertyName = + this.#discriminatedUnionInfo?.options.envelopePropertyName ?? null; + } this.discriminatorPropertyName = this.#discriminatedUnionInfo?.options.discriminatorPropertyName ?? null; } + protected startVariantEdge(): MutationHalfEdge { + return new MutationHalfEdge("variant", this, (tail) => { + this.#languageMutationNode.connectVariant(tail.languageMutationNode); + this.#wireMutationNode.connectVariant(tail.wireMutationNode); + }); + } + /** * Returns variants that remain visible under the current visibility rules. */ @@ -187,12 +253,8 @@ export class UnionHttpCanonicalization extends UnionMutation< * Canonicalize this union for HTTP. */ mutate() { - const languageNode = this.getMutationNode(this.#languageSubgraph); - languageNode.whenMutated(this.#renameWhenMutated.bind(this)); - - const wireNode = this.getMutationNode(this.#wireSubgraph); - wireNode.whenMutated(this.#renameWhenMutated.bind(this)); - + this.#languageMutationNode.whenMutated(this.#renameWhenMutated.bind(this)); + this.#wireMutationNode.whenMutated(this.#renameWhenMutated.bind(this)); super.mutate(); if (this.isDiscriminated) { @@ -204,24 +266,28 @@ export class UnionHttpCanonicalization extends UnionMutation< throw new Error("symbolic variant names are not supported"); } + const canonicalizedVariant = variant as UnionVariantHttpCanonicalization; const descriptor: VariantDescriptor = { - variant, - envelopeType: this.engine.canonicalize( - this.engine.$.model.create({ - name: "", - properties: { - [discriminatorProp]: this.engine.$.modelProperty.create({ - name: discriminatorProp, - type: this.engine.$.literal.create(variantName), - }), - [envelopeProp]: this.engine.$.modelProperty.create({ - name: envelopeProp, - type: variant.languageType.type, - }), - }, - }), - this.options, - ) as unknown as ModelHttpCanonicalization, + variant: canonicalizedVariant, + envelopeType: + this.envelopeKind === "none" + ? null + : (this.engine.canonicalize( + this.engine.$.model.create({ + name: "", + properties: { + [discriminatorProp]: this.engine.$.modelProperty.create({ + name: discriminatorProp, + type: this.engine.$.literal.create(variantName), + }), + [envelopeProp]: this.engine.$.modelProperty.create({ + name: envelopeProp, + type: canonicalizedVariant.languageType.type, + }), + }, + }), + this.options, + ) as unknown as ModelHttpCanonicalization), discriminatorValue: variantName, }; @@ -240,6 +306,10 @@ export class UnionHttpCanonicalization extends UnionMutation< return; } + if (!mutated.name) { + return; + } + const suffix = getVisibilitySuffix(this.options.visibility, Visibility.Read); mutated.name = `${mutated.name}${suffix}`; @@ -280,13 +350,15 @@ export class UnionHttpCanonicalization extends UnionMutation< return a.index - b.index; }); + const alreadyTested: VariantAnalysis[] = []; return orderedAnalyses.map((analysis) => { - const selected = this.#selectTestsForVariant(analysis, analyses); + const selected = this.#selectTestsForVariant(analysis, analyses, alreadyTested, target); const tests = selected .slice() .sort((a, b) => this.#testPriority(a.test) - this.#testPriority(b.test)) .map((definition) => definition.test); + alreadyTested.push(analysis); return { variant: analysis.variant, tests }; }); } @@ -351,20 +423,32 @@ export class UnionHttpCanonicalization extends UnionMutation< /** * Chooses the minimal set of tests that uniquely identify a variant. + * Takes into account which variants have already been ruled out by earlier tests in the sequence. */ #selectTestsForVariant( analysis: VariantAnalysis, analyses: VariantAnalysis[], + alreadyTestedAnalyses: VariantAnalysis[], + target: "language" | "wire", ): VariantTestDefinition[] { - const others = analyses.filter((candidate) => candidate !== analysis); + // Filter out variants that have already been tested earlier in the sequence + const others = analyses.filter( + (candidate) => candidate !== analysis && !alreadyTestedAnalyses.includes(candidate), + ); if (analysis.availableTests.length === 0) { if (others.length === 0) { return []; } + const indistinguishableVariants = others + .map((other) => this.#getVariantDebugName(other.variant)) + .join(", "); + throw new Error( - `Unable to determine distinguishing runtime checks for union variant "${this.#getVariantDebugName(analysis.variant)}".`, + `Unable to determine distinguishing runtime checks for ${target} type of union variant "${this.#getVariantDebugName(analysis.variant)}". ` + + `Indistinguishable from: [${indistinguishableVariants}]. ` + + `Variant type category: ${analysis.typeCategory ?? "unknown"}.`, ); } @@ -376,14 +460,36 @@ export class UnionHttpCanonicalization extends UnionMutation< ); if (remaining.length === 0) { + // If there are no remaining variants to distinguish from, but we have tests available, + // prefer literal tests over type tests for better specificity + if (required.length === 0 && optional.length > 0) { + // Prefer a literal test if available, otherwise fall back to type test + const literalTest = optional.find((test) => test.test.kind === "literal"); + if (literalTest) { + return [literalTest]; + } + const typeTest = optional.find((test) => test.test.kind === "type"); + return typeTest ? [typeTest] : [optional[0]]; + } return required; } const optionalSubset = this.#findCoveringSubset(optional, remaining); if (!optionalSubset) { + const indistinguishableVariants = remaining + .map((other) => this.#getVariantDebugName(other.variant)) + .join(", "); + + const availableTestsDescription = analysis.availableTests + .map((test) => `${test.test.kind}(${test.test.path.join(".")})`) + .join(", "); + throw new Error( - `Unable to distinguish union variant "${this.#getVariantDebugName(analysis.variant)}" with available runtime checks.`, + `Unable to distinguish ${target} type of union variant "${this.#getVariantDebugName(analysis.variant)}" with available runtime checks. ` + + `Indistinguishable from: [${indistinguishableVariants}]. ` + + `Variant type category: ${analysis.typeCategory ?? "unknown"}. ` + + `Available tests: [${availableTestsDescription}].`, ); } @@ -626,22 +732,38 @@ export class UnionHttpCanonicalization extends UnionMutation< /** * Provides a deterministic priority used to order variants. + * Variants with const tests come first (more specific), followed by those with only type tests (more generic). */ #variantPriority(analysis: VariantAnalysis) { + // Prioritize variants with const tests (literals) - they're more specific + const hasConstTests = analysis.constTests.length > 0; + const constTestPriority = hasConstTests ? 0 : 100; + + // Then sort by type category + let typeCategoryPriority: number; switch (analysis.typeCategory) { case "number": - return 0; + typeCategoryPriority = 0; + break; case "boolean": - return 1; + typeCategoryPriority = 1; + break; case "string": - return 2; + typeCategoryPriority = 2; + break; case "array": - return 3; + typeCategoryPriority = 3; + break; case "object": - return 4; + typeCategoryPriority = 4; + break; default: - return 5; + typeCategoryPriority = 5; + break; } + + // Combine priorities: const tests first, then type category + return constTestPriority + typeCategoryPriority; } /** diff --git a/packages/mutator-framework/readme.md b/packages/mutator-framework/readme.md index d550760f41c..6625f1169e0 100644 --- a/packages/mutator-framework/readme.md +++ b/packages/mutator-framework/readme.md @@ -1,6 +1,6 @@ # Mutator Framework -** WARNING: THIS PACKAGE IS EXPERIMENTAL AND WILL CHANGE ** +**WARNING: THIS PACKAGE IS EXPERIMENTAL AND WILL CHANGE** This package provides utilities for building mutations of the TypeSpec type graph. Mutations are modifications to the original type graph that live in a @@ -17,323 +17,290 @@ At a high level you: - Instantiate a `MutationEngine` subtype (e.g. `SimpleMutationEngine`) with the `Typekit` from the TypeSpec program you want to mutate. -The key APIs are: +## Key Concepts -- `MutationEngine` – orchestrates creation, caching, and traversal of mutation nodes. -- `SimpleMutationEngine` – a convenience engine that exposes a single default mutation subgraph. -- `MutationOptions` – lets you parameterize a mutation run and cache its results. -- `ModelMutation`, `ModelPropertyMutation`, `UnionMutation`, `UnionVariantMutation`, - `OperationMutation`, etc. – base classes for crafting custom mutations per TypeSpec kind. -- `MutationSubgraph` – creates an isolated graph of mutated types that can be inspected or - retrieved later. -- `ModelMutationNode`, `ModelPropertyMutationNode`, `UnionMutationNode`, etc. - - nodes which represent the possible mutation of a particular type graph type. +### Mutations -## Getting Started +Mutations are abstract classes that control how each TypeSpec type kind is +traversed and transformed. They have a protocol based on a static `mutationInfo` +method that returns a unique `mutationKey` for that mutation. If a mutation with +the same key exists, it is reused; otherwise, a new mutation is constructed. -```ts -import type { Model, Program } from "@typespec/compiler"; -import { $, type Typekit } from "@typespec/compiler/typekit"; -import { SimpleMutationEngine } from "@typespec/mutator-framework"; +The `mutationInfo` method can also return a `Mutation` directly, useful for +constructing a mutation view "as if" the type graph looked different. -// Create a typekit for the program -const tk: Typekit = $(program); +### Mutation Nodes -// Instantiate an engine for running the mutations. -// Might be the built-in SimpleMutationEngine, or a -// custom `MutationEngine` subclass. -const engine = new SimpleMutationEngine(tk, { - Model: RenameModelMutation, // defined later in this guide -}); -const renamedMutation = engine.mutate(someType); -const mutatedType = renamedMutation.mutatedType; -``` +Mutation nodes represent individual type transformations. They are unique per +`(type, mutationKey)` pair. Connections between nodes are built lazily using +half-edges - mutations call `startXEdge()` methods to create a `MutationHalfEdge` +that gets connected when the target mutation is resolved. -## Defining Custom Mutation Options +### Key APIs + +- `MutationEngine` – orchestrates creation, caching, and traversal of mutations. +- `SimpleMutationEngine` – a convenience engine with Simple\* mutation classes + that expose a single mutated type per source type. +- `MutationOptions` – lets you parameterize a mutation run and provide a `mutationKey`. +- `MutationHalfEdge` – represents the head-end of a connection; the tail is set when + the target mutation is resolved. +- `ModelMutation`, `ModelPropertyMutation`, `UnionMutation`, etc. – abstract base + classes for crafting custom mutations per TypeSpec kind. +- `SimpleModelMutation`, `SimpleModelPropertyMutation`, etc. – concrete implementations + that expose `mutationNode` and `mutatedType` properties. -Options derive from `MutationOptions`. They let you tune mutations (for example, -to switch on features or change naming rules) and provide a cache key used to -memoize results. Extend the class and override `cacheKey()` to represent your -configuration. +## Getting Started ```ts -// rename-mutations.ts -import { MutationOptions } from "@typespec/mutator-framework"; +import type { Model, Program } from "@typespec/compiler"; +import { $ } from "@typespec/compiler/typekit"; +import { + SimpleModelMutation, + SimpleMutationEngine, + SimpleMutationOptions, +} from "@typespec/mutator-framework"; -export class RenameMutationOptions extends MutationOptions { - constructor( - readonly prefix: string, - readonly suffix: string, - ) { +// Define custom options with a mutationKey +class RenameMutationOptions extends SimpleMutationOptions { + constructor(readonly suffix: string) { super(); } - override cacheKey() { - return `${this.prefix}-${this.suffix}`; + get mutationKey() { + return this.suffix; } } -``` -## Creating a Custom Mutation Engine +// Define a custom mutation that renames models +class RenameModelMutation extends SimpleModelMutation { + mutate() { + if ("name" in this.sourceType && typeof this.sourceType.name === "string") { + this.mutationNode.mutate( + (type) => (type.name = `${this.sourceType.name}${this.options.suffix}`), + ); + } + super.mutate(); + } +} -`MutationEngine` is responsible for coordinating mutation nodes and subgraphs. Supply constructors -for each type kind you want to customize. Anything you omit defaults to the base implementations -(`ModelMutation`, `ModelPropertyMutation`, etc.). +// Create the engine and run mutations +const tk = $(program); +const engine = new SimpleMutationEngine(tk, { + Model: RenameModelMutation, +}); -You can also register additional mutation subgraphs. Each subgraph represents an isolated view of -the mutated graph. This is useful when you want to compare alternative transformations side by side -(for example, with different naming conventions). +const options = new RenameMutationOptions("Suf"); +const fooMutation = engine.mutate(fooModel, options); +console.log(fooMutation.mutatedType.name); // "FooSuf" +``` + +## Defining Custom Mutation Options + +Options derive from `MutationOptions` (or `SimpleMutationOptions` for simple +mutations). Override the `mutationKey` getter to provide a unique cache key for +your configuration. Mutations are cached per `(type, mutationKey)` pair. ```ts -// rename-mutations.ts -import { MutationEngine, type MutationSubgraph } from "@typespec/mutator-framework"; -import type { Typekit } from "@typespec/compiler/typekit"; - -export class RenameMutationEngine extends MutationEngine<{ Model: RenameModelMutation }> { - constructor(typekit: Typekit) { - super(typekit, { Model: RenameModelMutation }); - this.registerSubgraph("prefix"); - this.registerSubgraph("suffix"); +import { SimpleMutationOptions } from "@typespec/mutator-framework"; + +class RenameMutationOptions extends SimpleMutationOptions { + constructor(readonly suffix: string) { + super(); } - getPrefix(options: RenameMutationOptions): MutationSubgraph { - return this.getMutationSubgraph(options, "prefix"); + get mutationKey() { + return this.suffix; } - getSuffix(options: RenameMutationOptions): MutationSubgraph { - return this.getMutationSubgraph(options, "suffix"); + with(options: Partial<{ suffix: string }>) { + return new RenameMutationOptions(options.suffix ?? this.suffix); } } ``` -The base `MutationEngine` does not define a default subgraph. If you just need a single mutated -view, use the `SimpleMutationEngine`. It auto-registers a `"subgraph"` and wires -`getDefaultMutationSubgraph` for you: - -```ts -import { SimpleMutationEngine } from "@typespec/mutator-framework"; - -const engine = new SimpleMutationEngine(tk, { - Model: RenameModelMutation, -}); -``` - ## Writing Mutation Classes -Mutation classes derive from the base classes included in the framework. Each class receives the -engine, the source TypeSpec type, the list of reference members that referenced that type (if any), -and the options. Override `mutate()` to perform your transformations. - -Inside `mutate()` you can: +Mutation classes are abstract classes that derive from the base classes included +in the framework. Override `mutate()` to perform your transformations. -- Traverse connected types via `this.engine.mutate(...)` or `this.engine.mutateReference(...)`. -- Retrieve or create mutation nodes with `this.getMutationNode()`. -- Mutate values using `this.mutateType()` or `engine.mutateType(...)`. -- Switch subgraphs by calling `this.engine.getMutationSubgraph(...)`. +### The `mutationInfo` Protocol -### Example: Renaming Models in Multiple Subgraphs +Each mutation class has a static `mutationInfo` method that is called to +determine the unique key for a mutation. The default implementation uses +`options.mutationKey`. Override this to implement context-sensitive mutation +keys based on reference types or other factors: ```ts -// rename-mutations.ts -import { ModelMutation } from "@typespec/mutator-framework"; - -export class RenameModelMutation extends ModelMutation< - RenameMutationOptions, - { Model: RenameModelMutation }, - RenameMutationEngine -> { - get withPrefix() { - return this.getMutatedType(this.engine.getPrefix(this.options)); - } - - get withSuffix() { - return this.getMutatedType(this.engine.getSuffix(this.options)); +class RenameModelBasedOnReferenceMutation extends SimpleModelMutation { + static mutationInfo( + engine: SimpleMutationEngine<{ Model: RenameModelBasedOnReferenceMutation }>, + sourceType: Model, + referenceTypes: MemberType[], + options: SimpleMutationOptions, + halfEdge?: MutationHalfEdge, + traits?: MutationTraits, + ): MutationInfo { + // Different key based on whether this type was reached via a reference + if (referenceTypes.length === 0) { + return { + mutationKey: options.mutationKey + "-no-ref", + hasReference: false, + isSynthetic: traits?.isSynthetic, + }; + } + return { + mutationKey: options.mutationKey + "-has-ref", + hasReference: true, + isSynthetic: traits?.isSynthetic, + }; } mutate() { - if ("name" in this.sourceType && typeof this.sourceType.name === "string") { - this.mutateType(this.engine.getPrefix(this.options), (model) => { - model.name = `${this.options.prefix}${model.name}`; - }); - - this.mutateType(this.engine.getSuffix(this.options), (model) => { - model.name = `${model.name}${this.options.suffix}`; - }); + if (this.mutationInfo.hasReference) { + this.mutationNode.mutate((type) => (type.name = `${this.sourceType.name}Reference`)); } - - // Always call super.mutate() if you still want the base implementation - // to traverse properties, base models, indexers, etc. with the same options. super.mutate(); } } ``` -### Running the Mutation - -```ts -import type { Model, Program } from "@typespec/compiler"; -import { $ } from "@typespec/compiler/typekit"; -import { RenameMutationEngine, RenameMutationOptions } from "./rename-mutations.js"; - -export function applyRename(program: Program, fooModel: Model) { - const engine = new RenameMutationEngine($(program)); - const options = new RenameMutationOptions("Pre", "Suf"); +### Lazy Connections with Half-Edges - const fooMutation = engine.mutate(fooModel, options); - const prefixFoo = fooMutation.withPrefix; - const suffixFoo = fooMutation.withSuffix; +Connections between mutations are built lazily. Mutations provide `startXEdge()` +methods that return a `MutationHalfEdge`. Pass this half-edge to `engine.mutate()` +or related methods. When the target mutation is resolved, its mutation node is +connected to the originating mutation's node: - const propMutation = fooMutation.properties.get("prop")!; - const barMutation = propMutation.type as RenameModelMutation; +```ts +class SimpleModelMutation extends ModelMutation { + // Creates a half-edge for connecting to a property mutation + startPropertyEdge() { + return new MutationHalfEdge("property", this, (tail) => + this.mutationNode.connectProperty(tail.mutationNode), + ); + } - return { - prefixFoo, - suffixFoo, - barWithSuffix: barMutation.withSuffix, - }; + mutate() { + for (const prop of this.sourceType.properties.values()) { + // Pass the half-edge so the connection is made when resolved + this.engine.mutate(prop, this.options, this.startPropertyEdge()); + } + } } ``` -### Example: Mutating Referenced Types +### Replacing Referenced Types -`ModelPropertyMutation` exposes `mutateReference` and `replaceReferencedType` -helpers that make it easy to mutate types referenced by properties. When -mutating references, a clone of the referenced type is made, so changes to not -affect the referenced type. This enables references to reference a unique type -with mutations that are particular to that type when referenced in that context. -For example, if the model property contains a decorator that affects the -mutation of a referenced scalar. +Use `engine.replaceAndMutateReference()` to substitute a type with a different +one while preserving the reference chain. This is useful for wrapping types in +unions or other containers: ```ts -import type { Model, Program } from "@typespec/compiler"; -import { $ } from "@typespec/compiler/typekit"; -import { - ModelMutation, - ModelPropertyMutation, - MutationOptions, - SimpleMutationEngine, -} from "@typespec/mutator-framework"; - -class UnionifyOptions extends MutationOptions {} - -class UnionifyModel extends ModelMutation< - UnionifyOptions, - UnionifyMutations, - SimpleMutationEngine -> { - get unionified() { - return this.getMutatedType(); - } -} - -class UnionifyProperty extends ModelPropertyMutation< - UnionifyOptions, - UnionifyMutations, - SimpleMutationEngine -> { - get unionified() { - return this.getMutatedType(); - } - +class UnionifyProperty extends SimpleModelPropertyMutation { mutate() { if (!this.engine.$.union.is(this.sourceType.type)) { - const unionVariant = this.engine.$.unionVariant.create({ type: this.sourceType.type }); - const fallbackVariant = this.engine.$.unionVariant.create({ - type: this.engine.$.builtin.string, + // Create a union wrapping the original type + const newUnionType = this.engine.$.union.create({ + name: "DynamicUnion", + variants: [ + this.engine.$.unionVariant.create({ type: this.sourceType.type }), + this.engine.$.unionVariant.create({ type: this.engine.$.builtin.string }), + ], }); - const unionType = this.engine.$.union.create({ variants: [unionVariant, fallbackVariant] }); + // Update the mutation node + this.mutationNode.mutate((prop) => { + prop.type = newUnionType; + }); - this.type = this.replaceReferencedType( - this.engine.getDefaultMutationSubgraph(this.options), - unionType, + // Replace and mutate the reference + this.type = this.engine.replaceAndMutateReference( + this.sourceType, + newUnionType, + this.options, + this.startTypeEdge(), ); } else { super.mutate(); } } } +``` -interface UnionifyMutations { - Model: UnionifyModel; - ModelProperty: UnionifyProperty; -} +### Returning Mutations from `mutationInfo` -export function createUnionifyEngine(program: Program) { - const tk = $(program); - return new SimpleMutationEngine(tk, { - Model: UnionifyModel, - ModelProperty: UnionifyProperty, - }); -} +The `mutationInfo` method can return a `Mutation` directly instead of a +`MutationInfo` object. This is useful for completely substituting the mutation +for a different one: -export function unionifyModel(program: Program, fooModel: Model) { - const engine = createUnionifyEngine(program); - const fooMutation = engine.mutate(fooModel, new UnionifyOptions()); - const propMutation = fooMutation.properties.get("prop")!; +```ts +class NullableReferencedModelMutation extends SimpleModelMutation { + static mutationInfo( + engine: SimpleMutationEngine<{ Model: NullableReferencedModelMutation }>, + sourceType: Model, + referenceTypes: MemberType[], + options: SimpleMutationOptions, + halfEdge?: MutationHalfEdge, + traits?: MutationTraits, + ) { + // When accessed via a ModelProperty reference, return a union mutation instead + if (referenceTypes.length > 0 && referenceTypes[0].kind === "ModelProperty") { + const nullableUnion = engine.$.union.create({ + name: `${sourceType.name}NullableUnion`, + variants: [ + engine.$.unionVariant.create({ name: "Value", type: sourceType }), + engine.$.unionVariant.create({ name: "Null", type: engine.$.intrinsic.null }), + ], + }); - return { - property: propMutation.unionified, - model: fooMutation.unionified, - }; + // Return a mutation for the union instead + return engine.replaceAndMutateReference(referenceTypes[0], nullableUnion, options, halfEdge); + } + + return super.mutationInfo(engine, sourceType, referenceTypes, options, halfEdge, traits); + } } ``` -## Core Mutation Base Classes +Mutation nodes also have a `replace` method. Returning a new mutation from MutationInfo makes the resulting Mutations look "as if" the source type graph were shaped differently. This is useful for doing things like normalizations of the type graph. The structure of the Mutations mimic this new structure. When you `replace` on a mutation node, the Mutation stays the same, but the mutated type graph is changed. This is useful for doing things like renaming things or swapping scalars in situations where you want to see both the source type and the mutated type in order to compare them. -| Class | Source Type | Responsibilities | -| ----------------------- | ------------------------------ | ---------------------------------------------------------- | -| `ModelMutation` | `Model` | Traverses base models, properties, and indexers. | -| `ModelPropertyMutation` | `ModelProperty` | Mutates referenced types, exposes `replaceReferencedType`. | -| `UnionMutation` | `Union` | Iterates over variants and lazy-loads their mutations. | -| `UnionVariantMutation` | `UnionVariant` | Handles referenced variant types. | -| `ScalarMutation` | `Scalar` | Provides access to scalar definitions and projections. | -| `LiteralMutation` | string/number/boolean literals | Provides literal values and traversal control. | -| `OperationMutation` | `Operation` | Mutates parameters, return types, and decorators. | -| `InterfaceMutation` | `Interface` | Walks operations declared on the interface. | -| `IntrinsicMutation` | `Intrinsic` | Surfaces intrinsic TypeSpec types. | +## Mutation Caching -Each class inherits from the foundational `Mutation` class, which provides -shared helpers for mutated types (`getMutatedType`) and nodes -(`getMutationNode`). Override them or add convenience getters/setters to tailor -the experience for your consumers. - -## Working with Mutation Subgraphs - -The engine builds mutation nodes inside a `MutationSubgraph`. Each subgraph -captures a set of mutations that share the same options and transformation -logic. +Mutations are automatically cached and reused. When you call `engine.mutate()` +with the same type and options (determined by `mutationKey`), you get back the +same mutation instance: ```ts -const prefixGraph = engine.getPrefix(renameOptions); -const prefixFoo = engine.getMutatedType(prefixGraph, Foo); - -const suffixGraph = engine.getSuffix(renameOptions); -const suffixFoo = engine.getMutatedType(suffixGraph, Foo); +const barMutation = engine.mutate(Bar, new RenameMutationOptions({ suffix: "X" })); +const fooMutation = engine.mutate(Foo, new RenameMutationOptions({ suffix: "X" })); -console.log(prefixFoo.name, suffixFoo.name); +// When traversing from Foo to its Bar property, we get the same mutation +expect(fooMutation.properties.get("prop")!.type === barMutation).toBe(true); ``` -When you call `engine.mutate(type, options)` the engine automatically creates mutation nodes in all -registered subgraphs for the provided options. Subsequent calls reuse the cached nodes, so you can -freely navigate the mutation graph without re-running your transformation logic. +### Simple Mutation Classes + +The `Simple*` mutation classes (e.g., `SimpleModelMutation`, `SimpleModelPropertyMutation`) +are concrete implementations that provide: + +- A `mutationNode` property for accessing the underlying mutation node +- A `mutatedType` property for accessing the mutated TypeSpec type +- `startXEdge()` methods for creating half-edges to connected types ## Tips for Building Mutations - **Always call `super.mutate()`** when you want the default traversal logic after your custom changes. Skipping it gives you full control, but you must handle traversal yourself. -- **Use `MutationOptions` subclasses** whenever your mutation behavior depends on input - configuration. Return a stable `cacheKey()` to reuse work. -- **Inspect `referenceTypes`** to learn which `ModelProperty` or `UnionVariant` led to the current - mutation node. This helps you emit diagnostics or perform context-sensitive logic. -- **Mutate lazily**. Mutations only run once per `(type, options)` pair. If you expose getters that - trigger work, they should go through `engine.mutate(...)` so the cache stays consistent. -- **Prefer `SimpleMutationEngine`** unless you need named subgraphs. You can graduate to a custom - engine later. +- **Use `mutationKey`** to differentiate mutations. Mutations are cached per `(type, mutationKey)` pair. +- **Inspect `referenceTypes`** in `mutationInfo` to learn which `ModelProperty` or `UnionVariant` + led to the current mutation. This enables context-sensitive mutations. +- **Use half-edges** for lazy connections. Call `startXEdge()` and pass the result to `engine.mutate()`. +- **Override `mutationInfo`** to return different mutation keys or substitute mutations entirely. +- **Use `replaceAndMutateReference`** when you need to substitute a type with a synthetic one. ## Additional Resources -- Browse the rest of the files under `packages/mutator-framework/src/mutation` to see the built-in +- Browse the files under `packages/mutator-framework/src/mutation` to see the built-in mutation implementations. -- The unit tests in `mutation-engine.test.ts` demonstrate more end-to-end usage patterns, including - multi-subgraph mutations and reference replacements. +- The unit tests in `simple-mutation-engine.test.ts` demonstrate end-to-end usage patterns, + including custom mutation keys, reference replacements, and type substitutions. diff --git a/packages/mutator-framework/src/mutation-node/enum-member.test.ts b/packages/mutator-framework/src/mutation-node/enum-member.test.ts index 1253f597db1..aebba5340f1 100644 --- a/packages/mutator-framework/src/mutation-node/enum-member.test.ts +++ b/packages/mutator-framework/src/mutation-node/enum-member.test.ts @@ -1,8 +1,8 @@ -import { t, type TesterInstance } from "@typespec/compiler/testing"; +import { expectTypeEquals, t, type TesterInstance } from "@typespec/compiler/testing"; +import { $ } from "@typespec/compiler/typekit"; import { beforeEach, expect, it } from "vitest"; import { Tester } from "../../test/test-host.js"; -import { getSubgraph } from "../../test/utils.js"; - +import { getEngine } from "../../test/utils.js"; let runner: TesterInstance; beforeEach(async () => { runner = await Tester.createInstance(); @@ -16,12 +16,43 @@ it("handles mutation of member values", async () => { } `); - const subgraph = getSubgraph(program); - const fooNode = subgraph.getNode(Foo); - const aNode = subgraph.getNode(a); + const engine = getEngine(program); + const fooNode = engine.getMutationNode(Foo); + const aNode = engine.getMutationNode(a); + fooNode.connectMember(aNode); aNode.mutate((clone) => (clone.value = "valueARenamed")); expect(aNode.isMutated).toBe(true); expect(fooNode.isMutated).toBe(true); - expect(fooNode.mutatedType.members.get("a") === aNode.mutatedType).toBe(true); + expectTypeEquals(fooNode.mutatedType.members.get("a")!, aNode.mutatedType); expect(aNode.mutatedType.value).toBe("valueARenamed"); }); + +it("is deleted when its container enum is deleted", async () => { + const { Foo, prop, program } = await runner.compile(t.code` + enum ${t.enum("Foo")} { + ${t.enumMember("prop")}; + } + `); + const engine = getEngine(program); + const propNode = engine.getMutationNode(prop); + const fooNode = engine.getMutationNode(Foo); + fooNode.connectMember(propNode); + + fooNode.delete(); + expect(propNode.isDeleted).toBe(true); +}); + +it("is deleted when its container enum is replaced", async () => { + const { Foo, prop, program } = await runner.compile(t.code` + enum ${t.enum("Foo")} { + ${t.enumMember("prop")}; + } + `); + const engine = getEngine(program); + const propNode = engine.getMutationNode(prop); + const fooNode = engine.getMutationNode(Foo); + fooNode.connectMember(propNode); + + fooNode.replace($(program).builtin.string); + expect(propNode.isDeleted).toBe(true); +}); diff --git a/packages/mutator-framework/src/mutation-node/enum-member.ts b/packages/mutator-framework/src/mutation-node/enum-member.ts index 7c1bcf73a33..102f09c078d 100644 --- a/packages/mutator-framework/src/mutation-node/enum-member.ts +++ b/packages/mutator-framework/src/mutation-node/enum-member.ts @@ -1,8 +1,27 @@ -import type { EnumMember } from "@typespec/compiler"; +import type { Enum, EnumMember } from "@typespec/compiler"; +import type { EnumMutationNode } from "./enum.js"; +import { HalfEdge } from "./mutation-edge.js"; import { MutationNode } from "./mutation-node.js"; export class EnumMemberMutationNode extends MutationNode { readonly kind = "EnumMember"; - traverse() {} + startEnumEdge() { + return new HalfEdge(this, { + onTailMutation: (tail) => { + this.mutate(); + this.mutatedType.enum = tail.mutatedType; + }, + onTailDeletion: () => { + this.delete(); + }, + onTailReplaced: (_oldTail, _newTail, head, _reconnect) => { + head.delete(); + }, + }); + } + + connectEnum(enumNode: EnumMutationNode) { + this.startEnumEdge().setTail(enumNode); + } } diff --git a/packages/mutator-framework/src/mutation-node/enum.test.ts b/packages/mutator-framework/src/mutation-node/enum.test.ts index c89a249d2cf..6fd07e6252c 100644 --- a/packages/mutator-framework/src/mutation-node/enum.test.ts +++ b/packages/mutator-framework/src/mutation-node/enum.test.ts @@ -1,8 +1,7 @@ import { t, type TesterInstance } from "@typespec/compiler/testing"; import { beforeEach, expect, it } from "vitest"; import { Tester } from "../../test/test-host.js"; -import { getSubgraph } from "../../test/utils.js"; - +import { getEngine } from "../../test/utils.js"; let runner: TesterInstance; beforeEach(async () => { runner = await Tester.createInstance(); @@ -16,26 +15,10 @@ it("handles mutation of members", async () => { } `); - const subgraph = getSubgraph(program); - const fooNode = subgraph.getNode(Foo); - const aNode = subgraph.getNode(a); - aNode.mutate(); - expect(aNode.isMutated).toBe(true); - expect(fooNode.isMutated).toBe(true); - expect(fooNode.mutatedType.members.get("a") === aNode.mutatedType).toBe(true); -}); - -it("handles mutation of members with name change", async () => { - const { program, Foo, a } = await runner.compile(t.code` - enum ${t.enum("Foo")} { - ${t.enumMember("a")}; - b; - } - `); - - const subgraph = getSubgraph(program); - const fooNode = subgraph.getNode(Foo); - const aNode = subgraph.getNode(a); + const engine = getEngine(program); + const fooNode = engine.getMutationNode(Foo); + const aNode = engine.getMutationNode(a); + fooNode.connectMember(aNode); aNode.mutate((clone) => (clone.name = "aRenamed")); expect(aNode.isMutated).toBe(true); expect(fooNode.isMutated).toBe(true); @@ -50,10 +33,10 @@ it("handles deletion of members", async () => { b; } `); - - const subgraph = getSubgraph(program); - const fooNode = subgraph.getNode(Foo); - const aNode = subgraph.getNode(a); + const engine = getEngine(program); + const fooNode = engine.getMutationNode(Foo); + const aNode = engine.getMutationNode(a); + fooNode.connectMember(aNode); aNode.delete(); expect(aNode.isDeleted).toBe(true); expect(fooNode.isMutated).toBe(true); diff --git a/packages/mutator-framework/src/mutation-node/enum.ts b/packages/mutator-framework/src/mutation-node/enum.ts index 8a0ee5cfa93..660abcdf4f5 100644 --- a/packages/mutator-framework/src/mutation-node/enum.ts +++ b/packages/mutator-framework/src/mutation-node/enum.ts @@ -1,33 +1,39 @@ import type { Enum, EnumMember } from "@typespec/compiler"; -import { MutationEdge } from "./mutation-edge.js"; +import { HalfEdge } from "./mutation-edge.js"; import { MutationNode } from "./mutation-node.js"; export class EnumMutationNode extends MutationNode { readonly kind = "Enum"; - traverse() { - for (const member of this.sourceType.members.values()) { - const memberNode = this.subgraph.getNode(member); - this.connectMember(memberNode, member.name); - } - } - - connectMember(memberNode: MutationNode, sourcePropName: string) { - MutationEdge.create(this, memberNode, { - onTailMutation: () => { - this.mutatedType.members.delete(sourcePropName); - this.mutatedType.members.set(memberNode.mutatedType.name, memberNode.mutatedType); + startMemberEdge() { + return new HalfEdge(this, { + onTailCreation: (tail) => { + tail.connectEnum(this); + }, + onTailMutation: (tail) => { + this.mutate(); + this.mutatedType.members.delete(tail.sourceType.name); + this.mutatedType.members.set(tail.mutatedType.name, tail.mutatedType); }, - onTailDeletion: () => { - this.mutatedType.members.delete(sourcePropName); + onTailDeletion: (tail) => { + this.mutate(); + this.mutatedType.members.delete(tail.sourceType.name); }, - onTailReplaced: (newTail) => { + onTailReplaced: (oldTail, newTail, head, reconnect) => { if (newTail.mutatedType.kind !== "EnumMember") { throw new Error("Cannot replace enum member with non-enum member type"); } - this.mutatedType.members.delete(sourcePropName); - this.mutatedType.members.set(newTail.mutatedType.name, newTail.mutatedType); + head.mutate(); + head.mutatedType.members.delete(oldTail.sourceType.name); + head.mutatedType.members.set(newTail.mutatedType.name, newTail.mutatedType); + if (reconnect) { + head.connectMember(newTail as MutationNode); + } }, }); } + + connectMember(memberNode: MutationNode) { + this.startMemberEdge().setTail(memberNode); + } } diff --git a/packages/mutator-framework/src/mutation-node/factory.ts b/packages/mutator-framework/src/mutation-node/factory.ts index e8930083d8a..56f39d7d609 100644 --- a/packages/mutator-framework/src/mutation-node/factory.ts +++ b/packages/mutator-framework/src/mutation-node/factory.ts @@ -15,6 +15,7 @@ import type { Union, UnionVariant, } from "@typespec/compiler"; +import type { MutationEngine } from "../mutation/mutation-engine.js"; import { EnumMemberMutationNode } from "./enum-member.js"; import { EnumMutationNode } from "./enum.js"; import { InterfaceMutationNode } from "./interface.js"; @@ -22,7 +23,7 @@ import { IntrinsicMutationNode } from "./intrinsic.js"; import { LiteralMutationNode } from "./literal.js"; import { ModelPropertyMutationNode } from "./model-property.js"; import { ModelMutationNode } from "./model.js"; -import type { MutationSubgraph } from "./mutation-subgraph.js"; +import type { MutationNodeOptions } from "./mutation-node.js"; import { OperationMutationNode } from "./operation.js"; import { ScalarMutationNode } from "./scalar.js"; import { TupleMutationNode } from "./tuple.js"; @@ -30,39 +31,41 @@ import { UnionVariantMutationNode } from "./union-variant.js"; import { UnionMutationNode } from "./union.js"; export function mutationNodeFor( - subgraph: MutationSubgraph, + engine: MutationEngine, sourceType: T, + options?: MutationNodeOptions | string, ): MutationNodeForType { switch (sourceType.kind) { case "Operation": - return new OperationMutationNode(subgraph, sourceType) as MutationNodeForType; + return new OperationMutationNode(engine, sourceType, options) as MutationNodeForType; case "Interface": - return new InterfaceMutationNode(subgraph, sourceType) as MutationNodeForType; + return new InterfaceMutationNode(engine, sourceType, options) as MutationNodeForType; case "Model": - return new ModelMutationNode(subgraph, sourceType) as MutationNodeForType; + return new ModelMutationNode(engine, sourceType, options) as MutationNodeForType; case "ModelProperty": - return new ModelPropertyMutationNode(subgraph, sourceType) as MutationNodeForType; + return new ModelPropertyMutationNode(engine, sourceType, options) as MutationNodeForType; case "Scalar": - return new ScalarMutationNode(subgraph, sourceType) as MutationNodeForType; + return new ScalarMutationNode(engine, sourceType, options) as MutationNodeForType; case "Tuple": - return new TupleMutationNode(subgraph, sourceType) as MutationNodeForType; + return new TupleMutationNode(engine, sourceType, options) as MutationNodeForType; case "Union": - return new UnionMutationNode(subgraph, sourceType) as MutationNodeForType; + return new UnionMutationNode(engine, sourceType, options) as MutationNodeForType; case "UnionVariant": - return new UnionVariantMutationNode(subgraph, sourceType) as MutationNodeForType; + return new UnionVariantMutationNode(engine, sourceType, options) as MutationNodeForType; case "Enum": - return new EnumMutationNode(subgraph, sourceType) as MutationNodeForType; + return new EnumMutationNode(engine, sourceType, options) as MutationNodeForType; case "EnumMember": - return new EnumMemberMutationNode(subgraph, sourceType) as MutationNodeForType; + return new EnumMemberMutationNode(engine, sourceType, options) as MutationNodeForType; case "String": case "Number": case "Boolean": return new LiteralMutationNode( - subgraph, + engine, sourceType as StringLiteral | NumericLiteral | BooleanLiteral, + options, ) as MutationNodeForType; case "Intrinsic": - return new IntrinsicMutationNode(subgraph, sourceType) as MutationNodeForType; + return new IntrinsicMutationNode(engine, sourceType, options) as MutationNodeForType; default: throw new Error("Unsupported type kind: " + sourceType.kind); } diff --git a/packages/mutator-framework/src/mutation-node/index.ts b/packages/mutator-framework/src/mutation-node/index.ts index ec37930f1bf..5934a41831d 100644 --- a/packages/mutator-framework/src/mutation-node/index.ts +++ b/packages/mutator-framework/src/mutation-node/index.ts @@ -2,13 +2,13 @@ export * from "./mutation-node.js"; export * from "./enum-member.js"; export * from "./enum.js"; +export * from "./factory.js"; export * from "./interface.js"; export * from "./intrinsic.js"; export * from "./literal.js"; export * from "./model-property.js"; export * from "./model.js"; export * from "./mutation-edge.js"; -export * from "./mutation-subgraph.js"; export * from "./operation.js"; export * from "./scalar.js"; export * from "./tuple.js"; diff --git a/packages/mutator-framework/src/mutation-node/interface.ts b/packages/mutator-framework/src/mutation-node/interface.ts index 9429a377e69..9932ba8beb5 100644 --- a/packages/mutator-framework/src/mutation-node/interface.ts +++ b/packages/mutator-framework/src/mutation-node/interface.ts @@ -1,33 +1,41 @@ import type { Interface, Operation } from "@typespec/compiler"; -import { MutationEdge } from "./mutation-edge.js"; +import { HalfEdge } from "./mutation-edge.js"; import { MutationNode } from "./mutation-node.js"; +export interface InterfaceConnectOptions { + /** Mutation keys for operation nodes, keyed by operation name. Defaults to this node's key for all. */ + operations?: Record; +} + export class InterfaceMutationNode extends MutationNode { readonly kind = "Interface"; - traverse() { - for (const [opName, op] of this.sourceType.operations) { - const opNode = this.subgraph.getNode(op); - this.connectOperation(opNode, opName); - } - } - - connectOperation(opNode: MutationNode, opName: string) { - MutationEdge.create(this, opNode, { - onTailMutation: () => { - this.mutatedType.operations.delete(opName); - this.mutatedType.operations.set(opNode.mutatedType.name, opNode.mutatedType); + startOperationEdge() { + return new HalfEdge(this, { + onTailCreation: (tail) => { + tail.connectInterface(this); }, - onTailDeletion: () => { - this.mutatedType.operations.delete(opName); + onTailMutation: (tail) => { + this.mutatedType.operations.delete(tail.sourceType.name); + this.mutatedType.operations.set(tail.mutatedType.name, tail.mutatedType); }, - onTailReplaced: (newTail) => { + onTailDeletion: (tail) => { + this.mutatedType.operations.delete(tail.sourceType.name); + }, + onTailReplaced: (oldTail, newTail, head, reconnect) => { if (newTail.mutatedType.kind !== "Operation") { throw new Error("Cannot replace operation with non-operation type"); } - this.mutatedType.operations.delete(opName); - this.mutatedType.operations.set(newTail.mutatedType.name, newTail.mutatedType); + head.mutatedType.operations.delete(oldTail.sourceType.name); + head.mutatedType.operations.set(newTail.mutatedType.name, newTail.mutatedType); + if (reconnect) { + head.connectOperation(newTail as MutationNode); + } }, }); } + + connectOperation(opNode: MutationNode) { + this.startOperationEdge().setTail(opNode); + } } diff --git a/packages/mutator-framework/src/mutation-node/intrinsic.ts b/packages/mutator-framework/src/mutation-node/intrinsic.ts index aca76b7e2c5..18a000ae899 100644 --- a/packages/mutator-framework/src/mutation-node/intrinsic.ts +++ b/packages/mutator-framework/src/mutation-node/intrinsic.ts @@ -4,5 +4,8 @@ import { MutationNode } from "./mutation-node.js"; export class IntrinsicMutationNode extends MutationNode { readonly kind = "Intrinsic"; - traverse() {} + connect() { + if (this.connected) return; + this.connected = true; + } } diff --git a/packages/mutator-framework/src/mutation-node/literal.ts b/packages/mutator-framework/src/mutation-node/literal.ts index 0571cefd02d..a9fced7ffeb 100644 --- a/packages/mutator-framework/src/mutation-node/literal.ts +++ b/packages/mutator-framework/src/mutation-node/literal.ts @@ -6,5 +6,8 @@ export class LiteralMutationNode extends MutationNode< > { readonly kind = "Literal"; - traverse() {} + connect() { + if (this.connected) return; + this.connected = true; + } } diff --git a/packages/mutator-framework/src/mutation-node/model-property.test.ts b/packages/mutator-framework/src/mutation-node/model-property.test.ts index b20b4e805a3..d014ab53e84 100644 --- a/packages/mutator-framework/src/mutation-node/model-property.test.ts +++ b/packages/mutator-framework/src/mutation-node/model-property.test.ts @@ -1,8 +1,9 @@ -import { t, type TesterInstance } from "@typespec/compiler/testing"; +import type { Model } from "@typespec/compiler"; +import { expectTypeEquals, t, type TesterInstance } from "@typespec/compiler/testing"; import { $ } from "@typespec/compiler/typekit"; import { beforeEach, expect, it } from "vitest"; import { Tester } from "../../test/test-host.js"; -import { getSubgraph } from "../../test/utils.js"; +import { getEngine } from "../../test/utils.js"; let runner: TesterInstance; beforeEach(async () => { @@ -15,122 +16,199 @@ it("handles mutation of property types", async () => { ${t.modelProperty("prop")}: string; } `); - const subgraph = getSubgraph(program); - const propNode = subgraph.getNode(prop); - const stringNode = subgraph.getNode($(program).builtin.string); + const engine = getEngine(program); + const propNode = engine.getMutationNode(prop); + const stringNode = engine.getMutationNode($(program).builtin.string); + propNode.connectType(stringNode); stringNode.mutate(); expect(propNode.isMutated).toBe(true); - expect(propNode.mutatedType.type === stringNode.mutatedType).toBe(true); + expectTypeEquals(propNode.mutatedType.type, stringNode.mutatedType); }); -it("handles mutating a reference", async () => { - const { Foo, Bar, prop, program } = await runner.compile(t.code` +it("updates its model property to the mutated model", async () => { + const { Foo, prop, program } = await runner.compile(t.code` + model ${t.model("Foo")} { + ${t.modelProperty("prop")}: string; + } + `); + const engine = getEngine(program); + const propNode = engine.getMutationNode(prop); + const stringNode = engine.getMutationNode($(program).builtin.string); + const fooNode = engine.getMutationNode(Foo); + fooNode.connectProperty(propNode); + propNode.connectType(stringNode); + stringNode.mutate(); + expectTypeEquals(fooNode.mutatedType, propNode.mutatedType.model!); +}); + +it("is deleted when its container model is deleted", async () => { + const { Foo, prop, program } = await runner.compile(t.code` model ${t.model("Foo")} { - ${t.modelProperty("prop")}: Bar; - }; - model ${t.model("Bar")} {} + ${t.modelProperty("prop")}: string; + } `); + const engine = getEngine(program); + const propNode = engine.getMutationNode(prop); + const fooNode = engine.getMutationNode(Foo); + fooNode.connectProperty(propNode); + const stringNode = engine.getMutationNode($(program).builtin.string); + propNode.connectType(stringNode); + fooNode.delete(); + expect(propNode.isDeleted).toBe(true); +}); - const subgraph = getSubgraph(program); - const fooNode = subgraph.getNode(Foo); - const propNode = subgraph.getNode(prop); - const barPrime = subgraph.getReferenceNode(prop); +it("is deleted when its container model is replaced", async () => { + const { Foo, prop, program } = await runner.compile(t.code` + model ${t.model("Foo")} { + ${t.modelProperty("prop")}: string; + } + `); + const engine = getEngine(program); + const propNode = engine.getMutationNode(prop); + const fooNode = engine.getMutationNode(Foo); + fooNode.connectProperty(propNode); + const stringNode = engine.getMutationNode($(program).builtin.string); + propNode.connectType(stringNode); + fooNode.replace($(program).builtin.string); + expect(propNode.isDeleted).toBe(true); +}); - // initially the source type is just Bar. - expect(barPrime.sourceType === Bar).toBe(true); +it("can connect to a different mutation key for the type", async () => { + const { Bar, prop, program } = await runner.compile(t.code` + model Foo { + ${t.modelProperty("prop")}: Bar; + } + model ${t.model("Bar")} {} + `); + const engine = getEngine(program); + const propNode = engine.getMutationNode(prop); + const barNode = engine.getMutationNode(Bar); + + barNode.mutate(); + expect(propNode.isMutated).toBe(false); - barPrime.mutate(); - expect(fooNode.isMutated).toBe(true); + const barNodeCustom = engine.getMutationNode(Bar); + propNode.connectType(barNodeCustom); + barNodeCustom.mutate(); expect(propNode.isMutated).toBe(true); - expect(barPrime.isMutated).toBe(true); - expect(fooNode.mutatedType.properties.get("prop")!.type === barPrime.mutatedType).toBeTruthy(); + expectTypeEquals(propNode.mutatedType.type, barNodeCustom.mutatedType); +}); - const barNode = subgraph.getNode(Bar); +it("can connect to an already-mutated node", async () => { + const { Bar, prop, program } = await runner.compile(t.code` + model Foo { + ${t.modelProperty("prop")}: Bar; + } + + model ${t.model("Bar")} {} + `); + const engine = getEngine(program); + const barNode = engine.getMutationNode(Bar); barNode.mutate(); - expect(barNode.isMutated).toBe(true); - expect(barPrime.isMutated).toBe(true); - // the mutated type doesn't change here. - expect(fooNode.mutatedType.properties.get("prop")!.type === barPrime.mutatedType).toBeTruthy(); + + const propNode = engine.getMutationNode(prop); + propNode.connectType(barNode); + expect(propNode.isMutated).toBe(true); + expectTypeEquals(propNode.mutatedType.type, barNode.mutatedType); }); -it("handles replacing the model reference", async () => { - const { Foo, Bar, prop, program } = await runner.compile(t.code` - model ${t.model("Foo")} { - ${t.modelProperty("prop")}: Bar; - }; - model ${t.model("Bar")} {} - `); - const tk = $(program); - const subgraph = getSubgraph(program); - const fooNode = subgraph.getNode(Foo); - const propNode = subgraph.getNode(prop); - const barPrime = subgraph.getReferenceNode(prop); - const unionType = tk.union.create({ - variants: [ - tk.unionVariant.create({ type: tk.builtin.string }), - tk.unionVariant.create({ type: Bar }), - ], +it("can connect to an already-replaced node", async () => { + const { Bar, prop, program } = await runner.compile(t.code` + model Foo { + ${t.modelProperty("prop")}: Bar; + } + + model ${t.model("Bar")} {} + `); + const engine = getEngine(program); + const barNode = engine.getMutationNode(Bar); + barNode.replace($(program).builtin.int16); + + const propNode = engine.getMutationNode(prop); + propNode.connectType(barNode); + expect(propNode.isMutated).toBe(true); + expectTypeEquals(propNode.mutatedType.type, $(program).builtin.int16); +}); + +it("handles replacing multiple properties", async () => { + const { Foo, one, two, program } = await runner.compile(t.code` + model ${t.model("Foo")} { + ${t.modelProperty("one")}: string; + ${t.modelProperty("two")}: string; + } + `); + + const engine = getEngine(program); + const fooNode = engine.getMutationNode(Foo); + const oneNode = engine.getMutationNode(one); + const twoNode = engine.getMutationNode(two); + fooNode.connectProperty(oneNode); + fooNode.connectProperty(twoNode); + + const r1 = $(program).modelProperty.create({ + name: "replacement", + type: $(program).builtin.string, + }); + const r2 = $(program).modelProperty.create({ + name: "replacement2", + type: $(program).builtin.string, }); + oneNode.replace(r1); + twoNode.replace(r2); - const replacedBarPrime = barPrime.replace(unionType); + expect(fooNode.mutatedType.properties.size).toBe(2); + expect(fooNode.mutatedType.properties.get("replacement")).toBeDefined(); + expect(fooNode.mutatedType.properties.get("replacement2")).toBeDefined(); +}); - // the subgraph now returns the new reference node - expect(subgraph.getReferenceNode(prop) === replacedBarPrime).toBe(true); +it("handles replacing properties and mutating their types", async () => { + const { Foo, Bar, one, program } = await runner.compile(t.code` + model ${t.model("Foo")} { + ${t.modelProperty("one")}: Bar; + } + + model ${t.model("Bar")} {} + `); + + const engine = getEngine(program); + const fooNode = engine.getMutationNode(Foo); + const oneNode = engine.getMutationNode(one); + const barNode = engine.getMutationNode(Bar); + fooNode.connectProperty(oneNode); + oneNode.connectType(barNode); + + const r1 = $(program).modelProperty.create({ + name: "replacement", + type: Bar, + }); + oneNode.replace(r1); - // foo and prop are marked mutated, barPrime is replaced - expect(fooNode.isMutated).toBe(true); - expect(propNode.isMutated).toBe(true); - expect(barPrime.isReplaced).toBe(true); + barNode.mutate(); + barNode.mutatedType.name = "Rebar"; - // prop's type is the replaced type - expect(tk.union.is(propNode.mutatedType.type)).toBe(true); - expect( - fooNode.mutatedType!.properties.get("prop")!.type === replacedBarPrime.mutatedType, - ).toBeTruthy(); + expect(fooNode.mutatedType.properties.size).toBe(1); + expect(fooNode.mutatedType.properties.get("replacement")).toBeDefined(); + expect((fooNode.mutatedType.properties.get("replacement")!.type as Model).name).toBe("Rebar"); }); -it("handles mutating a reference to a reference", async () => { - const { myString, Foo, fprop, Bar, program } = await runner.compile(t.code` - scalar ${t.scalar("myString")} extends string; - model ${t.model("Foo")} { - ${t.modelProperty("fprop")}: myString; - }; - model ${t.model("Bar")} { - bprop: Foo.fprop; - } - `); - - const subgraph = getSubgraph(program); - const fooNode = subgraph.getNode(Foo); - const barNode = subgraph.getNode(Bar); - const myStringNode = subgraph.getNode(myString); - - myStringNode.mutate(); - expect(myStringNode.isMutated).toBe(true); - expect(fooNode.isMutated).toBe(true); - expect(barNode.isMutated).toBe(true); - - // Foo.prop's type is the mutated myString - expect( - fooNode.mutatedType.properties.get("fprop")!.type === myStringNode.mutatedType, - ).toBeTruthy(); - - // Bar.prop's type is the mutated Foo.prop - expect( - barNode.mutatedType.properties.get("bprop")!.type === - fooNode.mutatedType.properties.get("fprop")!, - ).toBeTruthy(); - - const fpropRefNode = subgraph.getReferenceNode(fprop); - fpropRefNode.mutate(); - expect(fpropRefNode.isMutated).toBe(true); - expect( - fooNode.mutatedType.properties.get("fprop")!.type === fpropRefNode.mutatedType, - ).toBeTruthy(); - - // Bar.bprop references the mutated type (though is the same reference since fprop was already mutated) - expect( - barNode.mutatedType.properties.get("bprop")!.type === - fooNode.mutatedType.properties.get("fprop")!, - ).toBeTruthy(); +it("handles replacing properties that have already been mutated", async () => { + const { Foo, one, program } = await runner.compile(t.code` + model ${t.model("Foo")} { + ${t.modelProperty("one")}: string; + } + `); + + const engine = getEngine(program); + const fooNode = engine.getMutationNode(Foo); + const oneNode = engine.getMutationNode(one); + const stringNode = engine.getMutationNode($(program).builtin.string); + oneNode.connectType(stringNode); + const r1 = $(program).modelProperty.create({ + name: "replacement", + type: $(program).builtin.string, + }); + const newNode = oneNode.replace(r1); + fooNode.connectProperty(newNode as any); + expect(fooNode.mutatedType.properties.size).toBe(1); + expect(fooNode.mutatedType.properties.get("replacement")).toBeDefined(); }); diff --git a/packages/mutator-framework/src/mutation-node/model-property.ts b/packages/mutator-framework/src/mutation-node/model-property.ts index c22f4b64400..b339d8a14a3 100644 --- a/packages/mutator-framework/src/mutation-node/model-property.ts +++ b/packages/mutator-framework/src/mutation-node/model-property.ts @@ -1,53 +1,66 @@ -import type { ModelProperty, Type } from "@typespec/compiler"; -import { MutationEdge } from "./mutation-edge.js"; +import type { Model, ModelProperty, Type } from "@typespec/compiler"; +import { ModelPropertyMutationNode as SelfType } from "./model-property.js"; +import { HalfEdge } from "./mutation-edge.js"; import { MutationNode } from "./mutation-node.js"; +import { traceNode } from "./tracer.js"; + +export interface ModelPropertyConnectOptions { + /** Mutation key for the property's type node. Defaults to this node's key. */ + typeMutationKey?: string; +} export class ModelPropertyMutationNode extends MutationNode { readonly kind = "ModelProperty"; - #referenceMutated = false; - - traverse() { - const typeNode = this.subgraph.getNode(this.sourceType.type); - const referenceNode = this.subgraph.getReferenceNode(this.sourceType); - - this.connectType(typeNode); - this.connectReference(referenceNode); - } - connectReference(referenceNode: MutationNode) { - MutationEdge.create(this, referenceNode, { - onTailMutation: () => { - this.#referenceMutated = true; - this.mutatedType.type = referenceNode.mutatedType; + startTypeEdge() { + return new HalfEdge(this, { + onTailMutation: (tail) => { + traceNode(this, "Model property type mutated."); + this.mutate(); + this.mutatedType.type = tail.mutatedType; }, onTailDeletion: () => { - this.#referenceMutated = true; + this.mutate(); this.mutatedType.type = this.$.intrinsic.any; }, - onTailReplaced: (newTail) => { - this.#referenceMutated = true; - this.mutatedType.type = newTail.mutatedType; + onTailReplaced: (_oldTail, newTail, head, reconnect) => { + head.mutate(); + head.mutatedType.type = newTail.mutatedType; + if (reconnect) { + (head as ModelPropertyMutationNode).connectType(newTail); + } + }, + onHeadReplaced: (_oldHead, newHead, tail) => { + // When this edge's head is replaced, have the new head establish + // its own connection to the tail so it receives future mutations + if (newHead instanceof SelfType) { + (newHead as ModelPropertyMutationNode).connectType(tail); + } }, }); } connectType(typeNode: MutationNode) { - MutationEdge.create(this, typeNode, { - onTailMutation: () => { - if (this.#referenceMutated) { - return; - } - this.mutatedType.type = typeNode.mutatedType; + this.startTypeEdge().setTail(typeNode); + } + + startModelEdge() { + return new HalfEdge(this, { + onTailMutation: (tail) => { + traceNode(this, "Model property model mutated."); + this.mutate(); + this.mutatedType.model = tail.mutatedType; }, onTailDeletion: () => { - if (this.#referenceMutated) { - return; - } - this.mutatedType.type = this.$.intrinsic.any; + this.delete(); }, - onTailReplaced: (newTail) => { - this.mutatedType.type = newTail.mutatedType; + onTailReplaced: (_oldTail, _newTail, head, _reconnect) => { + head.delete(); }, }); } + + connectModel(modelNode: MutationNode) { + this.startModelEdge().setTail(modelNode); + } } diff --git a/packages/mutator-framework/src/mutation-node/model.test.ts b/packages/mutator-framework/src/mutation-node/model.test.ts index b68eb927968..ebd4892d2e5 100644 --- a/packages/mutator-framework/src/mutation-node/model.test.ts +++ b/packages/mutator-framework/src/mutation-node/model.test.ts @@ -1,8 +1,8 @@ import type { Model, Type } from "@typespec/compiler"; -import { t, type TesterInstance } from "@typespec/compiler/testing"; +import { expectTypeEquals, t, type TesterInstance } from "@typespec/compiler/testing"; import { beforeEach, expect, it } from "vitest"; import { Tester } from "../../test/test-host.js"; -import { getSubgraph } from "../../test/utils.js"; +import { getEngine } from "../../test/utils.js"; let runner: TesterInstance; beforeEach(async () => { @@ -15,12 +15,30 @@ it("handles mutation of properties", async () => { prop: string; } `); - const subgraph = getSubgraph(program); - const fooNode = subgraph.getNode(Foo); - const propNode = subgraph.getNode(Foo.properties.get("prop")!); + const engine = getEngine(program); + const fooNode = engine.getMutationNode(Foo); + const propNode = engine.getMutationNode(Foo.properties.get("prop")!); + fooNode.connectProperty(propNode); propNode.mutate(); expect(fooNode.isMutated).toBe(true); - expect(fooNode.mutatedType.properties.get("prop") === propNode.mutatedType).toBe(true); + expectTypeEquals(fooNode.mutatedType.properties.get("prop")!, propNode.mutatedType); +}); + +it("handles mutation of properties lazily", async () => { + const { Foo, program } = await runner.compile(t.code` + model ${t.model("Foo")} { + prop: string; + } + `); + const engine = getEngine(program); + const fooNode = engine.getMutationNode(Foo); + fooNode.mutate(); + + const propNode = engine.getMutationNode(Foo.properties.get("prop")!); + fooNode.connectProperty(propNode); + expect(fooNode.isMutated).toBe(true); + expect(propNode.isMutated).toBe(true); + expectTypeEquals(fooNode.mutatedType.properties.get("prop")!, propNode.mutatedType); }); it("handles deletion of properties", async () => { @@ -29,9 +47,10 @@ it("handles deletion of properties", async () => { prop: string; } `); - const subgraph = getSubgraph(program); - const fooNode = subgraph.getNode(Foo); - const propNode = subgraph.getNode(Foo.properties.get("prop")!); + const engine = getEngine(program); + const fooNode = engine.getMutationNode(Foo); + const propNode = engine.getMutationNode(Foo.properties.get("prop")!); + fooNode.connectProperty(propNode); propNode.delete(); expect(fooNode.isMutated).toBe(true); expect(fooNode.mutatedType.properties.get("prop")).toBeUndefined(); @@ -43,13 +62,14 @@ it("handles mutation of properties with name change", async () => { prop: string; } `); - const subgraph = getSubgraph(program); - const fooNode = subgraph.getNode(Foo); - const propNode = subgraph.getNode(Foo.properties.get("prop")!); + const engine = getEngine(program); + const fooNode = engine.getMutationNode(Foo); + const propNode = engine.getMutationNode(Foo.properties.get("prop")!); + fooNode.connectProperty(propNode); propNode.mutate((clone) => (clone.name = "propRenamed")); expect(fooNode.isMutated).toBe(true); - expect(fooNode.mutatedType.properties.get("prop") === undefined).toBe(true); - expect(fooNode.mutatedType.properties.get("propRenamed") === propNode.mutatedType).toBe(true); + expect(fooNode.mutatedType.properties.get("prop")).toBeUndefined(); + expectTypeEquals(fooNode.mutatedType.properties.get("propRenamed")!, propNode.mutatedType); }); it("handles mutation of base models", async () => { @@ -62,10 +82,10 @@ it("handles mutation of base models", async () => { bazProp: string; } `); - const subgraph = getSubgraph(program); - const fooNode = subgraph.getNode(Foo); - const barNode = subgraph.getNode(Bar); - + const engine = getEngine(program); + const fooNode = engine.getMutationNode(Foo); + const barNode = engine.getMutationNode(Bar); + fooNode.connectBase(barNode); barNode.mutate(); expect(barNode.isMutated).toBe(true); expect(fooNode.isMutated).toBe(true); @@ -82,9 +102,10 @@ it("handles deletion of base models", async () => { bazProp: string; } `); - const subgraph = getSubgraph(program); - const fooNode = subgraph.getNode(Foo); - const barNode = subgraph.getNode(Bar); + const engine = getEngine(program); + const fooNode = engine.getMutationNode(Foo); + const barNode = engine.getMutationNode(Bar); + fooNode.connectBase(barNode); barNode.delete(); expect(barNode.isDeleted).toBe(true); @@ -99,9 +120,10 @@ it("handles mutation of indexers", async () => { bazProp: string; } `); - const subgraph = getSubgraph(program); - const fooNode = subgraph.getNode(Foo); - const barNode = subgraph.getNode(Bar); + const engine = getEngine(program); + const fooNode = engine.getMutationNode(Foo); + const barNode = engine.getMutationNode(Bar); + fooNode.connectIndexerValue(barNode); barNode.mutate(); expect(barNode.isMutated).toBe(true); @@ -117,10 +139,15 @@ it("handles mutation of arrays", async () => { } `); - const subgraph = getSubgraph(program); - const fooNode = subgraph.getNode(Foo); - const barNode = subgraph.getNode(Bar); - const bazPropNode = subgraph.getNode(bazProp); + const engine = getEngine(program); + const fooNode = engine.getMutationNode(Foo); + const barNode = engine.getMutationNode(Bar); + const bazPropNode = engine.getMutationNode(bazProp); + barNode.connectProperty(bazPropNode); + const arrayType = bazProp.type as Model; + const arrayNode = engine.getMutationNode(arrayType); + bazPropNode.connectType(arrayNode); + arrayNode.connectIndexerValue(fooNode); fooNode.mutate(); expect(fooNode.isMutated).toBe(true); @@ -141,9 +168,15 @@ it("handles circular models", async () => { } `); - const subgraph = getSubgraph(program); - const fooNode = subgraph.getNode(Foo); - const barNode = subgraph.getNode(Bar); + const engine = getEngine(program); + const fooNode = engine.getMutationNode(Foo); + const barNode = engine.getMutationNode(Bar); + const fooPropBar = engine.getMutationNode(Foo.properties.get("bar")!); + const barPropFoo = engine.getMutationNode(Bar.properties.get("foo")!); + fooNode.connectProperty(fooPropBar); + fooPropBar.connectType(barNode); + barNode.connectProperty(barPropFoo); + barPropFoo.connectType(fooNode); fooNode.mutate(); expect(fooNode.isMutated).toBe(true); diff --git a/packages/mutator-framework/src/mutation-node/model.ts b/packages/mutator-framework/src/mutation-node/model.ts index 742550f46b1..84200e6a958 100644 --- a/packages/mutator-framework/src/mutation-node/model.ts +++ b/packages/mutator-framework/src/mutation-node/model.ts @@ -1,74 +1,97 @@ -import type { Model, ModelProperty, Type } from "@typespec/compiler"; -import { MutationEdge } from "./mutation-edge.js"; +import type { Model, ModelProperty, Scalar, Type } from "@typespec/compiler"; +import type { ModelPropertyMutationNode } from "./model-property.js"; +import { HalfEdge } from "./mutation-edge.js"; import { MutationNode } from "./mutation-node.js"; +import { traceNode } from "./tracer.js"; + +export interface ModelConnectOptions { + /** Mutation key for the base model node. Defaults to this node's key. */ + baseModel?: string; + /** Mutation key for the indexer value node. Defaults to this node's key. */ + indexerValue?: string; +} export class ModelMutationNode extends MutationNode { readonly kind = "Model"; - - traverse() { - if (this.sourceType.baseModel) { - const baseNode = this.subgraph.getNode(this.sourceType.baseModel); - this.connectToBase(baseNode); - } - - for (const [propName, prop] of this.sourceType.properties) { - const propNode = this.subgraph.getNode(prop); - this.connectProperty(propNode, propName); - } - - if (this.sourceType.indexer) { - const indexerNode = this.subgraph.getNode(this.sourceType.indexer.value); - this.connectIndexerValue(indexerNode); - } - } - - connectToBase(baseNode: MutationNode) { - MutationEdge.create(this, baseNode, { - onTailMutation: () => { - this.mutatedType!.baseModel = baseNode.mutatedType; + startBaseModelEdge() { + return new HalfEdge(this, { + onTailMutation: (tail) => { + this.mutate(); + this.mutatedType!.baseModel = tail.mutatedType; }, onTailDeletion: () => { + this.mutate(); this.mutatedType.baseModel = undefined; }, - onTailReplaced: (newTail) => { + onTailReplaced: (_oldTail, newTail, head, reconnect) => { if (newTail.mutatedType.kind !== "Model") { throw new Error("Cannot replace base model with non-model type"); } - this.mutatedType.baseModel = newTail.mutatedType; + head.mutate(); + head.mutatedType.baseModel = newTail.mutatedType; + if (reconnect) { + head.connectBase(newTail as MutationNode); + } }, }); } + connectBase(baseNode: MutationNode) { + this.startBaseModelEdge().setTail(baseNode); + } - connectProperty(propNode: MutationNode, sourcePropName: string) { - MutationEdge.create(this, propNode, { - onTailMutation: () => { - this.mutatedType.properties.delete(sourcePropName); - this.mutatedType.properties.set(propNode.mutatedType.name, propNode.mutatedType); + startPropertyEdge() { + return new HalfEdge(this, { + onTailCreation: (tail) => { + tail.connectModel(this); }, - onTailDeletion: () => { - this.mutatedType.properties.delete(sourcePropName); + onTailMutation: (tail) => { + this.mutate(); + traceNode( + this, + `Model property mutated: ${tail.sourceType.name} -> ${tail.mutatedType.name}`, + ); + this.mutatedType.properties.delete(tail.sourceType.name); + this.mutatedType.properties.set(tail.mutatedType.name, tail.mutatedType); + }, + onTailDeletion: (tail) => { + this.mutate(); + this.mutatedType.properties.delete(tail.sourceType.name); }, - onTailReplaced: (newTail) => { + onTailReplaced: (oldTail, newTail, head, reconnect) => { if (newTail.mutatedType.kind !== "ModelProperty") { throw new Error("Cannot replace model property with non-model property type"); } - this.mutatedType.properties.delete(sourcePropName); - this.mutatedType.properties.set(newTail.mutatedType.name, newTail.mutatedType); + head.mutate(); + traceNode( + this, + `Model property replaced: ${oldTail.sourceType.name} -> ${newTail.mutatedType.name}`, + ); + head.mutatedType.properties.delete(oldTail.sourceType.name); + head.mutatedType.properties.set(newTail.mutatedType.name, newTail.mutatedType); + if (reconnect) { + head.connectProperty(newTail as unknown as ModelPropertyMutationNode); + } }, }); } - connectIndexerValue(indexerNode: MutationNode) { - MutationEdge.create(this, indexerNode, { - onTailMutation: () => { + connectProperty(propNode: ModelPropertyMutationNode) { + this.startPropertyEdge().setTail(propNode); + } + + startIndexerValueEdge() { + return new HalfEdge(this, { + onTailMutation: (tail) => { + this.mutate(); if (this.mutatedType.indexer) { this.mutatedType.indexer = { key: this.mutatedType.indexer.key, - value: indexerNode.mutatedType, + value: tail.mutatedType, }; } }, onTailDeletion: () => { + this.mutate(); if (this.mutatedType.indexer) { this.mutatedType.indexer = { key: this.mutatedType.indexer.key, @@ -76,14 +99,64 @@ export class ModelMutationNode extends MutationNode { }; } }, - onTailReplaced: (newTail) => { + onTailReplaced: (_oldTail, newTail, head, reconnect) => { + head.mutate(); + if (head.mutatedType.indexer) { + head.mutatedType.indexer = { + key: head.mutatedType.indexer.key, + value: newTail.mutatedType, + }; + } + if (reconnect) { + head.connectIndexerValue(newTail); + } + }, + }); + } + + startIndexerKeyEdge() { + return new HalfEdge(this, { + onTailMutation: (tail) => { + this.mutate(); if (this.mutatedType.indexer) { this.mutatedType.indexer = { - key: this.mutatedType.indexer.key, - value: newTail.mutatedType, + key: tail.mutatedType, + value: this.mutatedType.indexer.value, + }; + } + }, + onTailDeletion: () => { + this.mutate(); + if (this.mutatedType.indexer) { + this.mutatedType.indexer = { + key: this.$.builtin.integer, + value: this.mutatedType.indexer.value, }; } }, + onTailReplaced: (_oldTail, newTail, head, reconnect) => { + if (!head.$.scalar.is(newTail.mutatedType)) { + throw new Error("Cannot replace indexer key with non-scalar type"); + } + head.mutate(); + if (head.mutatedType.indexer) { + head.mutatedType.indexer = { + key: newTail.mutatedType, + value: head.mutatedType.indexer.value, + }; + } + if (reconnect) { + head.connectIndexerKey(newTail as MutationNode); + } + }, }); } + + connectIndexerKey(indexerNode: MutationNode) { + this.startIndexerKeyEdge().setTail(indexerNode); + } + + connectIndexerValue(indexerNode: MutationNode) { + this.startIndexerValueEdge().setTail(indexerNode); + } } diff --git a/packages/mutator-framework/src/mutation-node/mutation-edge.ts b/packages/mutator-framework/src/mutation-node/mutation-edge.ts index 3c537ce8970..927d9fecb65 100644 --- a/packages/mutator-framework/src/mutation-node/mutation-edge.ts +++ b/packages/mutator-framework/src/mutation-node/mutation-edge.ts @@ -1,43 +1,148 @@ import type { Type } from "@typespec/compiler"; +import type { MutationNodeForType } from "./factory.js"; import { MutationNode } from "./mutation-node.js"; +import { traceEdge } from "./tracer.js"; -export interface MutationEdgeOptions { - onTailMutation: () => void; - onTailDeletion: () => void; - onTailReplaced: (newTail: MutationNode) => void; +export interface MutationEdgeOptions { + onTailCreation?: (tail: MutationNodeForType) => void; + onTailMutation: (tail: MutationNodeForType) => void; + onTailDeletion: (tail: MutationNodeForType) => void; + /** + * Called when the tail node is replaced with a new node. + * @param oldTail - The original tail node that was replaced + * @param newTail - The new tail node + * @param head - The head node of this edge + * @param reconnect - If true, the callback should create a new edge to newTail. + * If false, an edge already exists and should not be recreated. + */ + onTailReplaced: ( + oldTail: MutationNodeForType, + newTail: MutationNodeForType, + head: MutationNodeForType, + reconnect: boolean, + ) => void; + onHeadReplaced?: ( + oldHead: MutationNodeForType, + newHead: MutationNodeForType, + tail: MutationNodeForType, + ) => void; } export class MutationEdge { public head: MutationNode; public tail: MutationNode; - #options: MutationEdgeOptions; + #options: MutationEdgeOptions; + #deleted: boolean = false; - constructor(head: MutationNode, tail: MutationNode, options: MutationEdgeOptions) { + constructor( + head: MutationNode, + tail: MutationNode, + options: MutationEdgeOptions, + ) { this.head = head; this.tail = tail; this.#options = options; + traceEdge(this, "Created."); this.tail.addInEdge(this); + this.head.addOutEdge(this); + + // If the tail is a replacement node, notify the head about the replacement + // so it can update its state (e.g., delete old property name, add new one). + // We don't delete this edge since it's already correctly pointing to the new tail. + // Pass reconnect=false since the edge already exists. + if (this.tail.replacedNode) { + traceEdge(this, "Tail is a replacement node, notifying head."); + this.#options.onTailReplaced( + this.tail.replacedNode as any, + this.tail as any, + this.head as any, + false, // don't reconnect - edge already exists + ); + } else if (this.tail.isMutated || this.tail.isSynthetic) { + this.tailMutated(); + } + + if (this.tail.isReplaced) { + this.tailReplaced(this.tail.replacementNode!); + } } - static create(head: MutationNode, tail: MutationNode, options: MutationEdgeOptions) { + static create( + head: MutationNode, + tail: MutationNode, + options: MutationEdgeOptions, + ) { return new MutationEdge(head, tail, options); } + /** + * Delete this edge, removing it from both the head's outEdges and tail's inEdges. + * Once deleted, the edge will no longer propagate any mutations. + */ + delete() { + if (this.#deleted) return; + traceEdge(this, "Deleted."); + this.#deleted = true; + this.head.deleteOutEdge(this); + this.tail.deleteInEdge(this); + } + tailMutated(): void { - this.head.mutate(); - this.#options.onTailMutation(); + if (this.#deleted || this.head.isDeleted) return; + traceEdge(this, "Tail mutated."); + this.#options.onTailMutation(this.tail as any); } tailDeleted() { - this.head.mutate(); - this.#options.onTailDeletion(); + if (this.#deleted || this.head.isDeleted) return; + traceEdge(this, "Tail deleted."); + this.#options.onTailDeletion(this.tail as any); } - tailReplaced(newTail: MutationNode) { - this.head.mutate(); - this.tail.deleteInEdge(this); - this.tail = newTail; - this.tail.addInEdge(this); - this.#options.onTailReplaced(newTail); + tailReplaced(newTail: MutationNode, oldTail?: MutationNode) { + if (this.#deleted || this.head.isDeleted) return; + traceEdge(this, "Tail replaced with " + newTail.id); + const actualOldTail = oldTail ?? this.tail; + const head = this.head; + // Delete this edge - the onTailReplaced callback is responsible for + // creating a new edge if the head node needs to track the new tail's mutations + this.delete(); + this.#options.onTailReplaced(actualOldTail as any, newTail as any, head as any, true); + } + + headReplaced(newHead: MutationNode) { + if (this.#deleted || this.tail.isDeleted) return; + traceEdge(this, "Head replaced with " + newHead.id); + const oldHead = this.head; + const tail = this.tail; + // Delete this edge - the onHeadReplaced callback is responsible for + // creating a new edge if the replacement node needs to track tail mutations + this.delete(); + this.#options.onHeadReplaced?.(oldHead as any, newHead as any, tail as any); + } + + toString() { + return `MutationEdge(head=${this.head.id}, tail=${this.tail.id})`; + } +} + +export class HalfEdge { + public head: MutationNode; + public tail: MutationNode | null; + #options: MutationEdgeOptions; + + constructor(head: MutationNode, options: MutationEdgeOptions) { + this.head = head; + this.tail = null; + this.#options = options; + } + + setTail(tail: MutationNode): MutationEdge { + if (this.tail) { + throw new Error("HalfEdge already has a tail"); + } + this.tail = tail; + this.#options.onTailCreation?.(tail as any); + return MutationEdge.create(this.head, this.tail, this.#options); } } 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 f39c4405186..8dda9ffcbfc 100644 --- a/packages/mutator-framework/src/mutation-node/mutation-node.test.ts +++ b/packages/mutator-framework/src/mutation-node/mutation-node.test.ts @@ -1,57 +1,36 @@ -import { t, type TesterInstance } from "@typespec/compiler/testing"; +import { expectTypeEquals, t, type TesterInstance } from "@typespec/compiler/testing"; +import { $ } from "@typespec/compiler/typekit"; import { beforeEach, expect, it } from "vitest"; import { Tester } from "../../test/test-host.js"; -import { getSubgraph } from "../../test/utils.js"; +import { getEngine } from "../../test/utils.js"; let runner: TesterInstance; beforeEach(async () => { runner = await Tester.createInstance(); }); -it("Subgraph#getNode returns the same node for the same type when called", async () => { +it("Engine#getMutationNode returns the same node for the same type when called", async () => { const { Foo, program } = await runner.compile(t.code` model ${t.model("Foo")} { prop: string; } `); - const subgraph = getSubgraph(program); - const fooNode1 = subgraph.getNode(Foo); - const fooNode2 = subgraph.getNode(Foo); + const engine = getEngine(program); + const fooNode1 = engine.getMutationNode(Foo); + const fooNode2 = engine.getMutationNode(Foo); expect(fooNode1 === fooNode2).toBe(true); }); -it("Creates the same node when constructing the subgraph and coming back to the same type", async () => { - const { Foo, Bar, Baz, program } = await runner.compile(t.code` - model ${t.model("Foo")} { - prop: string; - } - - model ${t.model("Bar")} { - foo: Foo; - } - - model ${t.model("Baz")} { - foo: Foo; - } - `); - const subgraph = getSubgraph(program); - const fooNode = subgraph.getNode(Foo); - subgraph.getNode(Bar); - subgraph.getNode(Baz); - - expect(fooNode.inEdges.size).toBe(2); -}); - it("starts with the mutatedType and sourceType being the same", async () => { const { Foo, program } = await runner.compile(t.code` model ${t.model("Foo")} { prop: string; } `); - const subgraph = getSubgraph(program); - const fooNode = subgraph.getNode(Foo); + const engine = getEngine(program); + const fooNode = engine.getMutationNode(Foo); expect(fooNode.isMutated).toBe(false); - expect(fooNode.sourceType === fooNode.mutatedType).toBe(true); + expectTypeEquals(fooNode.sourceType, fooNode.mutatedType); }); it("clones the source type when mutating and sets isMutated to true", async () => { @@ -60,10 +39,10 @@ it("clones the source type when mutating and sets isMutated to true", async () = prop: string; } `); - const subgraph = getSubgraph(program); - const fooNode = subgraph.getNode(Foo); + const engine = getEngine(program); + const fooNode = engine.getMutationNode(Foo); expect(fooNode.isMutated).toBe(false); - expect(fooNode.sourceType === fooNode.mutatedType).toBe(true); + expectTypeEquals(fooNode.sourceType, fooNode.mutatedType); fooNode.mutate(); expect(fooNode.isMutated).toBe(true); expect(fooNode.sourceType === fooNode.mutatedType).toBe(false); @@ -71,7 +50,7 @@ it("clones the source type when mutating and sets isMutated to true", async () = }); it("invokes whenMutated callbacks when mutating", async () => { - const { Foo, program } = await runner.compile(t.code` + const { Foo, Bar, program } = await runner.compile(t.code` model ${t.model("Foo")} { prop: Bar; } @@ -80,9 +59,12 @@ it("invokes whenMutated callbacks when mutating", async () => { prop: string; } `); - const subgraph = getSubgraph(program); - const fooNode = subgraph.getNode(Foo); - const barNode = subgraph.getNode(Foo); + const engine = getEngine(program); + const fooNode = engine.getMutationNode(Foo); + const barNode = engine.getMutationNode(Bar); + const fooProp = engine.getMutationNode(Foo.properties.get("prop")!); + fooNode.connectProperty(fooProp); + fooProp.connectType(barNode); let called = false; fooNode.whenMutated((mutatedType) => { called = true; @@ -92,3 +74,38 @@ it("invokes whenMutated callbacks when mutating", async () => { barNode.mutate(); expect(called).toBe(true); }); + +it("clones synthetic mutation nodes", async () => { + const { Foo, prop, program } = await runner.compile(t.code` + model ${t.model("Foo")} { + ${t.modelProperty("prop")}: string; + } + `); + const engine = getEngine(program); + const fooNode = engine.getMutationNode(Foo); + const propNode = engine.getMutationNode(prop); + fooNode.connectProperty(propNode); + + const model = $(program).model.create({ + name: "Testing", + properties: {}, + }); + + const typeNode = engine.getMutationNode(model, { isSynthetic: true }); + propNode.connectType(typeNode); + + // the type isn't mutated + expect(typeNode.isMutated).toBe(false); + + // but things referencing it are... + expect(propNode.isMutated).toBe(true); + expect(fooNode.isMutated).toBe(true); + + // we haven't mutated anything yet. + expect(propNode.mutatedType.type === model).toBe(true); + + typeNode.mutate(); + expect(typeNode.isMutated).toBe(true); + expect(propNode.mutatedType.type === model).toBe(false); + expect(propNode.mutatedType.type === typeNode.mutatedType).toBe(true); +}); diff --git a/packages/mutator-framework/src/mutation-node/mutation-node.ts b/packages/mutator-framework/src/mutation-node/mutation-node.ts index d855c151c9e..f4dbaa6478a 100644 --- a/packages/mutator-framework/src/mutation-node/mutation-node.ts +++ b/packages/mutator-framework/src/mutation-node/mutation-node.ts @@ -1,11 +1,35 @@ -import type { MemberType, Type } from "@typespec/compiler"; +import type { Type } from "@typespec/compiler"; import type { Typekit } from "@typespec/compiler/typekit"; +import type { MutationEngine } from "../mutation/mutation-engine.js"; import { mutationNodeFor, type MutationNodeForType } from "./factory.js"; import type { MutationEdge } from "./mutation-edge.js"; -import type { MutationSubgraph } from "./mutation-subgraph.js"; +import { traceNode } from "./tracer.js"; + +/** + * Mutation nodes represent a node in the type graph that can possibly be + * mutated. Each mutation node tracks the source type, the mutated type, and the + * edges coming from other mutation nodes which represent references to the + * source type for this node. + * + * The mutated type is initially a reference to the source type. When a node is + * mutated, the mutated type is initialized to a clone of the source type and + * any mutations are applied. Then all nodes which reference this node are also + * mutated, which will cause their mutated types to reference the mutated type + * of this node. + * + * Each node is unique based on the source type and an optional mutation key. + * The mutation key allows for multiple mutation nodes to exist for the same + * source type, which is useful when a type's mutation depends on its context + * such as how it is referenced. + */ let nextId = 0; +export interface MutationNodeOptions { + mutationKey?: string; + isSynthetic?: boolean; +} + export abstract class MutationNode { abstract readonly kind: string; @@ -13,43 +37,76 @@ export abstract class MutationNode { sourceType: T; mutatedType: T; isMutated: boolean = false; + isSynthetic: boolean = false; isDeleted: boolean = false; isReplaced: boolean = false; replacementNode: MutationNodeForType | null = null; - inEdges: Set> = new Set(); - subgraph: MutationSubgraph; - referenceType: MemberType | null = null; + /** If this node is a replacement for another node, this is the node it replaced */ + replacedNode: MutationNodeForType | null = null; + inEdges: Set> = new Set(); + outEdges: Set> = new Set(); + engine: MutationEngine; + mutationKey: string; $: Typekit; + protected connected: boolean = false; #whenMutatedCallbacks: ((mutatedType: Type | null) => void)[] = []; - constructor(subgraph: MutationSubgraph, sourceNode: T) { - this.subgraph = subgraph; - this.$ = this.subgraph.engine.$; + constructor( + subgraph: MutationEngine, + sourceNode: T, + options: MutationNodeOptions | string = "", + ) { + this.engine = subgraph; + this.$ = this.engine.$; this.sourceType = sourceNode; this.mutatedType = sourceNode; - } + if (typeof options === "string") { + this.mutationKey = options; + } else { + this.mutationKey = options.mutationKey ?? ""; + this.isSynthetic = options.isSynthetic ?? false; + } - abstract traverse(): void; + traceNode(this, "Created."); + } - addInEdge(edge: MutationEdge) { + addInEdge(edge: MutationEdge) { this.inEdges.add(edge); } - deleteInEdge(edge: MutationEdge) { + deleteInEdge(edge: MutationEdge) { this.inEdges.delete(edge); } + addOutEdge(edge: MutationEdge) { + this.outEdges.add(edge); + } + + deleteOutEdge(edge: MutationEdge) { + this.outEdges.delete(edge); + } + whenMutated(cb: (mutatedType: T | null) => void) { this.#whenMutatedCallbacks.push(cb as any); } mutate(initializeMutation?: (type: T) => void) { - if (this.isMutated || this.isDeleted || this.isReplaced) { + if (this.isDeleted || this.isReplaced) { + traceNode(this, `Already deleted/replaced, skipping mutation.`); return; } + if (this.isMutated) { + traceNode(this, "Already mutated, running initialization"); + initializeMutation?.(this.mutatedType); + return; + } + + traceNode(this, "Mutating."); + this.mutatedType = this.$.type.clone(this.sourceType); + this.isMutated = true; initializeMutation?.(this.mutatedType); for (const cb of this.#whenMutatedCallbacks) { @@ -64,9 +121,7 @@ export abstract class MutationNode { } delete() { - if (this.isMutated || this.isDeleted || this.isReplaced) { - return; - } + traceNode(this, "Deleting."); this.isDeleted = true; @@ -81,30 +136,56 @@ export abstract class MutationNode { } } + /** + * Replace this node with a new type. This creates a new mutation node for the + * replacement type and updates all edges to point to the new node. + * + * When a node is replaced: + * 1. A new mutation node is created for the replacement type + * 2. The original node is marked as replaced and will ignore future mutations + * 3. All out-edges (where this node is the head) are notified via `headReplaced`, + * which marks the edge so its callbacks no longer fire for the original node. + * The edge's `onHeadReplaced` callback is invoked, allowing the replacement + * node to establish its own connections to the tail nodes if needed. + * 4. All in-edges (where this node is the tail) are notified via `tailReplaced`, + * which updates the edge to point to the replacement node and invokes the + * head's `onTailReplaced` callback. + * + * @param newType - The type to replace this node with + * @returns The new mutation node for the replacement type + */ replace(newType: Type) { - if (this.isMutated || this.isDeleted || this.isReplaced) { + if (this.isReplaced) { return this; } + traceNode(this, "Replacing."); + // We need to make a new node because different types need to handle edge mutations differently. this.isReplaced = true; - this.replacementNode = mutationNodeFor(this.subgraph, newType); - this.replacementNode.traverse(); + this.replacementNode = mutationNodeFor(this.engine, newType, this.mutationKey); + // Set the back-reference so the replacement node knows what it replaced + this.replacementNode.replacedNode = this as unknown as MutationNodeForType; // we don't need to do the clone stuff with this node, but we mark it as // mutated because we don't want to allow further mutations on it. this.replacementNode.isMutated = true; - if (this.referenceType) { - this.subgraph.replaceReferenceNode(this.referenceType, this.replacementNode); - } else { - this.subgraph.replaceNode(this, this.replacementNode); + this.engine.replaceMutationNode(this, this.replacementNode); + traceNode(this, "Calling head replaced"); + for (const edge of this.outEdges) { + edge.headReplaced(this.replacementNode); } + traceNode(this, "Calling tail replaced"); for (const edge of this.inEdges) { edge.tailReplaced(this.replacementNode); } return this.replacementNode; } + + toString() { + return `MutationNode(${"name" in this.sourceType && typeof this.sourceType.name === "string" ? this.sourceType.name : this.kind}, key=${this.mutationKey}, id=${this.id})`; + } } diff --git a/packages/mutator-framework/src/mutation-node/mutation-subgraph.ts b/packages/mutator-framework/src/mutation-node/mutation-subgraph.ts deleted file mode 100644 index 4ada71da931..00000000000 --- a/packages/mutator-framework/src/mutation-node/mutation-subgraph.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { MemberType, Type } from "@typespec/compiler"; -import type { MutationEngine } from "../mutation/mutation-engine.js"; -import { mutationNodeFor, type MutationNodeForType } from "./factory.js"; -import type { MutationNode } from "./mutation-node.js"; - -/** - * A subgraph of mutation nodes such that there is one node per type in the graph. - */ -export class MutationSubgraph { - #seenNodes = new Map>(); - #seenReferenceNodes = new Map>(); - - engine: MutationEngine; - - constructor(engine: MutationEngine) { - this.engine = engine; - } - - getNode(type: T, memberReferences: MemberType[] = []): MutationNodeForType { - if (memberReferences.length > 0) { - return this.getReferenceNode(memberReferences[0]!) as any; - } - - if (this.#seenNodes.has(type)) { - return this.#seenNodes.get(type)! as MutationNodeForType; - } - - const node = mutationNodeFor(this, type); - this.#seenNodes.set(type, node); - node.traverse(); - - return node; - } - - getReferenceNode(memberType: MemberType): MutationNode { - if (this.#seenReferenceNodes.has(memberType)) { - return this.#seenReferenceNodes.get(memberType)!; - } - - let referencedType: Type = memberType; - while (referencedType.kind === "ModelProperty" || referencedType.kind === "UnionVariant") { - referencedType = referencedType.type; - } - const node = mutationNodeFor(this, referencedType); - node.referenceType = memberType; - this.#seenReferenceNodes.set(memberType, node); - node.traverse(); - - return node; - } - - replaceNode(oldNode: MutationNode, newNode: MutationNode) { - this.#seenNodes.set(oldNode.sourceType, newNode); - } - - replaceReferenceNode(referenceType: MemberType, newNode: MutationNode) { - this.#seenReferenceNodes.set(referenceType, newNode); - } -} diff --git a/packages/mutator-framework/src/mutation-node/operation.ts b/packages/mutator-framework/src/mutation-node/operation.ts index ad60773efaa..f1de6c6ed6e 100644 --- a/packages/mutator-framework/src/mutation-node/operation.ts +++ b/packages/mutator-framework/src/mutation-node/operation.ts @@ -1,22 +1,21 @@ -import type { Model, Operation, Type } from "@typespec/compiler"; -import { MutationEdge } from "./mutation-edge.js"; +import type { Interface, Model, Operation, Type } from "@typespec/compiler"; +import { HalfEdge } from "./mutation-edge.js"; import { MutationNode } from "./mutation-node.js"; +export interface OperationConnectOptions { + /** Mutation key for the parameters node. Defaults to this node's key. */ + parameters?: string; + /** Mutation key for the return type node. Defaults to this node's key. */ + returnType?: string; +} + export class OperationMutationNode extends MutationNode { readonly kind = "Operation"; - traverse() { - const parameterNode = this.subgraph.getNode(this.sourceType.parameters); - this.connectParameters(parameterNode); - - const returnTypeNode = this.subgraph.getNode(this.sourceType.returnType); - this.connectReturnType(returnTypeNode); - } - - connectParameters(baseNode: MutationNode) { - MutationEdge.create(this, baseNode, { - onTailMutation: () => { - this.mutatedType!.parameters = baseNode.mutatedType; + startParametersEdge() { + return new HalfEdge(this, { + onTailMutation: (tail) => { + this.mutatedType!.parameters = tail.mutatedType; }, onTailDeletion: () => { this.mutatedType.parameters = this.$.model.create({ @@ -24,26 +23,59 @@ export class OperationMutationNode extends MutationNode { properties: {}, }); }, - onTailReplaced: (newTail) => { + onTailReplaced: (_oldTail, newTail, head, reconnect) => { if (newTail.mutatedType.kind !== "Model") { throw new Error("Cannot replace parameters with non-model type"); } - this.mutatedType.parameters = newTail.mutatedType; + head.mutatedType.parameters = newTail.mutatedType; + if (reconnect) { + head.connectParameters(newTail as MutationNode); + } }, }); } - connectReturnType(typeNode: MutationNode) { - MutationEdge.create(this, typeNode, { - onTailMutation: () => { - this.mutatedType!.returnType = typeNode.mutatedType; + connectParameters(baseNode: MutationNode) { + this.startParametersEdge().setTail(baseNode); + } + + startReturnTypeEdge() { + return new HalfEdge(this, { + onTailMutation: (tail) => { + this.mutatedType!.returnType = tail.mutatedType; }, onTailDeletion: () => { this.mutatedType.returnType = this.$.intrinsic.void; }, - onTailReplaced: (newTail) => { - this.mutatedType.returnType = newTail.mutatedType; + onTailReplaced: (_oldTail, newTail, head, reconnect) => { + head.mutatedType.returnType = newTail.mutatedType; + if (reconnect) { + head.connectReturnType(newTail); + } + }, + }); + } + + connectReturnType(typeNode: MutationNode) { + this.startReturnTypeEdge().setTail(typeNode); + } + + startInterfaceEdge() { + return new HalfEdge(this, { + onTailMutation: (tail) => { + this.mutate(); + this.mutatedType.interface = tail.mutatedType; + }, + onTailDeletion: () => { + this.delete(); + }, + onTailReplaced: (_oldTail, _newTail, head, _reconnect) => { + head.delete(); }, }); } + + connectInterface(interfaceNode: MutationNode) { + this.startInterfaceEdge().setTail(interfaceNode); + } } diff --git a/packages/mutator-framework/src/mutation-node/scalar.test.ts b/packages/mutator-framework/src/mutation-node/scalar.test.ts index 40d600b00f5..0d02ab8e57a 100644 --- a/packages/mutator-framework/src/mutation-node/scalar.test.ts +++ b/packages/mutator-framework/src/mutation-node/scalar.test.ts @@ -1,20 +1,22 @@ -import { t, type TesterInstance } from "@typespec/compiler/testing"; +import { expectTypeEquals, t, type TesterInstance } from "@typespec/compiler/testing"; import { $ } from "@typespec/compiler/typekit"; import { beforeEach, expect, it } from "vitest"; import { Tester } from "../../test/test-host.js"; -import { getSubgraph } from "../../test/utils.js"; +import { getEngine } from "../../test/utils.js"; let runner: TesterInstance; beforeEach(async () => { runner = await Tester.createInstance(); }); + it("handles mutation of base scalars", async () => { const { program, Base, Derived } = await runner.compile(t.code` scalar ${t.scalar("Base")}; scalar ${t.scalar("Derived")} extends Base; `); - const subgraph = getSubgraph(program); - const baseNode = subgraph.getNode(Base); - const derivedNode = subgraph.getNode(Derived); + const engine = getEngine(program); + const baseNode = engine.getMutationNode(Base); + const derivedNode = engine.getMutationNode(Derived); + derivedNode.connectBaseScalar(baseNode); baseNode.mutate(); expect(baseNode.isMutated).toBe(true); @@ -27,18 +29,16 @@ it("handles replacement of scalars", async () => { scalar ${t.scalar("Base")}; scalar ${t.scalar("Derived")} extends Base; `); - const subgraph = getSubgraph(program); - const baseNode = subgraph.getNode(Base); - const derivedNode = subgraph.getNode(Derived); + const engine = getEngine(program); + const baseNode = engine.getMutationNode(Base); + const derivedNode = engine.getMutationNode(Derived); + derivedNode.connectBaseScalar(baseNode); const replacedNode = baseNode.replace($(program).builtin.string); expect(replacedNode.isMutated).toBe(true); expect(baseNode.isReplaced).toBe(true); - // subgraph is updated - expect(replacedNode === subgraph.getNode(Base)).toBe(true); - // derived node is updated expect(derivedNode.isMutated).toBe(true); - expect(derivedNode.mutatedType.baseScalar === replacedNode.sourceType).toBe(true); + expectTypeEquals(derivedNode.mutatedType.baseScalar!, replacedNode.sourceType); }); diff --git a/packages/mutator-framework/src/mutation-node/scalar.ts b/packages/mutator-framework/src/mutation-node/scalar.ts index bc30e41779d..1546c5df19e 100644 --- a/packages/mutator-framework/src/mutation-node/scalar.ts +++ b/packages/mutator-framework/src/mutation-node/scalar.ts @@ -1,32 +1,39 @@ import type { Scalar } from "@typespec/compiler"; -import { MutationEdge } from "./mutation-edge.js"; +import { HalfEdge } from "./mutation-edge.js"; import { MutationNode } from "./mutation-node.js"; +export interface ScalarConnectOptions { + /** Mutation key for the base scalar node. Defaults to this node's key. */ + baseScalar?: string; +} + export class ScalarMutationNode extends MutationNode { readonly kind = "Scalar"; - traverse() { - if (this.sourceType.baseScalar) { - const baseScalarNode = this.subgraph.getNode(this.sourceType.baseScalar); - this.connectBaseScalar(baseScalarNode); - } - } - - connectBaseScalar(baseScalar: MutationNode) { - MutationEdge.create(this, baseScalar, { - onTailReplaced: (newTail) => { - if (!this.$.scalar.is(newTail.mutatedType)) { + startBaseScalarEdge() { + return new HalfEdge(this, { + onTailReplaced: (_oldTail, newTail, head, reconnect) => { + if (!head.$.scalar.is(newTail.mutatedType)) { throw new Error("Cannot replace base scalar with non-scalar type"); } - - this.mutatedType.baseScalar = newTail.mutatedType; + head.mutate(); + head.mutatedType.baseScalar = newTail.mutatedType; + if (reconnect) { + head.connectBaseScalar(newTail as MutationNode); + } }, - onTailMutation: () => { - this.mutatedType.baseScalar = baseScalar.mutatedType; + onTailMutation: (tail) => { + this.mutate(); + this.mutatedType.baseScalar = tail.mutatedType; }, onTailDeletion: () => { + this.mutate(); this.mutatedType.baseScalar = undefined; }, }); } + + connectBaseScalar(baseScalar: MutationNode) { + this.startBaseScalarEdge().setTail(baseScalar); + } } diff --git a/packages/mutator-framework/src/mutation-node/tracer.ts b/packages/mutator-framework/src/mutation-node/tracer.ts new file mode 100644 index 00000000000..79223b971b5 --- /dev/null +++ b/packages/mutator-framework/src/mutation-node/tracer.ts @@ -0,0 +1,18 @@ +import type { MutationEdge } from "./mutation-edge.js"; +import type { MutationNode } from "./mutation-node.js"; + +/** + * Useful tracing utilities to help debug mutation graphs. + */ +const shouldTrace = false; +export function traceNode(node: MutationNode, message: string = ""): void { + if (!shouldTrace) return; + // eslint-disable-next-line no-console + console.log(`${node}\n ${message}`); +} + +export function traceEdge(edge: MutationEdge, message: string = ""): void { + if (!shouldTrace) return; + // eslint-disable-next-line no-console + console.log(`${edge}\n ${message}`); +} diff --git a/packages/mutator-framework/src/mutation-node/tuple.test.ts b/packages/mutator-framework/src/mutation-node/tuple.test.ts index eec38073dfc..0b1f411f57c 100644 --- a/packages/mutator-framework/src/mutation-node/tuple.test.ts +++ b/packages/mutator-framework/src/mutation-node/tuple.test.ts @@ -3,7 +3,7 @@ import { t, type TesterInstance } from "@typespec/compiler/testing"; import { $ } from "@typespec/compiler/typekit"; import { beforeEach, expect, it } from "vitest"; import { Tester } from "../../test/test-host.js"; -import { getSubgraph } from "../../test/utils.js"; +import { getEngine } from "../../test/utils.js"; let runner: TesterInstance; beforeEach(async () => { runner = await Tester.createInstance(); @@ -17,10 +17,15 @@ it("handles mutation of element types", async () => { model ${t.model("Bar")} {} `); - const subgraph = getSubgraph(program); - const fooNode = subgraph.getNode(Foo); - const propNode = subgraph.getNode(prop); - const barNode = subgraph.getNode(Bar); + const engine = getEngine(program); + const fooNode = engine.getMutationNode(Foo); + const propNode = engine.getMutationNode(prop); + const barNode = engine.getMutationNode(Bar); + fooNode.connectProperty(propNode); + const tupleType = prop.type as Tuple; + const tupleNode = engine.getMutationNode(tupleType); + propNode.connectType(tupleNode); + tupleNode.connectElement(barNode, 0); barNode.mutate(); expect(barNode.isMutated).toBe(true); expect(propNode.isMutated).toBe(true); diff --git a/packages/mutator-framework/src/mutation-node/tuple.ts b/packages/mutator-framework/src/mutation-node/tuple.ts index de191b15267..76b02757c85 100644 --- a/packages/mutator-framework/src/mutation-node/tuple.ts +++ b/packages/mutator-framework/src/mutation-node/tuple.ts @@ -1,35 +1,42 @@ import type { Tuple, Type } from "@typespec/compiler"; -import { MutationEdge } from "./mutation-edge.js"; +import { HalfEdge } from "./mutation-edge.js"; import { MutationNode } from "./mutation-node.js"; +export interface TupleConnectOptions { + /** Mutation keys for element nodes, keyed by index. Defaults to this node's key for all. */ + elements?: string[]; +} + export class TupleMutationNode extends MutationNode { readonly kind = "Tuple"; #indexMap: number[] = []; - traverse() { - for (let i = 0; i < this.sourceType.values.length; i++) { - const elemType = this.sourceType.values[i]; - const elemNode = this.subgraph.getNode(elemType); - this.#indexMap[i] = i; - this.connectElement(elemNode, i); - } - } - - connectElement(elemNode: MutationNode, index: number) { - MutationEdge.create(this, elemNode, { - onTailMutation: () => { - this.mutatedType.values[this.#indexMap[index]] = elemNode.mutatedType; + startElementEdge(index: number) { + this.#indexMap[index] = index; + return new HalfEdge(this, { + onTailMutation: (tail) => { + this.mutate(); + this.mutatedType.values[this.#indexMap[index]] = tail.mutatedType; }, onTailDeletion: () => { + this.mutate(); const spliceIndex = this.#indexMap[index]; this.mutatedType.values.splice(spliceIndex, 1); for (let i = spliceIndex + 1; i < this.#indexMap.length; i++) { this.#indexMap[i]--; } }, - onTailReplaced: (newTail) => { - this.mutatedType.values[this.#indexMap[index]] = newTail.mutatedType; + onTailReplaced: (_oldTail, newTail, head, reconnect) => { + head.mutate(); + head.mutatedType.values[this.#indexMap[index]] = newTail.mutatedType; + if (reconnect) { + head.connectElement(newTail, index); + } }, }); } + + connectElement(elemNode: MutationNode, index: number) { + this.startElementEdge(index).setTail(elemNode); + } } diff --git a/packages/mutator-framework/src/mutation-node/union-variant.test.ts b/packages/mutator-framework/src/mutation-node/union-variant.test.ts index a4658d7946f..3dfc0805119 100644 --- a/packages/mutator-framework/src/mutation-node/union-variant.test.ts +++ b/packages/mutator-framework/src/mutation-node/union-variant.test.ts @@ -2,7 +2,7 @@ import { t, type TesterInstance } from "@typespec/compiler/testing"; import { $ } from "@typespec/compiler/typekit"; import { beforeEach, expect, it } from "vitest"; import { Tester } from "../../test/test-host.js"; -import { getSubgraph } from "../../test/utils.js"; +import { getEngine } from "../../test/utils.js"; let runner: TesterInstance; beforeEach(async () => { runner = await Tester.createInstance(); @@ -16,13 +16,49 @@ it("handles mutation of variant types", async () => { } `); - const subgraph = getSubgraph(program); - const fooNode = subgraph.getNode(Foo); - const v1Node = subgraph.getNode(v1); - const stringNode = subgraph.getNode($(program).builtin.string); + const engine = getEngine(program); + const fooNode = engine.getMutationNode(Foo); + const v1Node = engine.getMutationNode(v1); + fooNode.connectVariant(v1Node); + const stringNode = engine.getMutationNode($(program).builtin.string); + v1Node.connectType(stringNode); stringNode.mutate(); expect(stringNode.isMutated).toBe(true); expect(v1Node.isMutated).toBe(true); expect(fooNode.isMutated).toBe(true); expect(v1Node.mutatedType.type === stringNode.mutatedType).toBeTruthy(); }); + +it("is deleted when its container union is deleted", async () => { + const { Foo, prop, program } = await runner.compile(t.code` + union ${t.union("Foo")} { + ${t.unionVariant("prop")}: string; + } + `); + const engine = getEngine(program); + const propNode = engine.getMutationNode(prop); + const fooNode = engine.getMutationNode(Foo); + fooNode.connectVariant(propNode); + const stringNode = engine.getMutationNode($(program).builtin.string); + propNode.connectType(stringNode); + + fooNode.delete(); + expect(propNode.isDeleted).toBe(true); +}); + +it("is deleted when its container union is replaced", async () => { + const { Foo, prop, program } = await runner.compile(t.code` + union ${t.union("Foo")} { + ${t.unionVariant("prop")}: string; + } + `); + const engine = getEngine(program); + const propNode = engine.getMutationNode(prop); + const fooNode = engine.getMutationNode(Foo); + fooNode.connectVariant(propNode); + const stringNode = engine.getMutationNode($(program).builtin.string); + propNode.connectType(stringNode); + + fooNode.replace($(program).builtin.string); + expect(propNode.isDeleted).toBe(true); +}); diff --git a/packages/mutator-framework/src/mutation-node/union-variant.ts b/packages/mutator-framework/src/mutation-node/union-variant.ts index 6a848668c4d..e7678fa36c7 100644 --- a/packages/mutator-framework/src/mutation-node/union-variant.ts +++ b/packages/mutator-framework/src/mutation-node/union-variant.ts @@ -1,26 +1,55 @@ -import type { Type, UnionVariant } from "@typespec/compiler"; -import { MutationEdge } from "./mutation-edge.js"; +import type { Type, Union, UnionVariant } from "@typespec/compiler"; +import { HalfEdge } from "./mutation-edge.js"; import { MutationNode } from "./mutation-node.js"; +export interface UnionVariantConnectOptions { + /** Mutation key for the variant's type node. Defaults to this node's key. */ + type?: string; +} + export class UnionVariantMutationNode extends MutationNode { readonly kind = "UnionVariant"; - traverse() { - const typeNode = this.subgraph.getNode(this.sourceType.type); - this.connectType(typeNode); + startTypeEdge() { + return new HalfEdge(this, { + onTailMutation: (tail) => { + this.mutate(); + this.mutatedType.type = tail.mutatedType; + }, + onTailDeletion: () => { + this.mutate(); + this.mutatedType.type = this.$.intrinsic.any; + }, + onTailReplaced: (_oldTail, newTail, head, reconnect) => { + head.mutate(); + head.mutatedType.type = newTail.mutatedType; + if (reconnect) { + head.connectType(newTail); + } + }, + }); } connectType(typeNode: MutationNode) { - MutationEdge.create(this, typeNode, { - onTailMutation: () => { - this.mutatedType.type = typeNode.mutatedType; + this.startTypeEdge().setTail(typeNode); + } + + startUnionEdge() { + return new HalfEdge(this, { + onTailMutation: (tail) => { + this.mutate(); + this.mutatedType.union = tail.mutatedType; }, onTailDeletion: () => { - this.mutatedType.type = this.$.intrinsic.any; + this.delete(); }, - onTailReplaced: (newTail) => { - this.mutatedType.type = newTail.mutatedType; + onTailReplaced: (_oldTail, _newTail, head, _reconnect) => { + head.delete(); }, }); } + + connectUnion(unionNode: MutationNode) { + this.startUnionEdge().setTail(unionNode); + } } diff --git a/packages/mutator-framework/src/mutation-node/union.test.ts b/packages/mutator-framework/src/mutation-node/union.test.ts index ca6c07c4f73..77d4bc82311 100644 --- a/packages/mutator-framework/src/mutation-node/union.test.ts +++ b/packages/mutator-framework/src/mutation-node/union.test.ts @@ -1,7 +1,7 @@ import { t, type TesterInstance } from "@typespec/compiler/testing"; import { beforeEach, expect, it } from "vitest"; import { Tester } from "../../test/test-host.js"; -import { getSubgraph } from "../../test/utils.js"; +import { getEngine } from "../../test/utils.js"; let runner: TesterInstance; beforeEach(async () => { runner = await Tester.createInstance(); @@ -15,9 +15,10 @@ it("handles mutation of variants", async () => { } `); - const subgraph = getSubgraph(program); - const fooNode = subgraph.getNode(Foo); - const v1Node = subgraph.getNode(v1); + const engine = getEngine(program); + const fooNode = engine.getMutationNode(Foo); + const v1Node = engine.getMutationNode(v1); + fooNode.connectVariant(v1Node); v1Node.mutate((clone) => (clone.name = "v1Renamed")); expect(v1Node.isMutated).toBe(true); expect(fooNode.isMutated).toBe(true); diff --git a/packages/mutator-framework/src/mutation-node/union.ts b/packages/mutator-framework/src/mutation-node/union.ts index 1874e20eb4a..6c20bfba5b6 100644 --- a/packages/mutator-framework/src/mutation-node/union.ts +++ b/packages/mutator-framework/src/mutation-node/union.ts @@ -1,33 +1,39 @@ import type { Union, UnionVariant } from "@typespec/compiler"; -import { MutationEdge } from "./mutation-edge.js"; +import { HalfEdge } from "./mutation-edge.js"; import { MutationNode } from "./mutation-node.js"; export class UnionMutationNode extends MutationNode { readonly kind = "Union"; - traverse(): void { - for (const variant of this.sourceType.variants.values()) { - const variantNode = this.subgraph.getNode(variant); - this.connectVariant(variantNode, variant.name); - } - } - - connectVariant(variantNode: MutationNode, sourcePropName: string | symbol) { - MutationEdge.create(this, variantNode, { - onTailMutation: () => { - this.mutatedType.variants.delete(sourcePropName); - this.mutatedType.variants.set(variantNode.mutatedType.name, variantNode.mutatedType); + startVariantEdge() { + return new HalfEdge(this, { + onTailCreation: (tail) => { + tail.connectUnion(this); + }, + onTailMutation: (tail) => { + this.mutate(); + this.mutatedType.variants.delete(tail.sourceType.name); + this.mutatedType.variants.set(tail.mutatedType.name, tail.mutatedType); }, - onTailDeletion: () => { - this.mutatedType.variants.delete(sourcePropName); + onTailDeletion: (tail) => { + this.mutate(); + this.mutatedType.variants.delete(tail.sourceType.name); }, - onTailReplaced: (newTail) => { + onTailReplaced: (oldTail, newTail, head, reconnect) => { if (newTail.mutatedType.kind !== "UnionVariant") { throw new Error("Cannot replace union variant with non-union variant type"); } - this.mutatedType.variants.delete(sourcePropName); - this.mutatedType.variants.set(newTail.mutatedType.name, newTail.mutatedType); + head.mutate(); + head.mutatedType.variants.delete(oldTail.sourceType.name); + head.mutatedType.variants.set(newTail.mutatedType.name, newTail.mutatedType); + if (reconnect) { + head.connectVariant(newTail as MutationNode); + } }, }); } + + connectVariant(variantNode: MutationNode) { + this.startVariantEdge().setTail(variantNode); + } } diff --git a/packages/mutator-framework/src/mutation/enum-member.ts b/packages/mutator-framework/src/mutation/enum-member.ts index db2d9c1b172..0ef3d896599 100644 --- a/packages/mutator-framework/src/mutation/enum-member.ts +++ b/packages/mutator-framework/src/mutation/enum-member.ts @@ -1,6 +1,6 @@ import type { EnumMember, MemberType } from "@typespec/compiler"; import type { CustomMutationClasses, MutationEngine, MutationOptions } from "./mutation-engine.js"; -import { Mutation } from "./mutation.js"; +import { Mutation, type MutationInfo } from "./mutation.js"; export class EnumMemberMutation< TOptions extends MutationOptions, @@ -14,8 +14,9 @@ export class EnumMemberMutation< sourceType: EnumMember, referenceTypes: MemberType[] = [], options: TOptions, + info: MutationInfo, ) { - super(engine, sourceType, referenceTypes, options); + super(engine, sourceType, referenceTypes, options, info); } mutate() { diff --git a/packages/mutator-framework/src/mutation/enum.ts b/packages/mutator-framework/src/mutation/enum.ts index 560a4e5133e..d1cdc57430f 100644 --- a/packages/mutator-framework/src/mutation/enum.ts +++ b/packages/mutator-framework/src/mutation/enum.ts @@ -5,7 +5,7 @@ import type { MutationFor, MutationOptions, } from "./mutation-engine.js"; -import { Mutation } from "./mutation.js"; +import { Mutation, type MutationInfo } from "./mutation.js"; export class EnumMutation< TOptions extends MutationOptions, @@ -20,8 +20,9 @@ export class EnumMutation< sourceType: Enum, referenceTypes: MemberType[] = [], options: TOptions, + info: MutationInfo, ) { - super(engine, sourceType, referenceTypes, options); + super(engine, sourceType, referenceTypes, options, info); } protected mutateMembers() { diff --git a/packages/mutator-framework/src/mutation/interface.ts b/packages/mutator-framework/src/mutation/interface.ts index f4770ee7239..037c3fcacb0 100644 --- a/packages/mutator-framework/src/mutation/interface.ts +++ b/packages/mutator-framework/src/mutation/interface.ts @@ -3,11 +3,12 @@ import type { CustomMutationClasses, MutationEngine, MutationFor, + MutationHalfEdge, MutationOptions, } from "./mutation-engine.js"; -import { Mutation } from "./mutation.js"; +import { Mutation, type MutationInfo } from "./mutation.js"; -export class InterfaceMutation< +export abstract class InterfaceMutation< TOptions extends MutationOptions, TCustomMutations extends CustomMutationClasses, > extends Mutation { @@ -17,21 +18,27 @@ export class InterfaceMutation< constructor( engine: MutationEngine, sourceType: Interface, - referenceTypes: MemberType[] = [], + referenceTypes: MemberType[], options: TOptions, + info: MutationInfo, ) { - super(engine, sourceType, referenceTypes, options); + super(engine, sourceType, referenceTypes, options, info); } protected mutateOperations() { for (const op of this.sourceType.operations.values()) { this.operations.set( op.name, - this.engine.mutate(op, this.options) as MutationFor, + this.engine.mutate(op, this.options, this.startOperationEdge()) as MutationFor< + TCustomMutations, + "Operation" + >, ); } } + protected abstract startOperationEdge(): MutationHalfEdge; + mutate() { this.mutateOperations(); } diff --git a/packages/mutator-framework/src/mutation/intrinsic.ts b/packages/mutator-framework/src/mutation/intrinsic.ts index 726bbbf9153..a94e39d3ed9 100644 --- a/packages/mutator-framework/src/mutation/intrinsic.ts +++ b/packages/mutator-framework/src/mutation/intrinsic.ts @@ -1,6 +1,6 @@ import type { IntrinsicType, MemberType } from "@typespec/compiler"; import type { CustomMutationClasses, MutationEngine, MutationOptions } from "./mutation-engine.js"; -import { Mutation } from "./mutation.js"; +import { Mutation, type MutationInfo } from "./mutation.js"; export class IntrinsicMutation< TOptions extends MutationOptions, @@ -11,10 +11,11 @@ export class IntrinsicMutation< constructor( engine: TEngine, sourceType: IntrinsicType, - referenceTypes: MemberType[] = [], + referenceTypes: MemberType[], options: TOptions, + info: MutationInfo, ) { - super(engine, sourceType, referenceTypes, options); + super(engine, sourceType, referenceTypes, options, info); } mutate() { diff --git a/packages/mutator-framework/src/mutation/literal.ts b/packages/mutator-framework/src/mutation/literal.ts index 15cd3f53312..16b3ce45356 100644 --- a/packages/mutator-framework/src/mutation/literal.ts +++ b/packages/mutator-framework/src/mutation/literal.ts @@ -1,6 +1,6 @@ import type { BooleanLiteral, MemberType, NumericLiteral, StringLiteral } from "@typespec/compiler"; import type { CustomMutationClasses, MutationEngine, MutationOptions } from "./mutation-engine.js"; -import { Mutation } from "./mutation.js"; +import { Mutation, type MutationInfo } from "./mutation.js"; export class LiteralMutation< TOptions extends MutationOptions, @@ -17,10 +17,11 @@ export class LiteralMutation< constructor( engine: TEngine, sourceType: StringLiteral | NumericLiteral | BooleanLiteral, - referenceTypes: MemberType[] = [], + referenceTypes: MemberType[], options: TOptions, + info: MutationInfo, ) { - super(engine, sourceType, referenceTypes, options); + super(engine, sourceType, referenceTypes, options, info); } mutate() { diff --git a/packages/mutator-framework/src/mutation/model-property.ts b/packages/mutator-framework/src/mutation/model-property.ts index 607c976f89a..6f781fd6da4 100644 --- a/packages/mutator-framework/src/mutation/model-property.ts +++ b/packages/mutator-framework/src/mutation/model-property.ts @@ -1,35 +1,28 @@ import type { ModelProperty, Type } from "@typespec/compiler"; -import type { MutationSubgraph } from "../mutation-node/mutation-subgraph.js"; import type { CustomMutationClasses, MutationEngine, MutationFor, + MutationHalfEdge, MutationOptions, } from "./mutation-engine.js"; import { Mutation } from "./mutation.js"; -export class ModelPropertyMutation< - TOptions extends MutationOptions, +export abstract class ModelPropertyMutation< TCustomMutations extends CustomMutationClasses, + TOptions extends MutationOptions, TEngine extends MutationEngine = MutationEngine, > extends Mutation { readonly kind = "ModelProperty"; type!: MutationFor; - mutate() { - this.type = this.engine.mutateReference(this.sourceType, this.options); + mutate(newOptions: MutationOptions = this.options) { + this.type = this.engine.mutateReference( + this.sourceType, + newOptions, + this.startTypeEdge(), + ) as MutationFor; } - getReferenceMutationNode( - subgraph: MutationSubgraph = this.engine.getDefaultMutationSubgraph(this.options), - ) { - return subgraph.getReferenceNode(this.sourceType); - } - - replaceReferencedType(subgraph: MutationSubgraph, newType: Type) { - // First, update the mutation node - subgraph.getReferenceNode(this.sourceType).replace(newType); - // then return a new reference mutation for the new type - return this.engine.mutateReference(this.sourceType, newType, this.options); - } + protected abstract startTypeEdge(): MutationHalfEdge; } diff --git a/packages/mutator-framework/src/mutation/model.ts b/packages/mutator-framework/src/mutation/model.ts index 7cd7311b500..c9f3b0f79af 100644 --- a/packages/mutator-framework/src/mutation/model.ts +++ b/packages/mutator-framework/src/mutation/model.ts @@ -3,13 +3,14 @@ import type { CustomMutationClasses, MutationEngine, MutationFor, + MutationHalfEdge, MutationOptions, } from "./mutation-engine.js"; -import { Mutation } from "./mutation.js"; +import { Mutation, type MutationInfo } from "./mutation.js"; -export class ModelMutation< - TOptions extends MutationOptions, +export abstract class ModelMutation< TCustomMutations extends CustomMutationClasses, + TOptions extends MutationOptions, TEngine extends MutationEngine = MutationEngine, > extends Mutation { readonly kind = "Model"; @@ -23,36 +24,57 @@ export class ModelMutation< constructor( engine: TEngine, sourceType: Model, - referenceTypes: MemberType[] = [], + referenceTypes: MemberType[], options: TOptions, + info: MutationInfo, ) { - super(engine, sourceType, referenceTypes, options); + super(engine, sourceType, referenceTypes, options, info); } - protected mutateBaseModel() { + protected mutateBaseModel(newOptions: MutationOptions = this.options) { if (this.sourceType.baseModel) { - this.baseModel = this.engine.mutate(this.sourceType.baseModel, this.options); + this.baseModel = this.engine.mutate( + this.sourceType.baseModel, + newOptions, + this.startBaseEdge(), + ); } } - protected mutateProperties() { + protected mutateProperties(newOptions: MutationOptions = this.options) { for (const prop of this.sourceType.properties.values()) { - this.properties.set(prop.name, this.engine.mutate(prop, this.options)); + this.properties.set( + prop.name, + this.engine.mutate(prop, newOptions, this.startPropertyEdge()), + ); } } - protected mutateIndexer() { + protected mutateIndexer(newOptions: MutationOptions = this.options) { if (this.sourceType.indexer) { this.indexer = { - key: this.engine.mutate(this.sourceType.indexer.key, this.options), - value: this.engine.mutate(this.sourceType.indexer.value, this.options), + key: this.engine.mutate( + this.sourceType.indexer.key, + newOptions, + this.startIndexerKeyEdge(), + ), + value: this.engine.mutate( + this.sourceType.indexer.value, + newOptions, + this.startIndexerValueEdge(), + ), }; } } - mutate() { - this.mutateBaseModel(); - this.mutateProperties(); - this.mutateIndexer(); + protected abstract startBaseEdge(): MutationHalfEdge; + protected abstract startPropertyEdge(): MutationHalfEdge; + protected abstract startIndexerValueEdge(): MutationHalfEdge; + protected abstract startIndexerKeyEdge(): MutationHalfEdge; + + mutate(newOptions: MutationOptions = this.options) { + this.mutateBaseModel(newOptions); + this.mutateProperties(newOptions); + this.mutateIndexer(newOptions); } } diff --git a/packages/mutator-framework/src/mutation/mutation-engine.test.ts b/packages/mutator-framework/src/mutation/mutation-engine.test.ts deleted file mode 100644 index 5ae67499b40..00000000000 --- a/packages/mutator-framework/src/mutation/mutation-engine.test.ts +++ /dev/null @@ -1,271 +0,0 @@ -import type { Enum, Model } from "@typespec/compiler"; -import { t } from "@typespec/compiler/testing"; -import { $, type Typekit } from "@typespec/compiler/typekit"; -import { expect, it } from "vitest"; -import { Tester } from "../../test/test-host.js"; -import type { MutationSubgraph } from "../mutation-node/mutation-subgraph.js"; -import { EnumMemberMutation } from "./enum-member.js"; -import { EnumMutation } from "./enum.js"; -import { ModelPropertyMutation } from "./model-property.js"; -import { ModelMutation } from "./model.js"; -import { MutationEngine, MutationOptions } from "./mutation-engine.js"; -import { SimpleMutationEngine } from "./simple-mutation-engine.js"; - -class RenameMutationOptions extends MutationOptions { - prefix: string; - suffix: string; - - constructor(prefix: string, suffix: string) { - super(); - this.prefix = prefix; - this.suffix = suffix; - } - - cacheKey() { - return `${this.prefix}-${this.suffix}`; - } -} - -class RenameMutationEngine extends MutationEngine { - constructor($: Typekit) { - super($, { - Model: RenameModelMutation, - }); - this.registerSubgraph("prefix"); - this.registerSubgraph("suffix"); - } - - getPrefixSubgraph(options: RenameMutationOptions): MutationSubgraph { - return this.getMutationSubgraph(options, "prefix"); - } - - getSuffixSubgraph(options: RenameMutationOptions): MutationSubgraph { - return this.getMutationSubgraph(options, "suffix"); - } -} - -interface RenameMutationClasses { - Model: RenameModelMutation; -} - -class RenameModelMutation extends ModelMutation< - RenameMutationOptions, - RenameMutationClasses, - RenameMutationEngine -> { - get #prefixSubgraph() { - return this.engine.getPrefixSubgraph(this.options); - } - - get #suffixSubgraph() { - return this.engine.getSuffixSubgraph(this.options); - } - - get withPrefix() { - return this.getMutatedType(this.#prefixSubgraph); - } - - get withSuffix() { - return this.getMutatedType(this.#suffixSubgraph); - } - - mutate() { - if ("name" in this.sourceType && typeof this.sourceType.name === "string") { - this.mutateType( - this.#prefixSubgraph, - (m) => (m.name = `${this.options.prefix}${this.sourceType.name}`), - ); - this.mutateType( - this.#suffixSubgraph, - (m) => (m.name = `${this.sourceType.name}${this.options.suffix}`), - ); - } - - // mutate all connected types passing on the same options - super.mutate(); - } -} -it("creates mutations", async () => { - const runner = await Tester.createInstance(); - const { Foo, Bar, prop, program } = await runner.compile(t.code` - model ${t.model("Foo")} { - ${t.modelProperty("prop")}: Bar; - } - - model ${t.model("Bar")} { - prop: string; - } - `); - - const tk = $(program); - const engine = new RenameMutationEngine(tk); - const options = new RenameMutationOptions("Pre", "Suf"); - const fooMutation = engine.mutate(Foo, options); - - // can navigate the mutation result to get prefix and suffix names side-by-side - expect(fooMutation.properties.size).toBe(1); - - const barMutation = fooMutation.properties.get("prop")!.type as RenameModelMutation; - expect(barMutation.withPrefix.name).toBe("PreBar"); - - // Or you could get barMutation like: - const barMutation2 = engine.mutate(Bar, options); - - // but these are not the same mutation node because the mutation accessed via - // the property is a distinct from the one accessed from the scalar. - expect(barMutation === barMutation2).toBe(false); - expect(barMutation.referenceTypes.length).toEqual(1); - expect(barMutation.referenceTypes[0] === prop).toBe(true); - expect(barMutation2.referenceTypes.length).toEqual(0); - - // The graph is mutated - const prefixModel = fooMutation.withPrefix; - const suffixModel = fooMutation.withSuffix; - - expect(prefixModel.name).toBe("PreFoo"); - expect((prefixModel.properties.get("prop")!.type as Model).name).toBe("PreBar"); - expect(suffixModel.name).toBe("FooSuf"); - expect((suffixModel.properties.get("prop")!.type as Model).name).toBe("BarSuf"); -}); - -interface UnionifyMutations { - Model: UnionifyModel; - ModelProperty: UnionifyProperty; -} - -class UnionifyModel extends ModelMutation< - MutationOptions, - UnionifyMutations, - SimpleMutationEngine -> { - get unionified() { - return this.getMutatedType(); - } -} - -class UnionifyProperty extends ModelPropertyMutation< - MutationOptions, - UnionifyMutations, - SimpleMutationEngine -> { - get unionified() { - return this.getMutatedType(); - } - - mutate() { - if (!this.engine.$.union.is(this.sourceType.type)) { - // turn it into this union: - const newUnionType = this.engine.$.union.create({ - variants: [ - this.engine.$.unionVariant.create({ type: this.sourceType.type }), - this.engine.$.unionVariant.create({ - type: this.engine.$.builtin.string, - }), - ], - }); - - this.type = this.replaceReferencedType( - this.engine.getDefaultMutationSubgraph(this.options), - newUnionType, - ); - } else { - super.mutate(); - } - } -} - -it("mutates model properties into unions", async () => { - const runner = await Tester.createInstance(); - const { Foo, program } = await runner.compile(t.code` - model ${t.model("Foo")} { - ${t.modelProperty("prop")}: Bar; - } - - model ${t.model("Bar")} { - barProp: string; - } - `); - - const tk = $(program); - const engine = new SimpleMutationEngine(tk, { - ModelProperty: UnionifyProperty, - Model: UnionifyModel, - }); - - const fooMutation = engine.mutate(Foo); - const propMutation = fooMutation.properties.get("prop")!; - const typeMutation = propMutation.type as UnionifyModel; - expect(typeMutation.kind).toBe("Union"); - const propType = propMutation.unionified; - expect(tk.union.is(propType.type)).toBe(true); - - const mutatedFoo = fooMutation.unionified; - expect(tk.union.is(mutatedFoo.properties.get("prop")!.type)).toBe(true); -}); - -interface RenameEnumMutations { - Enum: RenameEnumMutation; - EnumMember: RenameEnumMemberMutation; -} - -class RenameEnumMutation extends EnumMutation< - RenameMutationOptions, - RenameEnumMutations, - MutationEngine -> { - get withPrefix() { - return this.getMutatedType(); - } - - mutate() { - this.mutateType((e) => (e.name = `${this.options.prefix}${this.sourceType.name}`)); - super.mutate(); - } -} - -class RenameEnumMemberMutation extends EnumMemberMutation< - RenameMutationOptions, - RenameEnumMutations, - MutationEngine -> { - get withPrefix() { - return this.getMutatedType(); - } - - mutate() { - this.mutateType((m) => (m.name = `${this.options.prefix}${this.sourceType.name}`)); - super.mutate(); - } -} - -it("mutates enums and enum members", async () => { - const runner = await Tester.createInstance(); - const { Status, program } = await runner.compile(t.code` - enum ${t.enum("Status")} { - Active, - Inactive, - } - `); - - const tk = $(program); - const engine = new SimpleMutationEngine(tk, { - Enum: RenameEnumMutation, - EnumMember: RenameEnumMemberMutation, - }); - - const options = new RenameMutationOptions("Pre", "Suf"); - const enumMutation = engine.mutate(Status, options) as RenameEnumMutation; - - // Verify the enum and its members are mutated - expect(enumMutation.withPrefix.name).toBe("PreStatus"); - - const activeMutation = enumMutation.members.get("Active") as RenameEnumMemberMutation; - expect(activeMutation.withPrefix.name).toBe("PreActive"); - - // Verify the mutated enum has the renamed members - const mutatedEnum = enumMutation.withPrefix as Enum; - expect(mutatedEnum.members.has("PreActive")).toBe(true); - expect(mutatedEnum.members.has("PreInactive")).toBe(true); - expect(mutatedEnum.members.has("Active")).toBe(false); - expect(mutatedEnum.members.has("Inactive")).toBe(false); -}); diff --git a/packages/mutator-framework/src/mutation/mutation-engine.ts b/packages/mutator-framework/src/mutation/mutation-engine.ts index 31442508748..e59aa83a770 100644 --- a/packages/mutator-framework/src/mutation/mutation-engine.ts +++ b/packages/mutator-framework/src/mutation/mutation-engine.ts @@ -1,7 +1,7 @@ import type { MemberType, Type } from "@typespec/compiler"; import type { Typekit } from "@typespec/compiler/typekit"; -import type { MutationNodeForType } from "../mutation-node/factory.js"; -import { MutationSubgraph } from "../mutation-node/mutation-subgraph.js"; +import { mutationNodeFor, type MutationNodeForType } from "../mutation-node/factory.js"; +import { MutationNode, type MutationNodeOptions } from "../mutation-node/mutation-node.js"; import { EnumMemberMutation } from "./enum-member.js"; import { EnumMutation } from "./enum.js"; import { InterfaceMutation } from "./interface.js"; @@ -11,6 +11,7 @@ import { ModelPropertyMutation } from "./model-property.js"; import { ModelMutation } from "./model.js"; import { Mutation } from "./mutation.js"; import { OperationMutation } from "./operation.js"; + import { ScalarMutation } from "./scalar.js"; import { UnionVariantMutation } from "./union-variant.js"; import { UnionMutation } from "./union.js"; @@ -21,9 +22,9 @@ export interface DefaultMutationClasses; Interface: InterfaceMutation; - Model: ModelMutation; + Model: ModelMutation; Scalar: ScalarMutation; - ModelProperty: ModelPropertyMutation; + ModelProperty: ModelPropertyMutation; Union: UnionMutation; UnionVariant: UnionVariantMutation; Enum: EnumMutation; @@ -50,22 +51,51 @@ export type InstancesFor any>> [K in keyof T]: InstanceType; }; -export class MutationEngine { - $: Typekit; +export interface InitialMutationContext< + TSourceType extends Type, + TCustomMutations extends CustomMutationClasses, + TOptions extends MutationOptions = MutationOptions, + TEngine extends MutationEngine = MutationEngine, +> { + engine: TEngine; + sourceType: TSourceType; + referenceTypes: MemberType[]; + options: TOptions; +} - // Map of Type -> (Map of options.cacheKey() -> Mutation) - #mutationCache = new Map>>(); +export interface CreateMutationContext { + mutationKey: string; +} - // Map of MemberType -> (Map of options.cacheKey() -> Mutation) - #referenceMutationCache = new Map>>(); +export interface MutationContext< + TSourceType extends Type, + TCustomMutations extends CustomMutationClasses, + TOptions extends MutationOptions = MutationOptions, + TEngine extends MutationEngine = MutationEngine, +> extends InitialMutationContext, + CreateMutationContext {} - #subgraphNames = new Set(); +export interface MutationTraits { + isSynthetic?: boolean; +} - // Map of subgraph names -> (Map of options.cacheKey() -> MutationSubgraph) - #subgraphs = new Map>(); +/** + * Orchestrates type mutations using custom and default mutation classes. + */ +export class MutationEngine { + /** TypeSpec type utilities. */ + $: Typekit; - #mutatorClasses: MutationRegistry; + // Map of Type -> (Map of options.cacheKey() -> Mutation) + #mutationCache = new Map>>(); + #seenMutationNodes = new WeakMap>>(); + #mutatorClasses: ConstructorsFor; + /** + * Creates a mutation engine with optional custom mutation classes. + * @param $ - TypeSpec type utilities + * @param mutatorClasses - Custom mutation class constructors + */ constructor($: Typekit, mutatorClasses: ConstructorsFor) { this.$ = $; this.#mutatorClasses = { @@ -85,170 +115,112 @@ export class MutationEngine { } as any; } - protected registerSubgraph(name: string) { - this.#subgraphNames.add(name); - } - - protected getMutationSubgraph(options: MutationOptions, name?: string) { - const optionsKey = options?.cacheKey() ?? "default"; - if (!this.#subgraphs.has(optionsKey)) { - this.#subgraphs.set(optionsKey, new Map()); - } - const subgraphsForOptions = this.#subgraphs.get(optionsKey)!; - - name = name ?? "default"; - if (!subgraphsForOptions.has(name)) { - subgraphsForOptions.set(name, new MutationSubgraph(this)); - } - - return subgraphsForOptions.get(name)!; - } - - getDefaultMutationSubgraph(options?: MutationOptions): MutationSubgraph { - throw new Error("This mutation engine does not provide a default mutation subgraph."); - } - /** - * Retrieve the mutated type from the default mutation subgraph for the given options. + * Gets or creates a mutation node for the given type and key. + * @param type - Source type + * @param mutationKey - Cache key for the node + * @returns Mutation node for the type */ - getMutatedType(options: MutationOptions, sourceType: T): T; - /** - * Retrieve the mutated type from a specific mutation subgraph. - */ - getMutatedType(subgraph: MutationSubgraph, sourceType: T): T; - /** - * Retrieve the mutated type from either the default subgraph with the given - * options or a specific subgraph. - */ - getMutatedType( - subgraphOrOptions: MutationOptions | MutationSubgraph, - sourceType: T, - ): T; - getMutatedType( - subgraphOrOptions: MutationOptions | MutationSubgraph, - sourceType: T, - ) { - if (subgraphOrOptions instanceof MutationOptions) { - return this.getMutationNode(subgraphOrOptions, sourceType).mutatedType; + getMutationNode( + type: T, + options?: MutationNodeOptions | string, + ): MutationNodeForType { + let keyMap = this.#seenMutationNodes.get(type); + const mutationKey = typeof options === "string" ? options : (options?.mutationKey ?? ""); + if (keyMap) { + const existingNode = keyMap.get(mutationKey); + if (existingNode) { + return existingNode as MutationNodeForType; + } + } else { + keyMap = new Map(); + this.#seenMutationNodes.set(type, keyMap); } - return this.getMutationNode(subgraphOrOptions, sourceType).mutatedType; + + const node = mutationNodeFor(this, type, options); + keyMap.set(mutationKey, node); + return node; } /** - * Get (and potentially create) the mutation node for the provided type in the default subgraph. + * Replaces one mutation node with another in the cache. + * @param oldNode - Node to remove + * @param newNode - Node to add */ - getMutationNode(options: MutationOptions, type: T): MutationNodeForType; - /** - * Get (and potentially create) the mutation node for the provided type in a specific subgraph. - */ - getMutationNode(subgraph: MutationSubgraph, type: T): MutationNodeForType; + replaceMutationNode(oldNode: MutationNode, newNode: MutationNode) { + const oldKeyMap = this.#seenMutationNodes.get(oldNode.sourceType); + if (oldKeyMap) { + oldKeyMap.delete(oldNode.mutationKey); + } - /** - * Get (and potentially create) the mutation node for the provided type in - * either the default subgraph with the given options or a specific subgraph. - */ - getMutationNode( - subgraphOrOptions: MutationOptions | MutationSubgraph, - type: T, - ): MutationNodeForType; - getMutationNode(subgraphOrOptions: MutationOptions | MutationSubgraph, type: T) { - let subgraph: MutationSubgraph; - if (subgraphOrOptions instanceof MutationOptions) { - subgraph = this.getDefaultMutationSubgraph(subgraphOrOptions); - } else { - subgraph = subgraphOrOptions; + let newKeyMap = this.#seenMutationNodes.get(newNode.sourceType); + if (!newKeyMap) { + newKeyMap = new Map(); + this.#seenMutationNodes.set(newNode.sourceType, newKeyMap); } - return subgraph.getNode(type); + newKeyMap.set(newNode.mutationKey, newNode); } - mutateType( - subgraphOrOptions: MutationOptions | MutationSubgraph, - type: T, - initializeMutation: (type: T) => void, + /** + * Replaces a reference with a new type and mutates it. + * @param reference - Original reference to replace + * @param newType - New type to use + * @param options - Mutation options + * @param halfEdge - Optional half edge for tracking + * @returns Mutation for the new type + */ + replaceAndMutateReference( + reference: MemberType, + newType: TType, + options: MutationOptions = new MutationOptions(), + halfEdge?: MutationHalfEdge, ) { - const subgraph = this.#getSubgraphFromOptions(subgraphOrOptions); - this.getMutationNode(subgraph, type).mutate(initializeMutation as (type: Type) => void); - } - - #getSubgraphFromOptions(subgraphOrOptions: MutationOptions | MutationSubgraph) { - if (subgraphOrOptions instanceof MutationOptions) { - return this.getDefaultMutationSubgraph(subgraphOrOptions); - } else { - return subgraphOrOptions; - } + const { references } = resolveReference(reference); + const mut = this.mutateWorker(newType, references, options, halfEdge, { + isSynthetic: true, + }); + return mut; } - mutate( + /** + * Internal worker that creates or retrieves mutations with caching. + */ + protected mutateWorker( type: TType, - options: MutationOptions = new MutationOptions(), + references: MemberType[], + options: MutationOptions, + halfEdge?: MutationHalfEdge, + traits?: MutationTraits, ): MutationFor { + // initialize cache if (!this.#mutationCache.has(type)) { this.#mutationCache.set(type, new Map>()); } const byType = this.#mutationCache.get(type)!; - const key = options.cacheKey(); - if (byType.has(key)) { - const existing = byType.get(key)! as any; - if (!existing.isMutated) { - existing.isMutated = true; - existing.mutate(); - } - return existing; - } - - this.#initializeSubgraphs(type, options); - const mutatorClass = this.#mutatorClasses[type.kind]; if (!mutatorClass) { throw new Error("No mutator registered for type kind: " + type.kind); } - // TS doesn't like this abstract class here, but it will be a derivative - // class in practice. - const mutation = new (mutatorClass as any)(this, type, [], options); - - byType.set(key, mutation); - mutation.isMutated = true; - mutation.mutate(); - return mutation; - } - - mutateReference( - memberType: TType, - referencedMutationNode: Type, - options: MutationOptions, - ): MutationFor; - mutateReference( - memberType: TType, - options: MutationOptions, - ): MutationFor; - mutateReference( - memberType: TType, - referencedMutationNodeOrOptions: Type | MutationOptions, - options?: MutationOptions, - ): MutationFor { - let referencedMutationNode: Type | undefined; - let finalOptions: MutationOptions; - if (referencedMutationNodeOrOptions instanceof MutationOptions) { - finalOptions = referencedMutationNodeOrOptions; - referencedMutationNode = undefined; - } else { - referencedMutationNode = referencedMutationNodeOrOptions as Type; - finalOptions = options!; - } - - if (!this.#referenceMutationCache.has(memberType)) { - this.#referenceMutationCache.set( - memberType, - new Map>(), - ); + const info = (mutatorClass as any).mutationInfo( + this, + type, + references, + options, + halfEdge, + traits, + ); + if (info instanceof Mutation) { + // Already a mutation, return it directly. + // Type change mutations break types badly, but hopefully in general things "just work"? + return info as any; } - const byType = this.#referenceMutationCache.get(memberType)!; - const key = finalOptions.cacheKey(); + const key = info.mutationKey; if (byType.has(key)) { const existing = byType.get(key)! as any; + halfEdge?.setTail(existing); if (!existing.isMutated) { existing.isMutated = true; existing.mutate(); @@ -256,39 +228,92 @@ export class MutationEngine { return existing; } - this.#initializeSubgraphs(memberType, finalOptions); - const sources: MemberType[] = []; - - let referencedType: Type = memberType; - while (referencedType.kind === "ModelProperty" || referencedType.kind === "UnionVariant") { - sources.push(referencedType); - referencedType = referencedType.type; - } - - const typeToMutate = referencedMutationNode ?? referencedType; - const mutatorClass = this.#mutatorClasses[typeToMutate.kind]; - if (!mutatorClass) { - throw new Error("No mutator registered for type kind: " + typeToMutate.kind); - } - - const mutation = new (mutatorClass as any)(this, typeToMutate, sources, finalOptions); - + // TS doesn't like this abstract class here, but it will be a derivative + // class in practice. + const mutation = new (mutatorClass as any)(this, type, [], options, info); byType.set(key, mutation); mutation.isMutated = true; + halfEdge?.setTail(mutation); mutation.mutate(); return mutation; } - #initializeSubgraphs(root: Type, options: MutationOptions) { - for (const name of this.#subgraphNames) { - const subgraph = this.getMutationSubgraph(options, name); - subgraph.getNode(root); - } + /** + * Mutates a type using registered mutation classes. + * @param type - Type to mutate + * @param options - Mutation options + * @param halfEdge - Optional half edge for linking mutations to parent mutations + * @returns Mutation for the type + */ + mutate( + type: TType, + options: MutationOptions = new MutationOptions(), + halfEdge?: MutationHalfEdge, + traits?: MutationTraits, + ): MutationFor { + return this.mutateWorker(type, [], options, halfEdge, traits); + } + + /** + * Mutates a type through a reference chain (e.g., ModelProperty or UnionVariant). + * @param reference - Reference to mutate + * @param options - Mutation options + * @param halfEdge - Optional half edge for tracking + * @returns Mutation for the referenced type + */ + mutateReference( + reference: MemberType, + options: MutationOptions = new MutationOptions(), + halfEdge?: MutationHalfEdge, + traits?: MutationTraits, + ): MutationFor { + const { referencedType, references } = resolveReference(reference); + + return this.mutateWorker(referencedType, references, options, halfEdge, traits) as any; + } +} + +function resolveReference(reference: MemberType) { + const references: MemberType[] = []; + let referencedType: Type = reference; + while (referencedType.kind === "ModelProperty" || referencedType.kind === "UnionVariant") { + references.push(referencedType); + referencedType = referencedType.type; } + return { + referencedType, + references, + }; } export class MutationOptions { - cacheKey(): string { + get mutationKey(): string { return ""; } } + +/** + * Half-edge used to link mutations together. This represents the head-end of a + * mutation. When the tail is created, it is set on the half-edge and allows the + * head mutation to connect its nodes to the tail mutation. + */ +export class MutationHalfEdge< + THead extends Mutation = any, + TTail extends Mutation = any, +> { + head: THead; + tail: TTail | undefined; + readonly kind: string; + #onTailCreation: (tail: TTail) => void; + + constructor(kind: string, head: THead, onTailCreation: (tail: TTail) => void) { + this.kind = kind; + this.head = head; + this.#onTailCreation = onTailCreation; + } + + setTail(tail: TTail) { + this.tail = tail; + this.#onTailCreation(tail); + } +} diff --git a/packages/mutator-framework/src/mutation/mutation.ts b/packages/mutator-framework/src/mutation/mutation.ts index 40b77243682..4d77c245ced 100644 --- a/packages/mutator-framework/src/mutation/mutation.ts +++ b/packages/mutator-framework/src/mutation/mutation.ts @@ -1,7 +1,16 @@ import type { MemberType, Type } from "@typespec/compiler"; -import type { MutationNodeForType } from "../mutation-node/factory.js"; -import type { MutationSubgraph } from "../mutation-node/mutation-subgraph.js"; -import type { CustomMutationClasses, MutationEngine, MutationOptions } from "./mutation-engine.js"; +import type { + CustomMutationClasses, + MutationEngine, + MutationHalfEdge, + MutationOptions, + MutationTraits, +} from "./mutation-engine.js"; + +export interface MutationInfo extends Record { + mutationKey: string; + isSynthetic?: boolean; +} export abstract class Mutation< TSourceType extends Type, @@ -13,78 +22,43 @@ export abstract class Mutation< static readonly subgraphNames: string[] = []; - engine: TEngine; + protected engine: TEngine; sourceType: TSourceType; - options: TOptions; + protected options: TOptions; isMutated: boolean = false; - referenceTypes: MemberType[]; + protected referenceTypes: MemberType[]; + protected mutationInfo: MutationInfo; constructor( engine: TEngine, sourceType: TSourceType, referenceTypes: MemberType[], options: TOptions, + info: MutationInfo, ) { this.engine = engine; this.sourceType = sourceType; this.options = options; this.referenceTypes = referenceTypes; + this.mutationInfo = info; } - abstract mutate(): void; - - /** - * Retrieve the mutated type for this mutation's default subgraph. - */ - protected getMutatedType(): TSourceType; - /** - * Retrieve the mutated type for the provided subgraph. - */ - protected getMutatedType(subgraph: MutationSubgraph): TSourceType; - protected getMutatedType(subgraphOrOptions?: MutationSubgraph | MutationOptions) { - return this.engine.getMutatedType(subgraphOrOptions ?? this.options, this.sourceType); - } - - /** - * Retrieve the mutation node for this mutation's default subgraph. - */ - protected getMutationNode(): MutationNodeForType; - /** - * Retrieve the mutation node for the provided subgraph. - */ - protected getMutationNode(subgraph: MutationSubgraph): MutationNodeForType; - /** - * Retrieve the mutation node for either the default subgraph with the given - * options or a specific subgraph. - */ - protected getMutationNode( - subgraphOrOptions: MutationSubgraph | MutationOptions, - ): MutationNodeForType; - protected getMutationNode(subgraphOrOptions?: MutationSubgraph | MutationOptions) { - return this.engine.getMutationNode(subgraphOrOptions ?? this.options, this.sourceType); + get mutationEngine(): TEngine { + return this.engine; } - /** - * Mutate this type in the default subgraph. - */ - protected mutateType(initializeMutation?: (type: TSourceType) => void): void; - /** - * Mutate this type in the given subgraph - */ - protected mutateType( - subgraph: MutationSubgraph, - initializeMutation?: (type: TSourceType) => void, - ): void; - - protected mutateType( - subgraphOrInit?: MutationSubgraph | ((type: TSourceType) => void), - initializeMutation?: (type: TSourceType) => void, - ) { - if (typeof subgraphOrInit === "function") { - initializeMutation = subgraphOrInit; - subgraphOrInit = undefined; - } - const node = this.getMutationNode(subgraphOrInit ?? this.options); - node.mutate(initializeMutation as (type: Type) => void); + static mutationInfo( + engine: MutationEngine, + sourceType: Type, + referenceTypes: MemberType[], + options: MutationOptions, + halfEdge?: MutationHalfEdge, + traits?: MutationTraits, + ): MutationInfo | Mutation { + return { + mutationKey: options.mutationKey, + isSynthetic: traits?.isSynthetic, + }; } + abstract mutate(): void; } diff --git a/packages/mutator-framework/src/mutation/operation.ts b/packages/mutator-framework/src/mutation/operation.ts index 06b102da4ca..9b19bb4b867 100644 --- a/packages/mutator-framework/src/mutation/operation.ts +++ b/packages/mutator-framework/src/mutation/operation.ts @@ -3,11 +3,12 @@ import type { CustomMutationClasses, MutationEngine, MutationFor, + MutationHalfEdge, MutationOptions, } from "./mutation-engine.js"; -import { Mutation } from "./mutation.js"; +import { Mutation, type MutationInfo } from "./mutation.js"; -export class OperationMutation< +export abstract class OperationMutation< TOptions extends MutationOptions, TCustomMutations extends CustomMutationClasses, TEngine extends MutationEngine = MutationEngine, @@ -19,20 +20,32 @@ export class OperationMutation< constructor( engine: TEngine, sourceType: Operation, - referenceTypes: MemberType[] = [], + referenceTypes: MemberType[], options: TOptions, + info: MutationInfo, ) { - super(engine, sourceType, referenceTypes, options); + super(engine, sourceType, referenceTypes, options, info); } protected mutateParameters() { - this.parameters = this.engine.mutate(this.sourceType.parameters, this.options); + this.parameters = this.engine.mutate( + this.sourceType.parameters, + this.options, + this.startParametersEdge(), + ); } protected mutateReturnType() { - this.returnType = this.engine.mutate(this.sourceType.returnType, this.options); + this.returnType = this.engine.mutate( + this.sourceType.returnType, + this.options, + this.startReturnTypeEdge(), + ); } + protected abstract startParametersEdge(): MutationHalfEdge; + protected abstract startReturnTypeEdge(): MutationHalfEdge; + mutate() { this.mutateParameters(); this.mutateReturnType(); diff --git a/packages/mutator-framework/src/mutation/scalar.ts b/packages/mutator-framework/src/mutation/scalar.ts index 01ec5c65499..b129a90be71 100644 --- a/packages/mutator-framework/src/mutation/scalar.ts +++ b/packages/mutator-framework/src/mutation/scalar.ts @@ -3,11 +3,12 @@ import type { CustomMutationClasses, MutationEngine, MutationFor, + MutationHalfEdge, MutationOptions, } from "./mutation-engine.js"; -import { Mutation } from "./mutation.js"; +import { Mutation, type MutationInfo } from "./mutation.js"; -export class ScalarMutation< +export abstract class ScalarMutation< TOptions extends MutationOptions, TCustomMutations extends CustomMutationClasses, TEngine extends MutationEngine = MutationEngine, @@ -18,18 +19,25 @@ export class ScalarMutation< constructor( engine: TEngine, sourceType: Scalar, - referenceTypes: MemberType[] = [], + referenceTypes: MemberType[], options: TOptions, + info: MutationInfo, ) { - super(engine, sourceType, referenceTypes, options); + super(engine, sourceType, referenceTypes, options, info); } protected mutateBaseScalar() { if (this.sourceType.baseScalar) { - this.baseScalar = this.engine.mutate(this.sourceType.baseScalar, this.options); + this.baseScalar = this.engine.mutate( + this.sourceType.baseScalar, + this.options, + this.startBaseScalarEdge(), + ); } } + protected abstract startBaseScalarEdge(): MutationHalfEdge; + mutate() { this.mutateBaseScalar(); } diff --git a/packages/mutator-framework/src/mutation/simple-mutation-engine.test.ts b/packages/mutator-framework/src/mutation/simple-mutation-engine.test.ts new file mode 100644 index 00000000000..46d40908f18 --- /dev/null +++ b/packages/mutator-framework/src/mutation/simple-mutation-engine.test.ts @@ -0,0 +1,330 @@ +import type { MemberType, Model, Union } from "@typespec/compiler"; +import { expectTypeEquals, t } from "@typespec/compiler/testing"; +import { $ } from "@typespec/compiler/typekit"; +import { 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 { + SimpleIntrinsicMutation, + SimpleModelMutation, + SimpleModelPropertyMutation, + SimpleMutationEngine, + SimpleMutationOptions, + SimpleScalarMutation, + SimpleUnionMutation, + type SimpleMutationOptionsInit, +} from "./simple-mutation-engine.js"; + +interface RenameMutationOptionsInit extends SimpleMutationOptionsInit { + suffix: string; +} + +class RenameMutationOptions extends SimpleMutationOptions { + suffix: string; + + constructor(options: RenameMutationOptionsInit) { + super(options); + this.suffix = options.suffix; + } + + get mutationKey() { + return `${this.suffix}`; + } + + with(options: Partial) { + return new RenameMutationOptions({ + suffix: options.suffix ?? this.suffix, + }); + } +} + +class RenameModelMutation extends SimpleModelMutation { + mutate() { + if ("name" in this.sourceType && typeof this.sourceType.name === "string") { + this.mutationNode.mutate( + (type) => (type.name = `${this.sourceType.name}${this.options.suffix}`), + ); + } + super.mutate(); + } +} + +it("creates model and model property mutations", async () => { + const runner = await Tester.createInstance(); + const { Foo, program } = await runner.compile(t.code` + model ${t.model("Foo")} { + ${t.modelProperty("prop")}: Bar; + } + + model ${t.model("Bar")} { + prop: string; + } + `); + + const tk = $(program); + const engine = new SimpleMutationEngine<{ Model: RenameModelMutation }>(tk, { + Model: RenameModelMutation, + }); + + const options = new RenameMutationOptions({ suffix: "Suf" }); + const fooMutation = engine.mutate(Foo, options); + + expect(fooMutation.mutatedType.name).toBe("FooSuf"); + expect(fooMutation.properties.size).toBe(1); + + const propMutation = fooMutation.properties.get("prop")!; + expect(propMutation.mutatedType.model!.name).toBe("FooSuf"); + expect((propMutation.mutatedType.type as Model).name).toBe("BarSuf"); +}); + +it("attaches to existing mutations", async () => { + const runner = await Tester.createInstance(); + const { Foo, Bar, program } = await runner.compile(t.code` + model ${t.model("Foo")} { + ${t.modelProperty("prop")}: Bar; + } + + model ${t.model("Bar")} { + prop: string; + } + `); + + const tk = $(program); + const engine = new SimpleMutationEngine<{ Model: RenameModelMutation }>(tk, { + Model: RenameModelMutation, + }); + + const barMutation = engine.mutate(Bar, new RenameMutationOptions({ suffix: "X" })); + const fooMutation = engine.mutate(Foo, new RenameMutationOptions({ suffix: "X" })); + + expect(fooMutation.properties.get("prop")!.type === barMutation).toBe(true); + expectTypeEquals(fooMutation.properties.get("prop")!.mutatedType.type, barMutation.mutatedType); +}); + +class RenameModelBasedOnReferenceMutation extends SimpleModelMutation { + static mutationInfo( + engine: SimpleMutationEngine<{ Model: RenameModelBasedOnReferenceMutation }>, + sourceType: Model, + referenceTypes: MemberType[], + options: SimpleMutationOptions, + halfEdge?: MutationHalfEdge, + traits?: MutationTraits, + ): MutationInfo { + if (referenceTypes.length === 0) { + return { + mutationKey: options.mutationKey + "-no-ref", + hasReference: false, + isSynthetic: traits?.isSynthetic, + }; + } + return { + mutationKey: options.mutationKey + "-has-ref", + hasReference: true, + isSynthetic: traits?.isSynthetic, + }; + } + + mutate() { + if ( + "name" in this.sourceType && + typeof this.sourceType.name === "string" && + this.mutationInfo.hasReference + ) { + this.mutationNode.mutate((type) => (type.name = `${this.sourceType.name}Reference`)); + } + super.mutate(); + } +} + +it("plumbs mutation info", async () => { + const runner = await Tester.createInstance(); + const { Foo, program } = await runner.compile(t.code` + model ${t.model("Foo")} { + ${t.modelProperty("prop")}: Bar; + } + + model ${t.model("Bar")} { + barProp: string; + } + `); + + const tk = $(program); + const engine = new SimpleMutationEngine<{ Model: RenameModelBasedOnReferenceMutation }>(tk, { + Model: RenameModelBasedOnReferenceMutation, + }); + + const fooMutation = engine.mutate(Foo); + const propMutation = fooMutation.properties.get("prop")!; + const barRefMutation = propMutation.type as RenameModelBasedOnReferenceMutation; + const barRefPropMutation = barRefMutation.properties.get("barProp")!; + + expect(fooMutation.mutatedType.name).toBe("Foo"); + expect((propMutation.mutatedType.type as Model).name).toBe("BarReference"); + expect(barRefMutation.mutatedType.name).toBe("BarReference"); + expect(barRefPropMutation.mutatedType.name).toBe("barProp"); + expect(barRefPropMutation.mutatedType.model!.name).toBe("BarReference"); +}); + +interface UnionifyMutations { + ModelProperty: UnionifyProperty; +} + +class UnionifyProperty extends SimpleModelPropertyMutation { + mutate() { + if (!this.engine.$.union.is(this.sourceType.type)) { + // turn it into this union: + const newUnionType = this.engine.$.union.create({ + name: "DynamicUnion", + variants: [ + this.engine.$.unionVariant.create({ type: this.sourceType.type }), + this.engine.$.unionVariant.create({ + type: this.engine.$.builtin.string, + }), + ], + }); + + this.mutationNode.mutate((prop) => { + prop.type = newUnionType; + }); + + this.type = this.engine.replaceAndMutateReference( + this.sourceType, + newUnionType, + this.options, + this.startTypeEdge(), + ); + } else { + super.mutate(); + } + } +} + +it("allows replacing types", async () => { + const runner = await Tester.createInstance(); + const { Foo, program } = await runner.compile(t.code` + model ${t.model("Foo")} { + ${t.modelProperty("prop")}: int32; + } + `); + + const tk = $(program); + const engine = new SimpleMutationEngine(tk, { + ModelProperty: UnionifyProperty, + }); + + const fooMutation = engine.mutate(Foo); + const propMutation = fooMutation.properties.get("prop")!; + expect(propMutation.mutatedType.type.kind).toBe("Union"); + + const unionNode = propMutation.type as SimpleUnionMutation; + expect(unionNode.kind).toBe("Union"); + expect(unionNode.variants.size).toBe(2); + const variants = [...unionNode.variants.values()]; + + expect(variants[0].type.kind).toBe("Scalar"); + expectTypeEquals( + (variants[0].type as SimpleScalarMutation).mutatedType, + tk.builtin.int32, + ); + + expect(variants[1].type.kind).toBe("Scalar"); + expectTypeEquals( + (variants[1].type as SimpleScalarMutation).mutatedType, + tk.builtin.string, + ); +}); + +const nullableUnionCache = new WeakMap(); + +class NullableReferencedModelMutation extends SimpleModelMutation { + static mutationInfo( + engine: SimpleMutationEngine<{ Model: NullableReferencedModelMutation }>, + sourceType: Model, + referenceTypes: MemberType[], + options: SimpleMutationOptions, + halfEdge?: MutationHalfEdge, + traits?: MutationTraits, + ) { + if (referenceTypes.length > 0 && referenceTypes[0].kind === "ModelProperty") { + let nullableUnion = nullableUnionCache.get(sourceType); + if (!nullableUnion) { + nullableUnion = engine.$.union.create({ + name: `${sourceType.name ?? "Anonymous"}NullableUnion`, + variants: [ + engine.$.unionVariant.create({ name: "Value", type: sourceType }), + engine.$.unionVariant.create({ name: "Null", type: engine.$.intrinsic.null }), + ], + }); + nullableUnionCache.set(sourceType, nullableUnion); + } + + return engine.replaceAndMutateReference(referenceTypes[0], nullableUnion, options, halfEdge); + } + + return super.mutationInfo(engine, sourceType, referenceTypes, options, halfEdge, traits); + } +} + +it( + "substitutes referenced models with nullable unions", + async () => { + const runner = await Tester.createInstance(); + const { Foo, Bar, program } = await runner.compile(t.code` + model ${t.model("Bar")} { + value: string; + } + + model ${t.model("Foo")} { + prop: Bar; + } + `); + + const tk = $(program); + const engine = new SimpleMutationEngine<{ Model: NullableReferencedModelMutation }>(tk, { + Model: NullableReferencedModelMutation, + }); + + const barMutation = engine.mutate(Bar); + + expect(barMutation.kind).toBe("Model"); + expect(barMutation.mutatedType === Bar).toBe(true); + + const fooMutation = engine.mutate(Foo); + + expect(fooMutation.kind).toBe("Model"); + + const propMutation = fooMutation.properties.get("prop")!; + const nullableBarUnionMutation = + propMutation.type as SimpleUnionMutation; + + expect(nullableBarUnionMutation.kind).toBe("Union"); + const unionVariants = [...nullableBarUnionMutation.variants.values()]; + expect(unionVariants).toHaveLength(2); + + const modelVariant = unionVariants[0]; + expect(modelVariant.type.kind).toBe("Model"); + + const modelVariantMutation = modelVariant.type as SimpleModelMutation; + expect(modelVariantMutation.mutatedType === Bar).toBe(true); + + const nullVariant = unionVariants[1]; + expect(nullVariant.type.kind).toBe("Intrinsic"); + + const nullMutation = nullVariant.type as SimpleIntrinsicMutation; + expect(nullMutation.mutatedType === tk.intrinsic.null).toBe(true); + + const nullableBarUnderlyingType = propMutation.mutatedType.type as Union; + expect(nullableBarUnderlyingType.kind).toBe("Union"); + expect(nullableBarUnderlyingType.variants.size).toBe(2); + expect(nullableBarUnderlyingType === nullableBarUnionMutation.mutatedType).toBe(true); + + const [valueUnionVariant, nullUnionVariant] = [...nullableBarUnderlyingType.variants.values()]; + expect(valueUnionVariant.name).toBe("Value"); + expect(valueUnionVariant.type.kind).toBe("Model"); + expect((valueUnionVariant.type as Model).name).toBe("Bar"); + expect(nullUnionVariant.name).toBe("Null"); + expect(nullUnionVariant.type === tk.intrinsic.null).toBe(true); + }, + Infinity, +); diff --git a/packages/mutator-framework/src/mutation/simple-mutation-engine.ts b/packages/mutator-framework/src/mutation/simple-mutation-engine.ts index 9ed19bdc51e..9162171c7bb 100644 --- a/packages/mutator-framework/src/mutation/simple-mutation-engine.ts +++ b/packages/mutator-framework/src/mutation/simple-mutation-engine.ts @@ -1,21 +1,473 @@ +import type { + BooleanLiteral, + Interface, + IntrinsicType, + MemberType, + Model, + ModelProperty, + NumericLiteral, + Operation, + Scalar, + StringLiteral, + Type, + Union, + UnionVariant, +} from "@typespec/compiler"; import type { Typekit } from "@typespec/compiler/typekit"; -import type { MutationSubgraph } from "../mutation-node/mutation-subgraph.js"; +import { type MutationNodeForType } from "../mutation-node/factory.js"; +import type { InterfaceMutationNode } from "../mutation-node/interface.js"; +import type { IntrinsicMutationNode } from "../mutation-node/intrinsic.js"; +import type { LiteralMutationNode } from "../mutation-node/literal.js"; +import type { ModelPropertyMutationNode } from "../mutation-node/model-property.js"; +import type { ModelMutationNode } from "../mutation-node/model.js"; +import type { HalfEdge } from "../mutation-node/mutation-edge.js"; +import type { OperationMutationNode } from "../mutation-node/operation.js"; +import type { ScalarMutationNode } from "../mutation-node/scalar.js"; +import type { UnionVariantMutationNode } from "../mutation-node/union-variant.js"; +import type { UnionMutationNode } from "../mutation-node/union.js"; +import { InterfaceMutation } from "./interface.js"; +import { IntrinsicMutation } from "./intrinsic.js"; +import { LiteralMutation } from "./literal.js"; +import { ModelPropertyMutation } from "./model-property.js"; +import { ModelMutation } from "./model.js"; import { type ConstructorsFor, type CustomMutationClasses, MutationEngine, + MutationHalfEdge, MutationOptions, } from "./mutation-engine.js"; +import type { MutationInfo } from "./mutation.js"; +import { OperationMutation } from "./operation.js"; +import { ScalarMutation } from "./scalar.js"; +import { UnionVariantMutation } from "./union-variant.js"; +import { UnionMutation } from "./union.js"; +export interface SimpleMutations { + Operation: SimpleOperationMutation; + Interface: SimpleInterfaceMutation; + Model: SimpleModelMutation; + Scalar: SimpleScalarMutation; + ModelProperty: SimpleModelPropertyMutation; + Union: SimpleUnionMutation; + UnionVariant: SimpleUnionVariantMutation; + String: SimpleLiteralMutation; + Number: SimpleLiteralMutation; + Boolean: SimpleLiteralMutation; + Intrinsic: SimpleIntrinsicMutation; +} + +export type SimpleMutation = SimpleMutations[keyof SimpleMutations]; + +export interface SimpleMutationOptionsInit { + referenceEdge?: HalfEdge; +} + +export class SimpleMutationOptions extends MutationOptions { + constructor(init?: SimpleMutationOptionsInit) { + super(); + } +} + +/** + * The simple mutation engine and it's associated mutation classes allow for + * creating a mutated node for types in the type graph. + */ export class SimpleMutationEngine< TCustomMutations extends CustomMutationClasses, > extends MutationEngine { constructor($: Typekit, mutatorClasses: ConstructorsFor) { - super($, mutatorClasses); - this.registerSubgraph("subgraph"); + const defaultedMutatorClasses = { + Operation: mutatorClasses.Operation ?? SimpleOperationMutation, + Interface: mutatorClasses.Interface ?? SimpleInterfaceMutation, + Model: mutatorClasses.Model ?? SimpleModelMutation, + Scalar: mutatorClasses.Scalar ?? SimpleScalarMutation, + ModelProperty: mutatorClasses.ModelProperty ?? SimpleModelPropertyMutation, + Union: mutatorClasses.Union ?? SimpleUnionMutation, + UnionVariant: mutatorClasses.UnionVariant ?? SimpleUnionVariantMutation, + String: mutatorClasses.String ?? SimpleLiteralMutation, + Number: mutatorClasses.Number ?? SimpleLiteralMutation, + Boolean: mutatorClasses.Boolean ?? SimpleLiteralMutation, + Intrinsic: mutatorClasses.Intrinsic ?? SimpleIntrinsicMutation, + } as any; + super($, defaultedMutatorClasses); + } + + mutate( + type: TType, + options: MutationOptions = new SimpleMutationOptions(), + halfEdge?: MutationHalfEdge, + ) { + return super.mutate(type, options, halfEdge); + } + + mutateReference( + reference: MemberType, + options: MutationOptions = new SimpleMutationOptions(), + halfEdge?: MutationHalfEdge, + ) { + return super.mutateReference(reference, options, halfEdge); + } +} + +export interface SingleMutationNode { + mutationNode: MutationNodeForType; + mutatedType: T; +} + +export class SimpleModelMutation + extends ModelMutation< + SimpleMutations, + TOptions, + SimpleMutationEngine> + > + implements SingleMutationNode +{ + constructor( + engine: SimpleMutationEngine>, + sourceType: Model, + referenceTypes: MemberType[], + options: TOptions, + info: MutationInfo, + ) { + super(engine, sourceType, referenceTypes, options, info); + this.#mutationNode = this.engine.getMutationNode(this.sourceType, { + mutationKey: info.mutationKey, + isSynthetic: info.isSynthetic, + }); + } + + startBaseEdge() { + return this.#createHalfEdge("base", (tail) => + this.#mutationNode.connectBase(tail.mutationNode as ModelMutationNode), + ); + } + + startPropertyEdge() { + return this.#createHalfEdge("property", (tail) => + this.#mutationNode.connectProperty(tail.mutationNode as ModelPropertyMutationNode), + ); + } + + startIndexerKeyEdge() { + return this.#createHalfEdge("indexerKey", (tail) => + this.#mutationNode.connectIndexerKey(tail.mutationNode as ScalarMutationNode), + ); + } + + startIndexerValueEdge() { + return this.#createHalfEdge("indexerValue", (tail) => + this.#mutationNode.connectIndexerValue(tail.mutationNode as MutationNodeForType), + ); + } + + #createHalfEdge( + kind: string, + cb: (tail: SimpleMutation) => void, + ): MutationHalfEdge, SimpleMutation> { + return new MutationHalfEdge(kind, this, cb); + } + + #mutationNode: ModelMutationNode; + get mutationNode() { + return this.#mutationNode; + } + + get mutatedType() { + return this.#mutationNode.mutatedType; + } +} + +export class SimpleModelPropertyMutation + extends ModelPropertyMutation< + SimpleMutations, + TOptions, + SimpleMutationEngine> + > + implements SingleMutationNode +{ + constructor( + engine: SimpleMutationEngine>, + sourceType: ModelProperty, + referenceTypes: MemberType[], + options: TOptions, + info: MutationInfo, + ) { + super(engine, sourceType, referenceTypes, options, info); + this.#mutationNode = this.engine.getMutationNode(this.sourceType, { + mutationKey: info.mutationKey, + isSynthetic: info.isSynthetic, + }); + } + + startTypeEdge(): MutationHalfEdge { + return new MutationHalfEdge("type", this, (tail) => { + this.#mutationNode.connectType(tail.mutationNode as MutationNodeForType); + }); + } + + #mutationNode: ModelPropertyMutationNode; + get mutationNode() { + return this.#mutationNode; + } + + get mutatedType() { + return this.#mutationNode.mutatedType; + } +} + +export class SimpleUnionMutation + extends UnionMutation< + TOptions, + SimpleMutations, + SimpleMutationEngine> + > + implements SingleMutationNode +{ + constructor( + engine: SimpleMutationEngine>, + sourceType: Union, + referenceTypes: MemberType[], + options: TOptions, + info: MutationInfo, + ) { + super(engine, sourceType, referenceTypes, options, info); + this.#mutationNode = this.engine.getMutationNode(this.sourceType, { + mutationKey: info.mutationKey, + isSynthetic: info.isSynthetic, + }); + } + + protected startVariantEdge(): MutationHalfEdge { + return new MutationHalfEdge("variant", this, (tail) => { + this.#mutationNode.connectVariant(tail.mutationNode as UnionVariantMutationNode); + }); + } + + #mutationNode: UnionMutationNode; + get mutationNode() { + return this.#mutationNode; + } + + get mutatedType() { + return this.#mutationNode.mutatedType; + } +} + +export class SimpleUnionVariantMutation + extends UnionVariantMutation< + TOptions, + SimpleMutations, + SimpleMutationEngine> + > + implements SingleMutationNode +{ + constructor( + engine: SimpleMutationEngine>, + sourceType: UnionVariant, + referenceTypes: MemberType[], + options: TOptions, + info: MutationInfo, + ) { + super(engine, sourceType, referenceTypes, options, info); + this.#mutationNode = this.engine.getMutationNode(this.sourceType, { + mutationKey: info.mutationKey, + isSynthetic: info.isSynthetic, + }); + } + + protected startTypeEdge(): MutationHalfEdge { + return new MutationHalfEdge("type", this, (tail) => { + this.#mutationNode.connectType(tail.mutationNode as MutationNodeForType); + }); + } + + #mutationNode: UnionVariantMutationNode; + get mutationNode() { + return this.#mutationNode; + } + + get mutatedType() { + return this.#mutationNode.mutatedType; + } +} + +export class SimpleOperationMutation + extends OperationMutation< + TOptions, + SimpleMutations, + SimpleMutationEngine> + > + implements SingleMutationNode +{ + constructor( + engine: SimpleMutationEngine>, + sourceType: Operation, + referenceTypes: MemberType[], + options: TOptions, + info: MutationInfo, + ) { + super(engine, sourceType, referenceTypes, options, info); + this.#mutationNode = this.engine.getMutationNode(this.sourceType, { + mutationKey: info.mutationKey, + isSynthetic: info.isSynthetic, + }); + } + + protected startParametersEdge(): MutationHalfEdge { + return new MutationHalfEdge("parameters", this, (tail) => { + this.#mutationNode.connectParameters(tail.mutationNode as ModelMutationNode); + }); + } + + protected startReturnTypeEdge(): MutationHalfEdge { + return new MutationHalfEdge("returnType", this, (tail) => { + this.#mutationNode.connectReturnType(tail.mutationNode as MutationNodeForType); + }); + } + + #mutationNode: OperationMutationNode; + get mutationNode() { + return this.#mutationNode; + } + + get mutatedType() { + return this.#mutationNode.mutatedType; + } +} + +export class SimpleInterfaceMutation + extends InterfaceMutation> + implements SingleMutationNode +{ + constructor( + engine: SimpleMutationEngine>, + sourceType: Interface, + referenceTypes: MemberType[], + options: TOptions, + info: MutationInfo, + ) { + super(engine, sourceType, referenceTypes, options, info); + this.#mutationNode = this.engine.getMutationNode(this.sourceType, { + mutationKey: info.mutationKey, + isSynthetic: info.isSynthetic, + }); + } + + protected startOperationEdge(): MutationHalfEdge { + return new MutationHalfEdge("operation", this, (tail) => { + this.#mutationNode.connectOperation(tail.mutationNode as OperationMutationNode); + }); + } + + #mutationNode: InterfaceMutationNode; + get mutationNode() { + return this.#mutationNode; + } + + get mutatedType() { + return this.#mutationNode.mutatedType; + } +} + +export class SimpleScalarMutation + extends ScalarMutation< + TOptions, + SimpleMutations, + SimpleMutationEngine> + > + implements SingleMutationNode +{ + constructor( + engine: SimpleMutationEngine>, + sourceType: Scalar, + referenceTypes: MemberType[], + options: TOptions, + info: MutationInfo, + ) { + super(engine, sourceType, referenceTypes, options, info); + this.#mutationNode = this.engine.getMutationNode(this.sourceType, { + mutationKey: info.mutationKey, + isSynthetic: info.isSynthetic, + }); + } + + protected startBaseScalarEdge(): MutationHalfEdge { + return new MutationHalfEdge("base", this, (tail) => { + this.#mutationNode.connectBaseScalar(tail.mutationNode as ScalarMutationNode); + }); + } + + #mutationNode: ScalarMutationNode; + get mutationNode() { + return this.#mutationNode; + } + + get mutatedType() { + return this.#mutationNode.mutatedType; + } +} + +export class SimpleLiteralMutation + extends LiteralMutation< + TOptions, + SimpleMutations, + SimpleMutationEngine> + > + implements SingleMutationNode +{ + constructor( + engine: SimpleMutationEngine>, + sourceType: StringLiteral | NumericLiteral | BooleanLiteral, + referenceTypes: MemberType[], + options: TOptions, + info: MutationInfo, + ) { + super(engine, sourceType, referenceTypes, options, info); + this.#mutationNode = this.engine.getMutationNode(this.sourceType, { + mutationKey: info.mutationKey, + isSynthetic: info.isSynthetic, + }); + } + + #mutationNode: LiteralMutationNode; + get mutationNode() { + return this.#mutationNode; + } + + get mutatedType() { + return this.#mutationNode.mutatedType; + } +} + +export class SimpleIntrinsicMutation + extends IntrinsicMutation< + TOptions, + SimpleMutations, + SimpleMutationEngine> + > + implements SingleMutationNode +{ + constructor( + engine: SimpleMutationEngine>, + sourceType: IntrinsicType, + referenceTypes: MemberType[], + options: TOptions, + info: MutationInfo, + ) { + super(engine, sourceType, referenceTypes, options, info); + this.#mutationNode = this.engine.getMutationNode(this.sourceType, { + mutationKey: info.mutationKey, + isSynthetic: info.isSynthetic, + }); + } + + #mutationNode: IntrinsicMutationNode; + get mutationNode() { + return this.#mutationNode; } - getDefaultMutationSubgraph(options: MutationOptions): MutationSubgraph { - return super.getMutationSubgraph(options, "subgraph"); + get mutatedType() { + return this.#mutationNode.mutatedType; } } diff --git a/packages/mutator-framework/src/mutation/union-variant.ts b/packages/mutator-framework/src/mutation/union-variant.ts index e542d9aad14..98b21a780d2 100644 --- a/packages/mutator-framework/src/mutation/union-variant.ts +++ b/packages/mutator-framework/src/mutation/union-variant.ts @@ -3,11 +3,12 @@ import type { CustomMutationClasses, MutationEngine, MutationFor, + MutationHalfEdge, MutationOptions, } from "./mutation-engine.js"; -import { Mutation } from "./mutation.js"; +import { Mutation, type MutationInfo } from "./mutation.js"; -export class UnionVariantMutation< +export abstract class UnionVariantMutation< TOptions extends MutationOptions, TCustomMutations extends CustomMutationClasses, TEngine extends MutationEngine = MutationEngine, @@ -18,13 +19,16 @@ export class UnionVariantMutation< constructor( engine: TEngine, sourceType: UnionVariant, - referenceTypes: MemberType[] = [], + referenceTypes: MemberType[], options: TOptions, + info: MutationInfo, ) { - super(engine, sourceType, referenceTypes, options); + super(engine, sourceType, referenceTypes, options, info); } mutate(): void { - this.type = this.engine.mutate(this.sourceType.type, this.options); + this.type = this.engine.mutateReference(this.sourceType, this.options, this.startTypeEdge()); } + + protected abstract startTypeEdge(): MutationHalfEdge; } diff --git a/packages/mutator-framework/src/mutation/union.ts b/packages/mutator-framework/src/mutation/union.ts index 2a79d29cd5b..e7bc16c832f 100644 --- a/packages/mutator-framework/src/mutation/union.ts +++ b/packages/mutator-framework/src/mutation/union.ts @@ -3,11 +3,12 @@ import type { CustomMutationClasses, MutationEngine, MutationFor, + MutationHalfEdge, MutationOptions, } from "./mutation-engine.js"; -import { Mutation } from "./mutation.js"; +import { Mutation, type MutationInfo } from "./mutation.js"; -export class UnionMutation< +export abstract class UnionMutation< TOptions extends MutationOptions, TCustomMutations extends CustomMutationClasses, TEngine extends MutationEngine = MutationEngine, @@ -20,19 +21,23 @@ export class UnionMutation< sourceType: Union, referenceTypes: MemberType[] = [], options: TOptions, + info: MutationInfo, ) { - super(engine, sourceType, referenceTypes, options); + super(engine, sourceType, referenceTypes, options, info); } protected mutateVariants() { - this.variants = new Map( - [...this.sourceType.variants].map(([name, variant]) => [ - name, - this.engine.mutate(variant, this.options), - ]), - ); + const variants = [...this.sourceType.variants.values()]; + for (const variant of variants) { + this.variants.set( + variant.name, + this.engine.mutate(variant, this.options, this.startVariantEdge()), + ); + } } + protected abstract startVariantEdge(): MutationHalfEdge; + mutate() { this.mutateVariants(); } diff --git a/packages/mutator-framework/test/utils.ts b/packages/mutator-framework/test/utils.ts index 1feba3fd7ff..5e8f9780555 100644 --- a/packages/mutator-framework/test/utils.ts +++ b/packages/mutator-framework/test/utils.ts @@ -1,9 +1,8 @@ import type { Program } from "@typespec/compiler"; import { $ } from "@typespec/compiler/typekit"; -import { MutationEngine, MutationSubgraph } from "../src/index.js"; +import { MutationEngine } from "../src/index.js"; -export function getSubgraph(program: Program) { +export function getEngine(program: Program) { const tk = $(program); - const engine = new MutationEngine(tk, {}); - return new MutationSubgraph(engine); + return new MutationEngine(tk, {}); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d38e971f4e6..6b7f102b7ce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -660,6 +660,9 @@ importers: '@typespec/compiler': specifier: workspace:^ version: link:../compiler + '@typespec/emitter-framework': + specifier: workspace:^ + version: link:../emitter-framework '@typespec/http': specifier: workspace:^ version: link:../http @@ -4439,89 +4442,105 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -5554,24 +5573,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@reflink/reflink-linux-arm64-musl@0.1.19': resolution: {integrity: sha512-37iO/Dp6m5DDaC2sf3zPtx/hl9FV3Xze4xoYidrxxS9bgP3S8ALroxRK6xBG/1TtfXKTvolvp+IjrUU6ujIGmA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@reflink/reflink-linux-x64-gnu@0.1.19': resolution: {integrity: sha512-jbI8jvuYCaA3MVUdu8vLoLAFqC+iNMpiSuLbxlAgg7x3K5bsS8nOpTRnkLF7vISJ+rVR8W+7ThXlXlUQ93ulkw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@reflink/reflink-linux-x64-musl@0.1.19': resolution: {integrity: sha512-e9FBWDe+lv7QKAwtKOt6A2W/fyy/aEEfr0g6j/hWzvQcrzHCsz07BNQYlNOjTfeytrtLU7k449H1PI95jA4OjQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@reflink/reflink-win32-arm64-msvc@0.1.19': resolution: {integrity: sha512-09PxnVIQcd+UOn4WAW73WU6PXL7DwGS6wPlkMhMg2zlHHG65F3vHepOw06HFCq+N42qkaNAc8AKIabWvtk6cIQ==} @@ -5664,61 +5687,73 @@ packages: resolution: {integrity: sha512-OVSQgEZDVLnTbMq5NBs6xkmz3AADByCWI4RdKSFNlDsYXdFtlxS59J+w+LippJe8KcmeSSM3ba+GlsM9+WwC1w==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.49.0': resolution: {integrity: sha512-ZnfSFA7fDUHNa4P3VwAcfaBLakCbYaxCk0jUnS3dTou9P95kwoOLAMlT3WmEJDBCSrOEFFV0Y1HXiwfLYJuLlA==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.49.0': resolution: {integrity: sha512-Z81u+gfrobVK2iV7GqZCBfEB1y6+I61AH466lNK+xy1jfqFLiQ9Qv716WUM5fxFrYxwC7ziVdZRU9qvGHkYIJg==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.49.0': resolution: {integrity: sha512-zoAwS0KCXSnTp9NH/h9aamBAIve0DXeYpll85shf9NJ0URjSTzzS+Z9evmolN+ICfD3v8skKUPyk2PO0uGdFqg==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loongarch64-gnu@4.49.0': resolution: {integrity: sha512-2QyUyQQ1ZtwZGiq0nvODL+vLJBtciItC3/5cYN8ncDQcv5avrt2MbKt1XU/vFAJlLta5KujqyHdYtdag4YEjYQ==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-gnu@4.49.0': resolution: {integrity: sha512-k9aEmOWt+mrMuD3skjVJSSxHckJp+SiFzFG+v8JLXbc/xi9hv2icSkR3U7uQzqy+/QbbYY7iNB9eDTwrELo14g==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.49.0': resolution: {integrity: sha512-rDKRFFIWJ/zJn6uk2IdYLc09Z7zkE5IFIOWqpuU0o6ZpHcdniAyWkwSUWE/Z25N/wNDmFHHMzin84qW7Wzkjsw==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.49.0': resolution: {integrity: sha512-FkkhIY/hYFVnOzz1WeV3S9Bd1h0hda/gRqvZCMpHWDHdiIHn6pqsY3b5eSbvGccWHMQ1uUzgZTKS4oGpykf8Tw==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.49.0': resolution: {integrity: sha512-gRf5c+A7QiOG3UwLyOOtyJMD31JJhMjBvpfhAitPAoqZFcOeK3Kc1Veg1z/trmt+2P6F/biT02fU19GGTS529A==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.40.0': resolution: {integrity: sha512-RcDGMtqF9EFN8i2RYN2W+64CdHruJ5rPqrlYw+cgM3uOVPSsnAQps7cpjXe9be/yDp8UC7VLoCoKC8J3Kn2FkQ==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.49.0': resolution: {integrity: sha512-BR7+blScdLW1h/2hB/2oXM+dhTmpW3rQt1DeSiCP9mc2NMMkqVgjIN3DDsNpKmezffGC9R8XKVOLmBkRUcK/sA==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.49.0': resolution: {integrity: sha512-hDMOAe+6nX3V5ei1I7Au3wcr9h3ktKzDvF2ne5ovX8RZiAHEtX1A5SNNk4zt1Qt77CmnbqT+upb/umzoPMWiPg==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-win32-arm64-msvc@4.49.0': resolution: {integrity: sha512-wkNRzfiIGaElC9kXUT+HLx17z7D0jl+9tGYRKwd8r7cUqTL7GYAvgUY++U2hK6Ar7z5Z6IRRoWC8kQxpmM7TDA==} @@ -19552,7 +19587,7 @@ snapshots: algoliasearch: 4.25.3 clipanion: 4.0.0-rc.4(typanion@3.14.0) diff: 5.2.0 - ink: 3.2.0(@types/react@19.2.6)(react@17.0.2) + ink: 3.2.0(@types/react@19.2.6)(react@18.3.1) ink-text-input: 4.0.3(ink@3.2.0(@types/react@19.2.6)(react@17.0.2))(react@17.0.2) react: 17.0.2 semver: 7.7.3 @@ -19702,7 +19737,7 @@ snapshots: '@yarnpkg/plugin-git': 3.1.3(@yarnpkg/core@4.5.0(typanion@3.14.0))(typanion@3.14.0) clipanion: 4.0.0-rc.4(typanion@3.14.0) es-toolkit: 1.42.0 - ink: 3.2.0(@types/react@19.2.6)(react@18.3.1) + ink: 3.2.0(@types/react@19.2.6)(react@17.0.2) react: 17.0.2 semver: 7.7.3 tslib: 2.8.1