feat(extension-api): apply Hybrid C Readonly typing per D-immutability-enforcement

Implements the W6.P8.C foundation slice of D-immutability-enforcement
(ACCEPTED 2026-05-14, Hybrid C):

* Point/Size are now readonly tuples — getPosition()[0] = X and
  getSize()[0] = X raise TS-ERR at compile time.
* getWidgets() now returns ReadonlyArray<Readonly<WidgetHandle>>
  — node.getWidgets().push(...) and []= raise TS-ERR.
* inputs()/outputs() are renamed to getInputs()/getOutputs() returning
  ReadonlyArray<Readonly<SlotInfo>>. Old names kept as @deprecated
  aliases for one minor release; SlotInfo fields were already readonly.
* New WidgetHandle.options: Readonly<WidgetOptions> accessor — bulk read
  of the options bag; widget.options.min = 0 and widget.options = {...}
  raise TS-ERR. Mutate via setOption(key, value).
* New WidgetHandle.serializeValue accessor-only — direct assignment
  raises TS-ERR; the v2 path is on('beforeSerialize') per D5.
* widget.value (setValue/getValue pair) is unchanged — already ZERO row
  in R3 per D14.

Zero runtime cost — no Object.freeze, no Proxy. The 'as any' /
'@ts-ignore' / JS-not-TS escape gap is the explicit trade per ADR; the
follow-up W6.P8.FREEZE closes that gap when prioritized.

Refs: decisions/D-immutability-enforcement.md (ACCEPTED 2026-05-14)
      research/architecture/D-immutability-enforcement-blast-radius.md
      W6.P8.B PICK (handoff-6, captured in tasks/W6-P8-B.lock)

Phase A — gates: lint OK / format:check OK / knip OK.
typecheck/test failures on the v2 stack are EXPECTED per AGENTS.md
Rule #8 until rebased onto Alex's ECS branch (PR #11939).
This commit is contained in:
Connor Byrne
2026-05-18 13:34:58 -07:00
parent a058a410ac
commit df921f3512
3 changed files with 168 additions and 10 deletions

View File

@@ -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<Readonly<WidgetHandle>>
/**
* 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<Readonly<SlotInfo>>
/**
* 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<Readonly<SlotInfo>>
/**
* @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<Readonly<SlotInfo>>
/**
* @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<Readonly<SlotInfo>>
// ── EVENTS ────────────────────────────────────────────────────────────────

View File

@@ -381,6 +381,39 @@ export interface WidgetHandle<T = WidgetValue> {
// ── 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<WidgetOptions>` — `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<WidgetOptions>
/**
* 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<T = WidgetValue> {
*/
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 ────────────────────────────────────────────────────────────────
/**

View File

@@ -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<WidgetOptions> — TS-ERR on any assignment.
// Mutate via setOption(key, value).
get options() {
const opts =
world.getComponent(widgetId, WidgetComponentSchema)?.options ?? {}
return opts as Readonly<WidgetOptions>
},
// 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<string, unknown> | undefined)?.[
'serializeValue'
]
return typeof fn === 'function'
? (fn as (...args: unknown[]) => unknown)
: undefined
},
getOption<K = unknown>(key: string): K | undefined {
const opts = world.getComponent(widgetId, WidgetComponentSchema)?.options
return (opts as Record<string, unknown> | 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') {