Files
ComfyUI/tools/magic_patch/assets/frontend-conversion.md

557 lines
32 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
name: converting-custom-nodes
description: 'Converts third-party custom-node JS off deprecated/unpublished ComfyUI APIs onto the published node API. Use for Magic Patch conversion work, migrating a pack, or reviewing a generated patch. Triggers on: convert custom node, magic patch, migrate pack, port to node API, output.links, input.link, widgets.splice, converted-widget.'
---
# Converting Custom Nodes
## Magic Patch workspace
For this portable conversion run, `v2/comfy-api.d.ts` is the exact published
API contract and `.magic-patch/references/` contains every deep dive linked
below. Paths into a ComfyUI frontend source checkout are background references
and may not exist. Do not leave the pack workspace or invent a member absent
from the bundled contract.
Converts third-party pack JS from the old, unpublished ComfyUI internals onto
the published node API (`src/platform/nodeApi/`, specified in `docs/node_api_WIP.md`).
This is **migration, not compatibility work**. The goal is that the old surface
can be _deleted_, so never add a shim — rewrite the call site.
## When to Use
- Converting a pack that Magic Patch escalated (`convert()` reported it in
`escalated`, meaning the mechanical rules deliberately refused).
- Hand-writing a conversion for an upstream PR.
- Reviewing a generated patch before it ships.
Do **not** use it to make broken code work again by any means available. A
conversion that reintroduces the old coupling is worse than no conversion.
## The two invariants
Everything below serves these. If a conversion violates either, it is wrong even
if the pack appears to work.
**1. The wire format must be byte-identical.**
`graphToPrompt` output and the serialized workflow must not change. This is the
frontend's contract with the backend, and it holds for 1,801/1,801 packs today —
which makes it an extremely sharp detector. The most common way to break it is
disconnect-and-reconnect, which allocates **new link ids**. Use
`output.moveLinksTo()` when re-homing links.
**2. Behaviour must be equivalent, except where the old code threw.**
The generated test has two halves: `equivalence` (must pass on the original
_and_ the converted source) and `fix` (must fail before, pass after). If you
cannot state an equivalence claim, you do not yet understand the code well
enough to convert it.
## Steps
### 1. Read what the code is actually doing
Do not pattern-match on the API name. The census surfaces are misleading:
- A `widgets.splice(i, 1)` immediately followed by `splice(i, 0, w)` is **not a
reorder** — it is a cache-invalidation hack. Replace it with
`widget.setOption(key, value)`, which invalidates properly.
- `onDrawForeground` is often **not drawing**. 47% of draw-callback bodies never
touch a drawing primitive; they enforce size, poll for changes, or sync DOM
visibility. Those become `setSizeConstraints`, `widget.on('change')`, and the
widget mount lifecycle respectively.
### 2. Check whether the object is live or serialized
**The single most dangerous confusion in this work.** These look identical:
```js
node.inputs[0].link = null // live slot — convert
p.workflow.nodes[i].inputs[0].link = null // serialized JSON — LEAVE ALONE
```
The second is correct as written; rewriting it corrupts a working pack. Trace
the variable to its origin before touching anything named `link`, `links`,
`inputs`, `outputs` or `widgets`. If it came from `graphToPrompt`, a fetch, a
`JSON.parse`, or a `.workflow` property, it is data — not a graph.
### 3. Apply the mapping
| Old | New | Notes |
| ---------------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------- |
| `output.links.push(id)` | `output.connectTo(nodeId, inputRef)` | creating a connection |
| moving links between own outputs | `output.moveLinksTo(ref)` | **preserves link ids** — required for the wire gate |
| `output.links` (read) | `output.links()` | frozen snapshot, safe to iterate while disconnecting |
| `input.link = null` | `input.disconnect()` | check step 2 first |
| `input.link` (read) | `input.source()` / `input.isConnected` | |
| `node.type = x` | delete the line | usually a defensive no-op; true type replacement is a **gap** — punt it |
| `slot.type = x`, `slot.name = x` | `slot.modify({ type, name })` | atomic, one undo step; keeps existing links |
| `widget.type = 'converted-widget'` | `widget.setHidden(true)` | ⚠️ old hack also suppressed serialization — see `references/widgets.md` |
| `widgets.splice` to reorder | `widgets.reorder(names)` | throws on a partial list instead of dropping widgets |
| `widgets.splice(i,1)` + `splice(i,0,w)` | `widget.setOption(key, value)` | same index in/out = cache invalidation, **not** a reorder |
| `widgets.push(w)` | `widgets.add(def)` | |
| `widgets = [...]` / `widgets.length = n` | `widgets.remove(name)` | assignment drops renderer tracking; length skips teardown |
| `getCustomWidgets` POJO | `widgets.mount({ mount })` | |
| `{...input}` / `{...node}` | `.snapshot()` | accessors moved to the prototype, so spread yields nothing |
| `nodeType.prototype.onNodeCreated = ...` | `defs.extend(sel, b => b.onCreated(...))` | selector = the hook's existing guard clause |
| `nodeType.prototype.onExecuted = ...` | `b.onExecuted(node, result)` | see `references/node-definitions.md` |
| `nodeType.prototype.onConfigure = ...` | `b.onConfigured(node, data)` | |
| `nodeType.prototype.onRemoved = ...` | `b.onRemoved(node)` | |
| `widget.inputEl` | `widgets.mount({ render })` | the element arrives as `render`'s argument; there is no `inputEl` to read |
| `this.widgets.length = n` | remove by name | assigning length skips widget teardown |
| `+!!this.inputs[0].widget` | `input.isWidgetInput` | converted-widget sniffing |
| `onDrawForeground` (drawing) | `widgets.canvas({ draw })` | renders in canvas _and_ Nodes 2.0 |
| `onDrawForeground` (sizing) | `node.setSizeConstraints({ autoHeight })` | see `references/draw-callbacks.md` |
| `onDrawForeground` (polling) | `widget.on('change')` | pick the narrowest event |
| `extends LGraphNode` | `defs.define({ type, ... })` | virtual nodes use `resolve(ctx)` |
| `onConnectInput` returning `false` | `b.onBeforeConnect((node, e) => false)` | any listener refusing is enough |
| `getExtraMenuOptions` (node menu) | `b.addMenuItem({ label, run })` | entries accumulate; canvas/slot menus are still a **gap** |
Prefer `comfy.supports('...')` over version comparisons — see `docs/node_api_WIP.md` §2.
### 4. Watch for the frame-to-event shift
A draw callback recomputed from current state every repaint, so nothing ever
needed to announce a change. Declarations and decorations must be **set when the
value changes**. A port that calls `decorations.set()` from inside the old
callback body will appear to work and quietly run on every repaint forever.
### 5. Write the test before claiming success
State an equivalence claim and a fix claim. Run the file against both sources:
equivalence green on both, fix red on the original and green on the converted.
A fix-only test proves the conversion did _something_, not that it was safe.
### 6. Refuse when you should
Escalate or decline rather than guess when:
- You cannot tell whether the object is live or serialized (step 2).
- The replacement depends on intent you cannot recover — e.g. re-homing links
versus rebuilding a mirror have different correct answers.
- The published API has no destination. A missing API is a **core gap to file**,
not something to work around in a pack. `docs/node_api_WIP.md` §1 lists what is and is
not covered.
A refusal costs a round trip. A wrong rewrite of working code is invisible until
a user hits it.
A refusal must answer **why**. Keep one adjacent comment block that records:
1. the user-visible behavior;
2. the exact old mechanism;
3. the ownership, determinism, wire-format, renderer, scope or lifecycle
guarantee that makes the mechanism unacceptable;
4. the supported remainder and exact user loss (`INOPERABLE: nothing` when
there is none); and
5. the concrete API capability or policy change that would reverse the
decision, or why the behavior must remain host-owned.
“Not supported”, “no API”, “renderer internals are unavailable”, and a list of
removed property names are not reasons. They describe absence. If the behavior
is acceptable and only a public destination is absent, that is `API-GAP`, not
`REFUSED`. If the behavior survives through another published mechanism, name
that destination and refuse only the old technique.
**A partial conversion is worse than a punt.** Two checks enforce this and both
fail the whole file:
- `retires-the-old-surface` — no `X.prototype.foo = ...` may remain. Converting
a function body while leaving the prototype assignment that reaches it moves
nothing; the old surface still cannot be deleted, which is the only reason
this programme exists.
- `no-unknown-api-members` — every member you introduce must exist in the
published API. Do not invent a plausible-sounding method. If the capability
you need is absent, that is `api-gap`, and naming it precisely is the most
useful thing you can do.
Both of these caught real conversions that every other check passed.
## Keep the diff small — it is the message
These diffs are read by the pack's author, often as a pull request. A patch that
touches ten lines says _we moved you off two deprecated calls_. A patch that
rewrites the file says _we rewrote your code_, and it will be rejected on sight
even when it is correct. Restructuring you did not need is not neutral.
**Change the registration. Leave everything else where it is.**
```js
// before
app.registerExtension({
name: 'x.ShowText',
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === 'ShowText') {
function populate(text) {
/* 30 lines */
}
nodeType.prototype.onExecuted = function (message) {
populate.call(this, message.text)
}
}
}
})
```
Two conversions of that, both correct:
```js
// ✗ restructured — populate hoisted, dedented, renamed, signature changed.
// Every line of a 30-line helper shows as changed.
function populateText(node, lines) {
/* 30 lines, reflowed */
}
comfy.defs.extend('ShowText', (b) => b.onExecuted(populateText))
// ✓ minimal — the helper stays exactly where and as it was.
comfy.defs.extend('ShowText', (b) => {
function populate(text) {
/* 30 lines, byte-identical */
}
b.onExecuted((node, result) => populate.call(node, result.text))
})
```
Both work. The second is the one an author merges.
Rules that follow from this:
- **Do not hoist, reorder or rename anything** the conversion does not require.
Keep helper names, parameter names and their order.
- **Keep helpers nested where they were nested.** Pulling one to module scope
changes every line of it.
- **Do not normalise style.** Quotes, semicolons, spacing and line breaks are
the author's, not yours.
- **Do not "improve" adjacent code.** A bug next to your change is not your
change.
### Indentation: match the original where you can, fix it where you must
The patch gets applied to the author's working tree, so a diff that minimises
its own size by leaving the body at its old depth ships them badly indented
code. That is a worse outcome than a larger diff.
The rule, in order:
1. **Prefer the original indentation.** If a line's nesting depth has not
actually changed, do not touch its leading whitespace.
2. **Re-indent when the nesting genuinely changed.** Removing a
`registerExtension` wrapper takes two levels off everything inside it, and
the result has to be correctly indented. Do it.
3. **Never re-indent anything whose nesting did not change.** That is the
churn worth eliminating, and it is entirely elective.
So the thing to avoid is not re-indentation, it is _gratuitous restructuring_ —
hoisting a helper to module scope, renaming its parameters, reflowing a function
the conversion never touched. Those change every line of code that did not need
to change. Correct indentation is not negotiable; unnecessary movement is.
Across the current database the unavoidable dedent accounts for 17–39% of added
lines. `run_checks` reports the proportion so you can see whether yours is in
that range or well past it.
## Recognise the intent, not the call
Most old-API code is a workaround for something missing. Port the call and you
carry the workaround across; recognise what it was _for_ and it usually
collapses. Check these before converting anything mechanically.
**If you are converting `api.addEventListener('b_preview')` or
`'b_preview_with_metadata'` or `'executing'`** — check whether the pack is
correlating frames to one node (a module-level `execId`, a
`displayNodeId === this.id` test, a `serverSupportsFeature` probe). That whole
apparatus answers "is this frame mine?". **Use `b.onPreview((node, frame) =>
…)`**, which answers it for you. Delete the global; it also mis-attributes
frames when two nodes preview at once.
**If you are converting `onDrawForeground` / `onDrawBackground` / anything
using `ctx`** — check what it actually draws. Rectangles, images, text and
lines are all a canvas can give you and all a DOM can. **Use
`node.widgets.canvas({ name, height, draw(ctx, [w, h]) })`** and keep the
drawing code as it is. It renders under both the old graph renderer and Nodes
2.0. Do not reach for the graph's shared context — that is the thing that ties
a pack to the old renderer.
**If you are converting hand-rolled hit testing** — bounding-box maths against
`node.pos`/`node.size`, pointer capture on `document`, hit tests against link
curves. Check whether it exists only because canvas has nothing to attach a
listener to. **Mount the control with `node.widgets.mount(...)` and use
ordinary DOM events**; most of the geometry disappears rather than being
ported.
**If you are converting `node.addDOMWidget(...)`** — **use
`node.widgets.mount({ name, render(container), destroy() })`**. Put the teardown
in `destroy`: a mounted element owns listeners, timers and observers that node
removal would otherwise leave running.
If that DOM widget's `getValue` / `setValue` held an editor document, give the
mount a `defaultValue` and synchronize through the `MountedValue` passed to
`render`. Set `serialize: true` and, for frontend-only state, `sendToPrompt:
false`; use the widget's `beforeSerialize` event when the live editor must be
sampled immediately before writing. Once the document is a real widget value,
normal workflow serialization also carries it through paste and duplicate — a
`clone()` override and side cache are not another requirement.
Count those cells per widget before consolidating an interface. Two legacy DOM
rows with workflow serialization enabled produce two positional
`widgets_values` entries even when both values are empty and neither reaches
the prompt. Replacing them with one larger mount changes the wire format; keep
two mounts, or prove from the widget flags that one row was never serialized.
If the old editor patched `ChangeTracker.undoRedo` to keep Ctrl+Z local, do not
port the patch. Focused `input`, `textarea`, and `contenteditable` elements own
their undo history, including while auto-queue-on-change is watching edits.
Mount the editor and keep its own undo/redo handlers on that element.
**If you are converting `node.addInput` / `removeInput` / `addOutput`** — the
pack is almost certainly growing slots as the last one fills (the "Multi"
combiner shape). **Use `node.inputs.add(name, type)` / `node.inputs.remove(ref)`**
and the matching `node.outputs` calls, driven from `b.onConnectionsChanged`.
**If you are converting `canvas.selected_nodes` / `selectedItems`** — the pack
wants the user's current selection, and is reaching into the canvas for it.
**Use `comfy.graph.selection()`**, which returns node handles.
**If you are writing to a node or widget handle** — every read and write is a
**method**, never a property: `setTitle`, `setColor`, `setBgColor`, `setMode`,
`setCollapsed`, `setProperty`, `getSize`/`setSize({ width, height })` on nodes;
`getValue`/`setValue`, `isHidden`/`setHidden`, `setOption` on widgets. Property
syntax compiles and silently does nothing. See the table in
`references/node-definitions.md`.
**If the pack registered a custom renderer for a backend-declared widget
type** — **use `comfy.defs.defineWidgetType(type, { render })`**, not an extra
mounted widget. The renderer receives the declared widget's value, so it keeps
the same positional `widgets_values` cell. Use `context.onNodeReady` when its
DOM needs the owning node; constructors run before a node has an id or graph.
**If you are converting `registerCustomNodes` / `extends LGraphNode` /
`isVirtualNode` / `applyToGraph`** — identify which of four intents the node
serves (annotation, wire, value, or acting on other nodes) and use **`comfy.defs.define`**
with `execution: 'frontend'` and, for wires and values, a pure `resolve`. See
`references/node-definitions.md` — nodes that act on others mutate neighbours through
handles in widget callbacks, never in `resolve`.
**If the pack deletes and recreates a node to repair it after definitions
change** — **use `comfy.graph.replace(node.id, node.type)`**. Same-type
replacement intentionally creates a fresh registered instance while retaining
the user's values, properties and compatible links in one undo step.
**If you are converting `onResize` / `computeSize` / a per-frame `setSize`** —
check whether the pack is enforcing a minimum, or growing to fit something it
mounted. **Use `node.setSizeConstraints({ minWidth, minHeight, maxWidth,
maxHeight, autoHeight })`** once, rather than re-asserting size on every frame.
`autoHeight` is usually the real intent.
**If you are converting `onSerialize` / `chainCallback(node, 'onSerialize')`** —
the pack is saving its own state into the node. **Use `b.onSerialize((node) =>
({ myKey: … }))`**; it comes back through `b.onConfigured`. Core fields —
`type`, `widgets_values`, `inputs`, `pos` and the rest — are ignored if you
return them, because changing those changes what the workflow means.
**If you are converting `onConnectInput` / `onConnectOutput`** — check what the
pack does with the return value. If it returns `false` to refuse a wire, **use
`b.onBeforeConnect((node, e) => …)`** and return `false` to refuse; `e` carries
`side`, `index`, `peerNodeId` and `peerType`. Any listener refusing is enough —
one pack cannot be overruled by another's silence. If it does not return
`false`, it is only observing, so **use `b.onConnectionsChanged`** instead: a
veto that never vetoes is a listener wearing the wrong hat.
**If you are converting `getExtraMenuOptions` or a `ContextMenu` the pack builds
itself** — check whether it is a menu _on a node_. If so, **use
`b.addMenuItem({ label, run(node) })`**; entries from every pack accumulate
rather than overwrite. A `ContextMenu` constructed to build a canvas-wide or
slot menu is not this, and is still a gap — punt it and name it.
**If you are converting a pack that writes its own name-keyed shape into
`widgets_values`** (a dict keyed by widget name, rather than the positional
array) — the pack is reaching for something the new model already gives it.
**Widget values are keyed by name at runtime**: `widgetValueStore` is keyed by
`graphId:nodeId:name` (`src/types/widgetId.ts`), with no index in the identity.
The positional array is only the legacy _serialized_ form, which is why
`widgets_values` is reserved.
So **delete the override, do not translate it.** Address widgets by name — that
is native — and let core serialize the positional array as it already does. The
pack's serialize hook largely disappears rather than being ported.
What you must keep is the _reading_ of old workflows. Core assigns positionally
and _then_ calls `onConfigure`, so `b.onConfigured` receives the saved node with
its legacy shape intact. Port the pack's rename maps, retired-widget handling
and positional-to-named fallbacks into that hook: those are the conversion, and
dropping them silently loses user data.
**If you are converting reads of `canvas.connecting_links`, `resizing_node` or
`node_widget`** — the pack is asking one question, not three: _is the editor
already mid-gesture?_ **Use `comfy.isInteracting()`** and stand down while it is
true. Do not reach for the individual fields; which gestures exist is the
editor's business and will change.
**If you are converting a pack that listens on `document` for pointer moves to
build an editing gesture** (drag one node onto another, shake to disconnect,
drop onto a link) — **use `comfy.onNodeMoved`**, which reports node movement
under both renderers from one subscription. Two cautions: it does not say
whether a _person_ moved the node, so guard your own writes against re-entry;
and for a gesture that commits on release, pair it with
`comfy.onNodeDragEnd(nodes => …)`, which reports every node the drag moved.
`onNodeDragEnd` is **Nodes 2.0 only** — the legacy canvas renderer publishes no
drag lifecycle, so it never fires there. Say so in your summary rather than
pretending the conversion is renderer-neutral.
**If you are converting `canvas.setDirty(...)` / `setDirtyCanvas(...)`** —
**delete it.** There is no published repaint request, deliberately. Handle
writes invalidate on their own, and a `widgets.canvas` surface has `redraw()`.
If something genuinely fails to repaint after a handle write, that is a bug in
the API — report it rather than working around it.
**If you are converting `addWidget('button', name, null, callback)` or any
widget whose callback is an action rather than a value change** — **use
`widget.on('activate', fn)`**. A button's value never moves, so `on('change')`
can never fire for one. Keep the widget itself (`widgets.add({ type: 'button',
name, value: null })`) — its `widgets_values` entry is positional, and dropping
it shifts every widget after it.
**If you are converting `registerExtension({ commands, keybindings })` or
`app.extensionManager.toast`** — **use `comfy.commands`**:
`register({ id, label, run, keybinding })` declares the command and binds its
key together, and `notify({ severity, summary, detail })` raises a toast. Ids
must be namespaced. The key is registered as a _default_, so a user's own
binding survives the pack re-registering on every load.
**If you are converting `api.apiURL(...)` or `api.addEventListener(...)`** —
**use `comfy.backend`**: `url('/view?…')` builds a route that honours how the
host is served, and `on(event, detail => …)` subscribes to any backend message,
including one your own Python side emits. For the built-in preview channel
prefer `b.onPreview`, which answers "is this frame for my node?" — `backend.on`
hands you the raw payload and leaves correlation to you.
**If the pack adds its own file input beside a backend COMBO** — inspect the
Python declaration before porting it. `image_upload`, `animated_image_upload`
and `video_upload` in that input's options tell the host to supply the chooser,
upload and preview. Delete the duplicate UI; adding another widget changes the
positional wire format while recreating behavior core already owns.
If a pack rewrites `graphToPrompt` only to name a cache or sidecar file, inspect
its Python before declaring the feature impossible. A pack-owned preview route
may already serve file inputs without a graph run; connected tensors can use
`queue.run({ nodes: [node] })`, then refresh the sidecar from `b.onExecuted` and
`execution_cached`. A hidden id declared as `UNIQUE_ID` is supplied by the host.
A hidden string default such as `"0"` is instead a pack-backend identity bug:
record the simultaneous-node limitation precisely, but do not mislabel the
published queue, execution and backend mechanisms as missing.
**If you are converting `app.ui.settings.getSettingValue` / `addSetting`, or a
`settings: [...]` array in `registerExtension`** — **use `comfy.settings`**:
`declare({ id, name, type, defaultValue })` once at load, then `get(id)` and
`await set(id, value)`. Ids must be namespaced (`MyPack.thing`) — one flat space
is shared with core and every other pack, and the id is where the value lives
permanently. Re-declaring does not reset a stored value, so declaring on every
load is correct.
**If the pack replaces the canvas background draw method only to show an
image** — **write `Comfy.Canvas.BackgroundImage` through `comfy.settings`**.
The host loads and redraws it under both renderers. Preserve and restore the
previous setting when the pack's temporary background mode stops.
**If you are converting a read of `LiteGraph.NODE_SLOT_HEIGHT`,
`NODE_TITLE_HEIGHT`, `ROUND_RADIUS` or `vueNodesMode`** — **use
the published answer to the operation, not another numeric renderer constant**.
`getBounds`, `getSlotPosition`, `getScreenRect`, `widgets.mount` and
`widgets.canvas` already account for layout and renderer choice. There is no
published `comfy.constants`; if behavior truly requires reproducing the
renderer geometry after those mechanisms are considered, name that specific
gap rather than inventing one.
**If you are converting `this._somethingPrivate = x` on a node** — handles hold
no arbitrary properties. **Keep a `Map` keyed by `node.id`** and clear the entry
in `b.onRemoved`. This is supported, not a workaround; the old property was
collected with the node and a Map is not.
**If you are converting `node.imgs = [img]` + `setDirtyCanvas`** — the pack is
showing an image on the node. **Use `widgets.canvas` and `drawImage` in
`draw`**, then `redraw()` when the image changes.
**If you are converting a captured-and-chained `widget.callback`** — check
whether it only wants to know the value changed. **Use `widget.on('change', (v,
old) => …)`**, which is additive: no other pack can drop your listener by
forgetting to call through.
**If you are converting `widget.serializeValue`** — classify its return. A
constant `undefined` becomes `serialize: false`; a synchronous substitute uses
`widget.on('beforeSerialize', event => event.setSerializedValue(value))`. If it
awaits derived state, an async `comfy.queue.guard` may commit that state before
the prompt is built, but guards time out after five seconds. Anything that can
legitimately take longer remains an API gap rather than a safe conversion.
Note the two flags are distinct: `options.serialize` gates the API prompt,
`widget.serialize` gates workflow persistence.
**If you are converting `widget.type = 'converted-widget'`** — the pack is
hiding a widget, not changing its kind. **Use `widget.setHidden(true)`.** The
`origType`/`origComputeSize`/`origSerializeValue` bookkeeping around it existed
only to undo the hack; it has no readers and goes away.
## Pattern references
Deep dives, loaded only when relevant. `SKILL.md` stays short on purpose; detail
lives here.
| Reference | Covers |
| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `references/nodegraph-101.md` | **Read first if you have not converted a pack before.** What a node graph is, the definition/class/instance distinction, the lifecycle, workflow vs prompt, and why packs patch prototypes at all. |
| `references/node-definitions.md` | `beforeRegisterNodeDef` + prototype patching — **1,265 packs, 47.4% of installs, the largest surface**. The selector is already written as the hook's guard clause. |
| `references/widgets.md` | Widget-array mutation and the converted-widget protocol — 286 packs / 21.6%, overlapping cohorts totalling more. Where naive conversions are most often silently wrong. |
| `references/draw-callbacks.md` | `onDraw*` — 420 packs, 32.2% of installs. Measured breakdown showing 47% never draw at all, a decision tree, and the canvas→CSS mapping. |
### Adding a capability
If a conversion is blocked and the fix is a **new API capability**, the addition
is a design decision, not a detail. Record it in `docs/node_api_WIP.md` §4f
alongside: what forced it, the alternative that was rejected, and what it would
cost to reverse. An addition made under conversion pressure with its rationale
only in a commit message is one nobody can argue with later.
### Adding a pattern
Add a reference when a pattern is (a) seen in real pack code, not anticipated,
and (b) large or subtle enough that the mapping table row is insufficient.
Each reference should carry:
1. **How common it is**, measured — not estimated. Counts come from the corpus
at `~/comfy/nodes-compat-study/`, and should be labelled grep-derived where
they are.
2. **What the code is actually doing**, which is often not what the API name
suggests. Classify before mapping.
3. **Real before/after**, quoted from a named pack and file.
4. **The trap** — the way a plausible conversion silently goes wrong.
If a pattern turns out to be mechanically safe, add it to
`conversion/rules.ts` instead: a rule with golden cases beats prose, because CI
runs it. Prose is for the judgement calls.
## Where things live
| | |
| ----------------------------------- | --------------------------------------------------------- |
| Published API spec | `docs/node_api_WIP.md` |
| Published API code | `src/platform/nodeApi/` |
| Rule catalog (per-pattern guidance) | `src/workbench/extensions/magicPatch/conversion/rules.ts` |
| Verdict grading | `src/workbench/extensions/magicPatch/verify/verdict.ts` |
| Programme design | `docs/magic_patch_WIP.md` |
The rule catalog's `guidance` field is injected into the agent prompt for
**only the rules that matched**, so this skill covers method and the catalog
covers specifics. Keep it that way — duplicating pattern detail here means it
will drift.
## Checklist
- [ ] Traced every `link`/`links`/`widgets` reference to live-vs-serialized
- [ ] No shim, no compatibility wrapper, no reintroduced old API
- [ ] Link ids preserved where links were moved
- [ ] Equivalence claim stated and passing on both sources
- [ ] Fix claim red on the original, green on the converted source
- [ ] `graphToPrompt` and serialized workflow byte-identical
- [ ] Anything uncertain escalated rather than guessed