diff --git a/src/extension-api/node.ts b/src/extension-api/node.ts index a9ff456ac5..8b884a15b5 100644 --- a/src/extension-api/node.ts +++ b/src/extension-api/node.ts @@ -26,13 +26,21 @@ export type { NodeEntityId } /** * A 2D point as `[x, y]`. + * + * **Immutable tuple per D-immutability-enforcement (Hybrid C).** Attempts to + * mutate via `node.getPosition()[0] = X` raise a TypeScript error. Use + * {@link NodeHandle.setPosition} to move the node. */ -export type Point = [x: number, y: number] +export type Point = readonly [x: number, y: number] /** * A 2D size as `[width, height]`. + * + * **Immutable tuple per D-immutability-enforcement (Hybrid C).** Attempts to + * mutate via `node.getSize()[0] = X` raise a TypeScript error. Use + * {@link NodeHandle.setSize} to resize the node. */ -export type Size = [width: number, height: number] +export type Size = readonly [width: number, height: number] /** * LiteGraph node execution mode. @@ -374,8 +382,25 @@ export interface NodeHandle { /** * Returns all widgets on this node as `WidgetHandle` instances. * + * **Immutable view per D-immutability-enforcement (Hybrid C).** The returned + * array cannot be mutated (`push`, `splice`, `length =`, index assignment + * all raise TS errors). Each `WidgetHandle` is also surface-frozen — use + * the `WidgetHandle` setter methods (`setValue`, `setHidden`, etc.) to + * mutate widget state. To add or remove widgets, use + * {@link NodeHandle.addWidget} / future `removeWidget` (W6.P8.UNMIGRATABLE). + * + * @example + * ```ts + * // ❌ TS-ERR — readonly array; v1 patterns no longer compile + * node.getWidgets().push(newWidget) + * node.getWidgets()[0] = newWidget + * + * // ✅ Iterate / read freely + * for (const w of node.getWidgets()) console.log(w.name) + * const labels = node.getWidgets().map((w) => w.label) + * ``` */ - getWidgets(): readonly WidgetHandle[] + getWidgets(): ReadonlyArray> /** * Adds a new widget to this node. @@ -417,14 +442,47 @@ export interface NodeHandle { /** * Returns all input slots on this node. * + * **Immutable view per D-immutability-enforcement (Hybrid C).** The returned + * array and each slot are `Readonly` — `node.getInputs().push(...)`, + * `node.getInputs()[i] = X`, and `node.getInputs()[i].name = "x"` all raise + * TypeScript errors at compile time. Per-slot mutators (`setInputName`, + * `replaceInput`, bulk field setters) are tracked under + * W6.P8.UNMIGRATABLE / D-input-output-shape. + * + * @example + * ```ts + * // ❌ TS-ERR — readonly array; v1 patterns no longer compile + * node.getInputs().push({ name: 'x', type: 'INT' }) + * node.getInputs()[0].name = 'renamed' + * + * // ✅ Read / iterate freely + * const types = node.getInputs().map((s) => s.type) + * ``` */ - inputs(): readonly SlotInfo[] + getInputs(): ReadonlyArray> /** * Returns all output slots on this node. * + * **Immutable view per D-immutability-enforcement (Hybrid C).** Same + * read-only semantics as {@link NodeHandle.getInputs}. Per-slot mutators + * tracked under W6.P8.UNMIGRATABLE / D-input-output-shape. */ - outputs(): readonly SlotInfo[] + getOutputs(): ReadonlyArray> + + /** + * @deprecated Use {@link NodeHandle.getInputs} instead. Renamed to align + * with the `getX()` accessor convention (D11/D-immutability-enforcement). + * Will be removed in v1.0. + */ + inputs(): ReadonlyArray> + + /** + * @deprecated Use {@link NodeHandle.getOutputs} instead. Renamed to align + * with the `getX()` accessor convention (D11/D-immutability-enforcement). + * Will be removed in v1.0. + */ + outputs(): ReadonlyArray> // ── EVENTS ──────────────────────────────────────────────────────────────── diff --git a/src/extension-api/widget.ts b/src/extension-api/widget.ts index f143ab3e06..169a153175 100644 --- a/src/extension-api/widget.ts +++ b/src/extension-api/widget.ts @@ -381,6 +381,39 @@ export interface WidgetHandle { // ── OPTIONS BAG — type-specific overrides ───────────────────────────────── + /** + * Read-only snapshot of the full options bag for this widget. + * + * **Immutable per D-immutability-enforcement (Hybrid C).** The returned + * object is `Readonly` — `widget.options.min = 0`, + * `widget.options = {...}`, and `widget.options.values = [...]` all raise + * TypeScript errors at compile time. To mutate, use + * {@link WidgetHandle.setOption} per-key. Bulk `setOptions(opts)` is + * tracked under W6.P8.UNMIGRATABLE / D-widget-converge. + * + * Note: this is an accessor pair on the v2 surface. Reading is free; the + * setter intentionally does not exist on the public type. v1 patterns like + * `widget.options.serialize = false` should migrate to + * {@link WidgetHandle.setSerializeEnabled}; `widget.options.values = [...]` + * (combo refresh) migrates to a future `setValues` mutator (W6.P3). + * + * @example + * ```ts + * // ❌ TS-ERR — every option write raises a compile-time error + * widget.options.min = 0 + * widget.options = { min: 0, max: 100 } + * widget.options.serialize = false + * + * // ✅ Read freely + * const min = widget.options.min ?? 0 + * + * // ✅ Mutate via typed setters + * widget.setOption('min', 0) + * widget.setSerializeEnabled(false) + * ``` + */ + readonly options: Readonly + /** * Returns the per-instance override for `key`, or the class-default value * from `INPUT_TYPES` if no override has been set, or `undefined` if the key @@ -412,6 +445,39 @@ export interface WidgetHandle { */ setOption(key: string, value: unknown): void + // ── SERIALIZE VALUE — read-only accessor; D5 is the write path ─────────── + + /** + * The widget's current `serializeValue` function (or `undefined` if none is + * registered). + * + * **Accessor-only per D-immutability-enforcement (Hybrid C).** The setter + * intentionally does not exist on the public type — assignment + * (`widget.serializeValue = fn`) raises a TypeScript error. The v2 + * migration target is the {@link WidgetHandle.on | `on('beforeSerialize', fn)`} + * event (per D5), which is typed, async-capable, and composable across + * multiple extensions on the same widget. + * + * @deprecated v1 callers reading `widget.serializeValue` to invoke the + * function directly should subscribe to `'beforeSerialize'` instead. This + * read-only accessor exists for debugging / introspection only and may be + * removed once the v1 surface is fully retired. + * + * @example + * ```ts + * // ❌ TS-ERR — direct assignment no longer compiles + * widget.serializeValue = () => 'static value' + * + * // ✅ Subscribe to the typed event (D5) + * widget.on('beforeSerialize', (e) => { + * if (e.context === 'prompt') e.setSerializedValue('static value') + * }) + * ``` + * + * @stability experimental + */ + readonly serializeValue: ((...args: unknown[]) => unknown) | undefined + // ── EVENTS ──────────────────────────────────────────────────────────────── /** diff --git a/src/services/extension-api-service.ts b/src/services/extension-api-service.ts index 1237c5c9dd..0f4acc2137 100644 --- a/src/services/extension-api-service.ts +++ b/src/services/extension-api-service.ts @@ -56,7 +56,7 @@ import type { Size, DOMWidgetOptions } from '@/extension-api/node' -import type { WidgetHandle } from '@/extension-api/widget' +import type { WidgetHandle, WidgetOptions } from '@/extension-api/widget' import type { Unsubscribe } from '@/extension-api/events' import { stampBrand } from '@/extension-api/brand' import type { @@ -314,6 +314,25 @@ function createWidgetHandle(widgetId: WidgetEntityId): WidgetHandle { }) }, + // D-immutability-enforcement (Hybrid C): read-only snapshot of options + // bag. Public type is Readonly — TS-ERR on any assignment. + // Mutate via setOption(key, value). + get options() { + const opts = + world.getComponent(widgetId, WidgetComponentSchema)?.options ?? {} + return opts as Readonly + }, + // D-immutability-enforcement (Hybrid C): accessor only — no setter. v2 + // migration target is on('beforeSerialize') per D5. + get serializeValue() { + const schema = world.getComponent(widgetId, WidgetComponentSchema) + const fn = (schema?.options as Record | undefined)?.[ + 'serializeValue' + ] + return typeof fn === 'function' + ? (fn as (...args: unknown[]) => unknown) + : undefined + }, getOption(key: string): K | undefined { const opts = world.getComponent(widgetId, WidgetComponentSchema)?.options return (opts as Record | undefined)?.[key] as @@ -410,13 +429,20 @@ function createNodeHandle(nodeId: NodeEntityId): NodeHandle { getPosition(): Point { // Position is centralized in layoutStore (Yjs CRDT-backed) // See D13 §4 — layoutStore.ts is the source of truth for position/size + // D-immutability-enforcement: Point is a readonly tuple, so the literal + // must be widened via `as const`. const layout = layoutStore.getNodeLayoutRef(nodeId).value - return layout ? [layout.position.x, layout.position.y] : [0, 0] + return layout + ? ([layout.position.x, layout.position.y] as const) + : ([0, 0] as const) }, getSize(): Size { // Size is centralized in layoutStore (Yjs CRDT-backed) + // D-immutability-enforcement: Size is a readonly tuple, see getPosition. const layout = layoutStore.getNodeLayoutRef(nodeId).value - return layout ? [layout.size.width, layout.size.height] : [0, 0] + return layout + ? ([layout.size.width, layout.size.height] as const) + : ([0, 0] as const) }, getTitle() { return world.getComponent(nodeId, NodeVisualKey)?.title ?? '' @@ -540,7 +566,7 @@ function createNodeHandle(nodeId: NodeEntityId): NodeHandle { return createWidgetHandle(widgetId) }, - inputs() { + getInputs() { const conn = world.getComponent(nodeId, ConnectivityKey) return (conn?.inputSlotIds ?? []).map((slotId: SlotEntityId) => { const slot = world.getComponent( @@ -559,7 +585,7 @@ function createNodeHandle(nodeId: NodeEntityId): NodeHandle { } satisfies SlotInfo }) }, - outputs() { + getOutputs() { const conn = world.getComponent(nodeId, ConnectivityKey) return (conn?.outputSlotIds ?? []).map((slotId: SlotEntityId) => { const slot = world.getComponent( @@ -578,6 +604,14 @@ function createNodeHandle(nodeId: NodeEntityId): NodeHandle { } satisfies SlotInfo }) }, + /** @deprecated D-immutability-enforcement: use getInputs(). */ + inputs() { + return this.getInputs() + }, + /** @deprecated D-immutability-enforcement: use getOutputs(). */ + outputs() { + return this.getOutputs() + }, on: ((event: string, fn: (...args: unknown[]) => unknown): Unsubscribe => { if (event === 'positionChanged') {