From 4d55dc872eab7ca87f2badc7311eb5be6e2857ed Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Thu, 15 Oct 2026 11:59:50 -0400 Subject: [PATCH 1/3] docs(mutator-framework): explain graph lifecycle and callbacks --- .../src/mutation-node/factory.ts | 6 ++ .../src/mutation-node/mutation-edge.ts | 55 ++++++++++++++- .../src/mutation-node/mutation-node.ts | 69 ++++++++++++++----- 3 files changed, 109 insertions(+), 21 deletions(-) diff --git a/packages/mutator-framework/src/mutation-node/factory.ts b/packages/mutator-framework/src/mutation-node/factory.ts index aa99c39aa0f..684738d30ea 100644 --- a/packages/mutator-framework/src/mutation-node/factory.ts +++ b/packages/mutator-framework/src/mutation-node/factory.ts @@ -30,6 +30,11 @@ import { TupleMutationNode } from "./tuple.js"; import { UnionVariantMutationNode } from "./union-variant.js"; import { UnionMutationNode } from "./union.js"; +/** + * Constructs a fresh node selected by the source type's kind, without caching. + * Use {@link MutationEngine.getMutationNode} to share nodes within an engine. + * Throws for unsupported kinds; creating a node does not traverse its references. + */ export function mutationNodeFor( engine: MutationEngine, sourceType: T, @@ -72,6 +77,7 @@ export function mutationNodeFor( } } +/** Maps supported compiler type shapes to their corresponding concrete node classes. */ export type MutationNodeForType = T extends Model ? ModelMutationNode : T extends Interface diff --git a/packages/mutator-framework/src/mutation-node/mutation-edge.ts b/packages/mutator-framework/src/mutation-node/mutation-edge.ts index 64866e61978..d78fb5f71c6 100644 --- a/packages/mutator-framework/src/mutation-node/mutation-edge.ts +++ b/packages/mutator-framework/src/mutation-node/mutation-edge.ts @@ -3,10 +3,23 @@ import type { MutationNodeForType } from "./factory.js"; import { MutationNode } from "./mutation-node.js"; import { traceEdge } from "./tracer.js"; +/** Callbacks implementing how a head's type reference follows a tail's lifecycle. */ export interface MutationEdgeOptions { - onTailCreation?: (args: { tail: MutationNodeForType }) => void; - onTailMutation: (args: { tail: MutationNodeForType }) => void; - onTailDeletion: (args: { tail: MutationNodeForType }) => void; + /** Called by {@link HalfEdge.setTail} before constructing the full edge. */ + onTailCreation?: (args: { + /** Newly supplied dependency, available for establishing reverse connections. */ + tail: MutationNodeForType; + }) => void; + /** Updates the head's reference when the tail changes or is already mutated/synthetic at connection. */ + onTailMutation: (args: { + /** Dependency whose current output should be reflected by the head. */ + tail: MutationNodeForType; + }) => void; + /** Removes or substitutes the head's reference when the tail is deleted. */ + onTailDeletion: (args: { + /** Deleted dependency; its output is not a usable value for the original slot. */ + tail: MutationNodeForType; + }) => void; /** * Called when the tail node is replaced with a new node. * @param args.oldTail - The original tail node that was replaced @@ -16,24 +29,43 @@ export interface MutationEdgeOptions { * If false, an edge already exists and should not be recreated. */ onTailReplaced: (args: { + /** Previous dependency, used to remove old names or other slot metadata. */ oldTail: MutationNodeForType; + /** Replacement dependency, which may have a different type kind. */ newTail: MutationNodeForType; + /** Dependent node whose reference must be updated. */ head: MutationNodeForType; + /** Whether this callback must establish a new edge to keep receiving changes. */ reconnect: boolean; }) => void; + /** Optionally transfers a dependency to a replacement head after the old edge is detached. */ onHeadReplaced?: (args: { + /** Head whose outgoing edge has been removed. */ oldHead: MutationNodeForType; + /** Replacement head, which may not support the same relationship. */ newHead: MutationNodeForType; + /** Dependency that the replacement head may reconnect to. */ tail: MutationNodeForType; }) => void; } +/** + * Directed reference from a dependent head to a dependency tail. + * Tail lifecycle events invoke relationship-specific callbacks on the head. + * Replacement detaches the old edge; callbacks own any reconnection. + */ export class MutationEdge { + /** Node containing the reference that depends on {@link tail}. */ public head: MutationNode; + /** Referenced node whose lifecycle this edge observes. */ public tail: MutationNode; #options: MutationEdgeOptions; #deleted: boolean = false; + /** + * Registers both ends and synchronizes an already mutated, synthetic, or + * replaced tail. Unlike {@link HalfEdge.setTail}, does not call `onTailCreation`. + */ constructor( head: MutationNode, tail: MutationNode, @@ -67,6 +99,7 @@ export class MutationEdge { } } + /** Constructs and registers a full edge with the same synchronization as the constructor. */ static create( head: MutationNode, tail: MutationNode, @@ -87,18 +120,24 @@ export class MutationEdge { this.tail.deleteInEdge(this); } + /** Dispatches the mutation callback unless the edge or its head has been deleted. */ tailMutated(): void { if (this.#deleted || this.head.isDeleted) return; traceEdge(this, "Tail mutated."); this.#options.onTailMutation({ tail: this.tail as any }); } + /** Dispatches the deletion callback unless the edge or its head has been deleted. */ tailDeleted() { if (this.#deleted || this.head.isDeleted) return; traceEdge(this, "Tail deleted."); this.#options.onTailDeletion({ tail: this.tail as any }); } + /** + * Detaches this edge and dispatches `onTailReplaced` with `reconnect: true`. + * The old tail defaults to this edge's tail; this method does not retarget the edge. + */ tailReplaced(newTail: MutationNode, oldTail?: MutationNode) { if (this.#deleted || this.head.isDeleted) return; traceEdge(this, "Tail replaced with " + newTail.id); @@ -115,6 +154,7 @@ export class MutationEdge { }); } + /** Detaches this edge and lets `onHeadReplaced` optionally reconnect its dependency. */ headReplaced(newHead: MutationNode) { if (this.#deleted || this.tail.isDeleted) return; traceEdge(this, "Head replaced with " + newHead.id); @@ -130,22 +170,31 @@ export class MutationEdge { }); } + /** Formats the endpoint IDs for traces and debugging. */ toString() { return `MutationEdge(head=${this.head.id}, tail=${this.tail.id})`; } } +/** Node-level reference whose dependency is supplied once, when it becomes available. */ export class HalfEdge { + /** Dependent node that starts the reference. */ public head: MutationNode; + /** Dependency, or `null` until {@link setTail} connects it. */ public tail: MutationNode | null; #options: MutationEdgeOptions; + /** Defers registration of the edge and its lifecycle callbacks until a tail is supplied. */ constructor(head: MutationNode, options: MutationEdgeOptions) { this.head = head; this.tail = null; this.#options = options; } + /** + * Calls `onTailCreation`, constructs a full edge, and returns it. + * Throws if this half-edge has already been completed. + */ setTail(tail: MutationNode): MutationEdge { if (this.tail) { throw new Error("HalfEdge already has a tail"); diff --git a/packages/mutator-framework/src/mutation-node/mutation-node.ts b/packages/mutator-framework/src/mutation-node/mutation-node.ts index f4dbaa6478a..9bab5bb33be 100644 --- a/packages/mutator-framework/src/mutation-node/mutation-node.ts +++ b/packages/mutator-framework/src/mutation-node/mutation-node.ts @@ -5,53 +5,64 @@ import { mutationNodeFor, type MutationNodeForType } from "./factory.js"; import type { MutationEdge } from "./mutation-edge.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; +/** Identity and origin metadata used when constructing a mutation node. */ export interface MutationNodeOptions { + /** Key distinguishing nodes for the same source type; defaults to `""`. */ mutationKey?: string; + /** Marks an introduced type so incoming connections propagate it even before cloning. */ isSynthetic?: boolean; } +/** + * Tracks a source type and its output in a parallel type graph. + * + * The output initially aliases the source. The first {@link mutate} clones it + * and notifies incoming reference edges, allowing dependent nodes to update. + * Obtain nodes through {@link MutationEngine.getMutationNode} to share one node + * per source type identity and key within an engine; direct construction is not cached. + */ export abstract class MutationNode { + /** Type kind handled by this node's connection methods. */ abstract readonly kind: string; + /** Process-local sequential identifier used in diagnostic strings and traces. */ id = nextId++; + /** Original type used to initialize this node; not replaced when output changes. */ sourceType: T; + /** Current output: initially the source, then a clone, or `never` after deletion. */ mutatedType: T; + /** Whether this node has been cloned or designated as an already-mutated replacement. */ isMutated: boolean = false; + /** Whether connections must propagate this introduced type without waiting for a mutation. */ isSynthetic: boolean = false; + /** Whether {@link delete} has removed this node's contribution to its dependents. */ isDeleted: boolean = false; + /** Whether {@link replace} has redirected references to another node. */ isReplaced: boolean = false; + /** Successor created by {@link replace}, or `null` before replacement. */ replacementNode: MutationNodeForType | null = null; /** If this node is a replacement for another node, this is the node it replaced */ replacedNode: MutationNodeForType | null = null; + /** References from dependent heads to this node as their tail. */ inEdges: Set> = new Set(); + /** References from this node as a head to the nodes it depends on. */ outEdges: Set> = new Set(); + /** Engine owning the node cache and typekit used for graph updates. */ engine: MutationEngine; + /** Context key used with source type identity in the engine's node cache. */ mutationKey: string; + /** Type utilities from {@link engine}, used for cloning and fallback types. */ $: Typekit; protected connected: boolean = false; #whenMutatedCallbacks: ((mutatedType: Type | null) => void)[] = []; + /** + * Initializes an uncached node whose output aliases `sourceNode`. + * An omitted key is empty and an omitted synthetic flag is false. + */ constructor( subgraph: MutationEngine, sourceNode: T, @@ -71,26 +82,42 @@ export abstract class MutationNode { traceNode(this, "Created."); } + /** Registers an incoming reference; does not register it with the head. */ addInEdge(edge: MutationEdge) { this.inEdges.add(edge); } + /** Removes incoming-edge bookkeeping only; use {@link MutationEdge.delete} to detach both ends. */ deleteInEdge(edge: MutationEdge) { this.inEdges.delete(edge); } + /** Registers an outgoing dependency; does not register it with the tail. */ addOutEdge(edge: MutationEdge) { this.outEdges.add(edge); } + /** Removes outgoing-edge bookkeeping only; use {@link MutationEdge.delete} to detach both ends. */ deleteOutEdge(edge: MutationEdge) { this.outEdges.delete(edge); } + /** + * Registers a callback for the first clone and for deletion (`null`). + * Registration does not replay an earlier mutation. Later initializer calls + * and replacement do not invoke these callbacks. + */ whenMutated(cb: (mutatedType: T | null) => void) { this.#whenMutatedCallbacks.push(cb as any); } + /** + * Clones on first use, runs the initializer, notifies callbacks and incoming + * edges, then finishes the clone through the typekit. + * + * Subsequent calls run only the initializer on the existing output. Deleted + * or replaced nodes ignore the call entirely. + */ mutate(initializeMutation?: (type: T) => void) { if (this.isDeleted || this.isReplaced) { traceNode(this, `Already deleted/replaced, skipping mutation.`); @@ -120,6 +147,11 @@ export abstract class MutationNode { this.$.type.finishType(this.mutatedType); } + /** + * Marks this node deleted, calls mutation callbacks with `null`, sets its + * output to `never`, and notifies incoming edges. Each relationship decides + * whether to remove its slot, substitute a fallback, or delete its head. + */ delete() { traceNode(this, "Deleting."); @@ -185,6 +217,7 @@ export abstract class MutationNode { return this.replacementNode; } + /** Formats the source name or kind, mutation key, and local ID for debugging. */ toString() { return `MutationNode(${"name" in this.sourceType && typeof this.sourceType.name === "string" ? this.sourceType.name : this.kind}, key=${this.mutationKey}, id=${this.id})`; } From 43835376c276ffe0c876e0eae6c3e32905edd052 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Thu, 15 Oct 2026 11:59:55 -0400 Subject: [PATCH 2/3] docs(mutator-framework): record graph documentation update --- .../changes/docs-jsdoc-mutator-graph-2026-9-15-11-59-53.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .chronus/changes/docs-jsdoc-mutator-graph-2026-9-15-11-59-53.md diff --git a/.chronus/changes/docs-jsdoc-mutator-graph-2026-9-15-11-59-53.md b/.chronus/changes/docs-jsdoc-mutator-graph-2026-9-15-11-59-53.md new file mode 100644 index 00000000000..73445732b7c --- /dev/null +++ b/.chronus/changes/docs-jsdoc-mutator-graph-2026-9-15-11-59-53.md @@ -0,0 +1,7 @@ +--- +changeKind: internal +packages: + - "@typespec/mutator-framework" +--- + +Document mutation node identity, lifecycle propagation, and edge callback ownership. \ No newline at end of file From d715c069fc227825ba8c931c502ac2c0d0a1a129 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Thu, 15 Oct 2026 12:28:34 -0400 Subject: [PATCH 3/3] docs(mutator-framework): use spellchecked graph terminology --- packages/mutator-framework/src/mutation-node/mutation-edge.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/mutator-framework/src/mutation-node/mutation-edge.ts b/packages/mutator-framework/src/mutation-node/mutation-edge.ts index d78fb5f71c6..6a8d64a7aa3 100644 --- a/packages/mutator-framework/src/mutation-node/mutation-edge.ts +++ b/packages/mutator-framework/src/mutation-node/mutation-edge.ts @@ -136,7 +136,7 @@ export class MutationEdge { /** * Detaches this edge and dispatches `onTailReplaced` with `reconnect: true`. - * The old tail defaults to this edge's tail; this method does not retarget the edge. + * The old tail defaults to this edge's tail; this method does not change the edge's endpoints. */ tailReplaced(newTail: MutationNode, oldTail?: MutationNode) { if (this.#deleted || this.head.isDeleted) return;